Repository navigation
fix: a failed prompt leaves no workspace verdict, and the workspace telemetry review's follow-ups #1711
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix: a failed prompt leaves no workspace verdict, and the workspace telemetry review's follow-ups #1711
Changes from all commits
bc9e1c5
4d31646
01644f2
88756ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,9 +10,13 @@ | |
| // agent does the work with other tools. So a job here is a turn with at least one tool call that | ||
| // didn't fail, ending `completed`, the outcome `task_outcome_signal` reports as `accepted`. It | ||
| // reports as `something_else` because nothing says which job it was. | ||
| // | ||
| // With the workspace kill switch set, no first event names the project's saved link, so a project | ||
| // linked before the switch was set isn't counted as that workspace's first prompt or job. | ||
| import { createHash } from "node:crypto" | ||
| import { mkdir, writeFile } from "node:fs/promises" | ||
| import path from "node:path" | ||
| import { Flag } from "@opencode-ai/core/flag/flag" | ||
| import { Global } from "@/global" | ||
| import { Log } from "@/altimate/util/log" | ||
| import { Telemetry } from "@/altimate/telemetry" | ||
|
|
@@ -80,8 +84,38 @@ export async function claim(event: FirstEvent, workspaceId: number, scope?: Scop | |
| } | ||
| } | ||
|
|
||
| /** The credential scope of the turn each tool call ran in, while that call's `tool.execute.after` | ||
| * plugins run. The trigger site notes it from its own turn, so a mark made there names the tenant the | ||
| * call's workspace id belongs to, even when the credential has switched since, or the turn was | ||
| * cancelled and replaced before the tool returned. Keyed by call id, not session: a session's later | ||
| * turn has its own calls. */ | ||
| const callScopes = new Map<string, Scope>() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined for this PR, recorded as R5 on the review log. The overwrite needs two tool calls running at the same time in one process with the same call id. The id is the provider's Keying by something unique to each execution would mean adding it to the plugin hook input, which this PR keeps unchanged, or carrying it in async context. Either is a bigger change than this case warrants before this release. |
||
|
|
||
| /** Notes `scope` for `callID` until the returned handle is disposed. */ | ||
| export function noteCallScope(callID: string | undefined, scope: Scope | undefined): Disposable { | ||
| if (callID === undefined || !scope) return { [Symbol.dispose]() {} } | ||
| callScopes.set(callID, scope) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor (Codex). The key is the raw When two overlapping calls share an id under different turn credentials, the second This is an edge input that costs one first-event attribution. Safe in a follow-up, for example by keying on the processor's execution identity. |
||
| return { | ||
| [Symbol.dispose]() { | ||
| if (callScopes.get(callID) === scope) callScopes.delete(callID) | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| /** The scope noted for a tool call, while its plugins run. */ | ||
| export function callScope(callID: string): Scope | undefined { | ||
| return callScopes.get(callID) | ||
| } | ||
|
|
||
| /** The workspace a first event may name: none while the workspace kill switch is set. Nothing is | ||
| * marked then either, so the workspace's rows are still sent once the switch is off. */ | ||
| export function firstEventWorkspace(workspaceId: number | undefined): number | undefined { | ||
| return Flag.ALTIMATE_DISABLE_WORKSPACE ? undefined : workspaceId | ||
| } | ||
|
|
||
| /** A typed prompt: the onboarding row and the workspace row are one row when both apply. */ | ||
| export async function promptSent(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise<void> { | ||
| export async function promptSent(sessionID: string, turnWorkspaceId: number | undefined, scope?: Scope): Promise<void> { | ||
| const workspaceId = firstEventWorkspace(turnWorkspaceId) | ||
| const onboarding = OnboardingTelemetry.isOnboardingSession(sessionID) && OnboardingTelemetry.claimFirstPrompt(sessionID) | ||
| const inWorkspace = workspaceId !== undefined && (await claim("first_prompt_sent", workspaceId, scope)) | ||
| if (onboarding || inWorkspace) { | ||
|
|
@@ -93,7 +127,12 @@ export async function promptSent(sessionID: string, workspaceId: number | undefi | |
| } | ||
|
|
||
| /** A turn in a workspace that ended `completed` with at least one tool call that didn't fail. */ | ||
| export async function jobCompleted(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise<void> { | ||
| export async function jobCompleted( | ||
| sessionID: string, | ||
| turnWorkspaceId: number | undefined, | ||
| scope?: Scope, | ||
| ): Promise<void> { | ||
| const workspaceId = firstEventWorkspace(turnWorkspaceId) | ||
| if (workspaceId === undefined) return | ||
| if (await claim("first_job_completed", workspaceId, scope)) { | ||
| await OnboardingTelemetry.emit( | ||
|
|
@@ -106,5 +145,6 @@ export async function jobCompleted(sessionID: string, workspaceId: number | unde | |
| /** Test seam: drops this process's memory of the marks, as a new process would start. */ | ||
| export function resetForTest() { | ||
| sent.clear() | ||
| callScopes.clear() | ||
| warnedUnwritable = false | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -125,7 +125,13 @@ import { Telemetry } from "@/telemetry" // altimate_change — session telemetry | |
| import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_change — error_fingerprint payload | ||
| // altimate_change start — the session's bound workspace rides on every telemetry row; workspace rules canonicalize bound | ||
| // directories like the bindings cache does | ||
| import { canonicalDirectory, endTurnBinding, turnBinding, unvalidatedTurnBinding } from "@/altimate/workspace/state" | ||
| import { | ||
| canonicalDirectory, | ||
| endTurnBinding, | ||
| turnBinding, | ||
| unvalidatedTurnBinding, | ||
| type AccountKey, | ||
| } from "@/altimate/workspace/state" | ||
|
|
||
| /** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the | ||
| * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the | ||
|
|
@@ -1825,6 +1831,7 @@ export namespace SessionPrompt { | |
| // altimate_change start — file rules append only to this tool result | ||
| lessons, | ||
| workspaceId: boundWorkspace?.datamateId, | ||
| workspaceScope: boundWorkspace?.scope, | ||
| turn: telemetryTurn, | ||
| // altimate_change end | ||
| }), | ||
|
|
@@ -2845,6 +2852,8 @@ export namespace SessionPrompt { | |
| /** The workspace the calling turn resolved, for the rows its MCP calls emit, and the turn itself, | ||
| * so a call landing after the turn was replaced does not count toward the replacement. */ | ||
| workspaceId?: number | ||
| /** The credential that turn's workspace was resolved under, noted for each tool call's plugins. */ | ||
| workspaceScope?: AccountKey | ||
| turn?: number | ||
| // altimate_change end | ||
| }) { | ||
|
|
@@ -2970,6 +2979,8 @@ export namespace SessionPrompt { | |
| // altimate_change start — stamp authoritative tool source so clients render the right | ||
| // badge. Shared with SessionTools.resolve (session/tools.ts) so the resolvers can't drift. | ||
| const stamped = stampRegistryToolSource(output, item) | ||
| // The turn's credential, for this call's plugins: a first-event mark names its tenant. | ||
| using _callScope = WorkspaceFirstEvents.noteCallScope(ctx.callID, input.workspaceScope) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor (Codex). Disposal happens only when this block exits. A |
||
| // altimate_change end | ||
| await Plugin.trigger( | ||
| "tool.execute.after", | ||
|
|
@@ -3195,6 +3206,14 @@ export namespace SessionPrompt { | |
| ? await unvalidatedTurnBinding(Instance.directory) | ||
| : await turnBinding(input.sessionID, Instance.directory) | ||
| )?.datamateId | ||
| // Every part settles before a failed one is rethrown. A part still running after another failed could | ||
| // otherwise read the turn's workspace once `prompt` has cleared it, and leave a verdict for the next | ||
| // loop started without a prompt (summarize, resume) to take. | ||
| function settled<T>(results: PromiseSettledResult<T>[]): T[] { | ||
| const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor (Codex). |
||
| if (failed) throw failed.reason | ||
| return results.map((result) => (result as PromiseFulfilledResult<T>).value) | ||
| } | ||
| // altimate_change end | ||
| const agentName = input.agent ?? (await Agent.defaultAgent()) | ||
| const agent = await Agent.get(agentName) | ||
|
|
@@ -3250,7 +3269,9 @@ export namespace SessionPrompt { | |
| id: part.id ? PartID.make(part.id) : PartID.ascending(), | ||
| }) | ||
|
|
||
| const parts = await Promise.all( | ||
| // altimate_change start — every part settles before a failure is rethrown (see `settled`) | ||
| const parts = await Promise.allSettled( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: A failed prompt now waits for every attachment, so an attachment that never settles withholds the error indefinitely.
Suggestion: keep waiting for the parts that settle, but bound the wait (per-part timeout, or race the settled set against a deadline) so the original error is still reported when a sibling hangs.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined for this PR, recorded as R6 on the review log. The hang comes from
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor (Codex; reproduced by the lead). A failed prompt now waits for every sibling. If a ranged Probe: a missing directory plus Not a blocker: on main the same hung lookup already stalls every prompt with that attachment, and no loop starts. Safe in a follow-up: bound |
||
| // altimate_change end | ||
| input.parts.map(async (part): Promise<Draft<MessageV2.Part>[]> => { | ||
| if (part.type === "file") { | ||
| // before checking the protocol we check if this is an mcp resource because it needs special handling | ||
|
|
@@ -3580,7 +3601,9 @@ export namespace SessionPrompt { | |
| }, | ||
| ] | ||
| }), | ||
| ).then((x) => x.flat().map(assign)) | ||
| // altimate_change start — every part settles before a failure is rethrown (see `settled`) | ||
| ).then((x) => settled(x).flat().map(assign)) | ||
| // altimate_change end | ||
|
|
||
| await Plugin.trigger( | ||
| "chat.message", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION:
callScope(input.callID)is only populated on the native registry path, so this claim silently falls back to the credential at tool-return time on other paths.WorkspaceFirstEvents.noteCallScopeis called only inresolveTools's native tool wrapper (packages/opencode/src/session/prompt.ts:2983). The MCP wrapper also firestool.execute.after(prompt.ts:3090-3100) and thetasktool fires it too (prompt.ts:1420-1430), but neither notes a scope. The MCP loop assignstools[key] = itemafter the native loop (prompt.ts:3163), so an MCP server exposing a tool namedsample_setupreplaces the native one and reaches this hook through the un-noted path.callScopeis thenundefined, andclaim()falls back tocurrentScope()(packages/opencode/src/altimate/workspace/first-events.ts:53), which reads the credential now — the exact cross-tenant read this PR removes.This is latent today because
sample_setupis native, but the new mechanism's invariant is "the trigger site notes the scope". Noting it at the MCP (andtask) trigger sites, or deriving the scope from the turn context rather than a single call site, keeps the invariant true.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declined, recorded as R7 on the review log: not reachable for this mark.
Only
sample_setupcompletes a job (isJobCompletion,onboarding-telemetry.ts:183), and only the native tool reaches it by that name. Thetasktrigger isn't a job (jobForToolmaps notask). An MCP tool's key is<server>_<tool>(mcp/index.ts:1251), so it would take a server namedsamplewith a tool namedsetup. Even then the MCP trigger passes the raw call result, which carries nometadata.success, soisJobCompletionis false and nothing is claimed. If a job-completing tool is ever added on another path, its trigger site should note the scope as the native one does.