Repository navigation
fix: a failed prompt leaves no workspace verdict, and the workspace telemetry review's follow-ups - #1711
fix: a failed prompt leaves no workspace verdict, and the workspace telemetry review's follow-ups#1711ralphstodomingo wants to merge 4 commits into
Conversation
…st gaps - Every attachment part settles before a failed one is rethrown, so a late part can't put back the cleared verdict. Tested. - The first-events loop test runs with the workspace feature on. - The telemetry event list has both first-event types. - A comment no longer names a branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8
…link States what the CLI already does: a cached link still names the turn's rows and still sends the workspace's first events once. No code change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
…wn turn The trigger site notes the turn's credential under the call id while that call's plugins run, and the onboarding hook claims under it. A tenant switch mid-turn, or a late return, no longer drops the other tenant's row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8
…ject's link A project linked before the switch was set still sent that workspace's first_prompt_sent and first_job_completed. Now neither is sent, onboarding rows name no workspace, and nothing is marked, so the rows come once the switch is off. Other rows still name the link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8
Review log: claims and disclosed residuals (head 88756ea)Review against these. A finding is a reproducible trace that violates a numbered claim. Claims
Disclosed residuals (not findings)
|
|
@codex review against the numbered claims and the disclosed residuals in the review-log comment on this PR: report only a reproducible trace that violates a numbered claim. Instances of the disclosed residuals are not findings. A round with no claim violation ends review. |
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
1 issue found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/opencode/src/altimate/workspace/first-events.ts">
<violation number="1" location="packages/opencode/src/altimate/workspace/first-events.ts:92">
P2: `callScopes` overwrites an earlier scope whenever overlapping tool executions reuse the same `callID`, so a late `tool.execute.after` can claim the workspace under the replacement turn's tenant or fall back to the current tenant. Key the scope by an execution-unique token (or retain per-call entries) rather than relying on provider call IDs being unique across concurrent turns.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| * 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.
P2: callScopes overwrites an earlier scope whenever overlapping tool executions reuse the same callID, so a late tool.execute.after can claim the workspace under the replacement turn's tenant or fall back to the current tenant. Key the scope by an execution-unique token (or retain per-call entries) rather than relying on provider call IDs being unique across concurrent turns.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/workspace/first-events.ts, line 92:
<comment>`callScopes` overwrites an earlier scope whenever overlapping tool executions reuse the same `callID`, so a late `tool.execute.after` can claim the workspace under the replacement turn's tenant or fall back to the current tenant. Key the scope by an execution-unique token (or retain per-call entries) rather than relying on provider call IDs being unique across concurrent turns.</comment>
<file context>
@@ -80,8 +84,38 @@ export async function claim(event: FirstEvent, workspaceId: number, scope?: Scop
+ * 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>()
+
+/** Notes `scope` for `callID` until the returned handle is disposed. */
</file context>
There was a problem hiding this comment.
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 toolCallId. sanitizeToolCallID replaces only a missing or non-string one, and the AI SDK providers return or generate unique ids. Even with a repeated id, the wrong tenant is taken only when the two calls' turns ran under different credentials, and only for an onboarding sample-setup completion. With the same credential, the fallback to the current credential names the same tenant.
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.
|
|
||
| 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.
WARNING: A failed prompt now waits for every attachment, so an attachment that never settles withholds the error indefinitely.
Promise.allSettled removes the fail-fast behaviour: createUserMessage no longer rejects until every part settles. The ranged text/plain attachment part awaits LSP.documentSymbol (packages/opencode/src/session/prompt.ts:3397), which runs client.connection.sendRequest("textDocument/documentSymbol", ...) with no timeout (packages/opencode/src/lsp/lsp.ts:301-304,427-433); the .catch(() => []) there only handles rejection, not a promise that never settles, and ctx.abort for these reads is a fresh never-aborted AbortController (prompt.ts:3435). So with one failing sibling part (e.g. the missing-directory read) and a hung LSP server or unresponsive language server, the failure used to surface immediately and now never surfaces: the session stays busy with no error and no way to know the prompt failed.
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.
There was a problem hiding this comment.
Declined for this PR, recorded as R6 on the review log.
The hang comes from LSP.documentSymbol having no timeout, and it is on main too. When no sibling part fails, Promise.all on main also waits for that symbol lookup, so a hung language server stalls every prompt with a ranged attachment whose range came back empty. This PR changes only the case where another part also fails: the error now waits for the hung part as well, instead of surfacing first. Bounding the lookup belongs on documentSymbol itself, which fixes both cases, and is its own change.
| await WorkspaceFirstEvents.claim( | ||
| "first_job_completed", | ||
| workspaceId, | ||
| WorkspaceFirstEvents.callScope(input.callID), |
There was a problem hiding this comment.
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.noteCallScope is called only in resolveTools's native tool wrapper (packages/opencode/src/session/prompt.ts:2983). The MCP wrapper also fires tool.execute.after (prompt.ts:3090-3100) and the task tool fires it too (prompt.ts:1420-1430), but neither notes a scope. The MCP loop assigns tools[key] = item after the native loop (prompt.ts:3163), so an MCP server exposing a tool named sample_setup replaces the native one and reaches this hook through the un-noted path. callScope is then undefined, and claim() falls back to currentScope() (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_setup is native, but the new mechanism's invariant is "the trigger site notes the scope". Noting it at the MCP (and task) 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.
Declined, recorded as R7 on the review log: not reachable for this mark.
Only sample_setup completes a job (isJobCompletion, onboarding-telemetry.ts:183), and only the native tool reaches it by that name. The task trigger isn't a job (jobForTool maps no task). An MCP tool's key is <server>_<tool> (mcp/index.ts:1251), so it would take a server named sample with a tool named setup. Even then the MCP trigger passes the raw call result, which carries no metadata.success, so isJobCompletion is 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.
| accountSees([{ id: LINKED, name: "project-link" }]) | ||
| yield* linkProject(dir) | ||
| // The ranged file's symbol lookup outlasts the missing directory, whose failed read fails the prompt. | ||
| symbolLookup = () => new Promise((done) => setTimeout(() => done([]), 300)) |
There was a problem hiding this comment.
SUGGESTION: The 300 ms timer is the only ordering guarantee, so on a slow host this regression test can pass without the production fix.
The test exists to prove a part that finishes after the failure cannot leave a turn verdict behind. If the missing-directory ReadTool takes longer than the 300 ms symbol lookup on a cold CI runner, the ranged part resolves its verdict first and the directory failure lands afterwards, so endTurnBinding is not followed by any late read and both the fixed and unfixed code reach the same RELINKED assertion.
packages/opencode/test/AGENTS.md asks for a published readiness signal instead of a fixed sleep. For example, have the failing part set a deferred/flag that symbolLookup awaits, so the late part provably settles after the failure rather than relying on relative timing.
There was a problem hiding this comment.
Declined for this PR, recorded as R8 on the review log.
Agreed that the timer is the ordering guarantee. On a slow host the test can only pass without the fix; it can't fail with it, so it never blocks CI. It failed without the fix locally when it was written. Replacing the timer with a readiness signal is a test-only change worth making, and it can follow without touching the release.
Code Review SummaryStatus: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Note: the Files Reviewed (9 files)
Fix these issues in Kilo Cloud Reviewed by deepseek-v4.1-flash-1017 · Input: 142.5K · Output: 40.5K · Cached: 3.7M Review guidance: REVIEW.md from base branch |
sahrizvi
left a comment
There was a problem hiding this comment.
Consensus review (Claude lead + Codex, both rounds completed): APPROVE
All three claims hold for ordinary inputs, and three of the four earlier minors are fixed. M1 (the onboarding first-job credential) is partly fixed: it is correct for distinct call ids, and minors 2 and 4 cover what is left. Every fix has a test that fails when the fix is reverted (10 of 10 mutations caught). test/session, test/altimate/{telemetry,workspace,plugin}, test/plugin and test/telemetry pass at head and at base, typecheck passes, and every prompt.ts edit sits inside altimate_change markers. The "All fibers interrupted" unhandled error also appears on base.
No critical or major finding is open. The remaining minors are inline, and none blocks:
- A failed prompt never reports its error when a sibling's symbol lookup hangs (
prompt.ts:3273): an edge input on a hang that already exists on main. - A repeated raw call id can swap or drop the noted tenant (
first-events.ts:97): an edge input, safe in a follow-up. - The error reported is the first failure in input order, not in time (
prompt.ts:3213): cosmetic. - A hook that never settles keeps its call-scope entry (
prompt.ts:2983): an edge input. - The verdict test's slow-lookup seam is never called (
pinned-workspace-loop.test.ts:372): test-only.
Codex first rated minor 1 as major, then revised it to minor after a probe showed main hangs on the same input. Nits are in a separate comment.
|
|
||
| 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.
Minor (Codex; reproduced by the lead). A failed prompt now waits for every sibling. If a ranged ?start=N&end=N file's LSP.documentSymbol never answers (no timeout, R6), the error is never reported.
Probe: a missing directory plus missing.txt?start=3&end=3, with the legacy LSP.documentSymbol spied to return a promise that never settles. Head was still pending after 8 s; base rejected in 537 ms.
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 documentSymbol, or report the first rejection and clear the verdict once every part has settled.
| // 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") |
There was a problem hiding this comment.
Minor (Codex). find picks the first rejected part in input order. Promise.all reported the first rejection in time. Example: a missing directory first and a malformed URL second. Main shows the URL error; head waits and shows the directory error. This only changes which diagnostic is shown, so it's cosmetic, but the PR's "error is still the first failure" doesn't hold literally.
| /** 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) |
There was a problem hiding this comment.
Minor (Codex). The key is the raw options.toolCallId (prompt.ts:2870), and processor.ts:158 says a provider may repeat one malformed raw id for concurrent calls. So R5's "the AI SDK makes them unique" doesn't hold for this repo.
When two overlapping calls share an id under different turn credentials, the second set overwrites the first. If the second call disposes first, the first call's lookup returns undefined and claim falls back to the claim-time credential.
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.
| // 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) |
There was a problem hiding this comment.
Minor (Codex). Disposal happens only when this block exits. A tool.execute.after hook that never settles keeps its entry, because cancelling the session doesn't unwind a pending hook. That leaves one entry per hung hook, with no hard cap. It's an edge input: a hung hook already hangs this call on main. Success and thrown-error exits dispose correctly.
| accountSees([{ id: LINKED, name: "project-link" }]) | ||
| yield* linkProject(dir) | ||
| // The ranged file's symbol lookup outlasts the missing directory, whose failed read fails the prompt. | ||
| symbolLookup = () => new Promise((done) => setTimeout(() => done([]), 300)) |
There was a problem hiding this comment.
Minor, test-only (lead; Codex agreed). This seam is never called. The stub replaces documentSymbol on LSP.Service (@/lsp/lsp, line 83), but prompt.ts:74 imports LSP from ../lsp, the legacy namespace. An instrumented symbolLookup never printed.
The test still fails when allSettled is reverted to Promise.all (3 of 3 runs), but through natural ordering, not this timer. So the comment above and R8 are inaccurate, and nothing tests a slow or hung lookup. Spying on the namespace (spyOn(LSP, "documentSymbol") from src/lsp) would make the ordering explicit.
|
Consensus review nits (Claude + Codex), not blocking
|
Issue for this PR
Closes #1698
Type of change
What does this PR do?
first_prompt_sentandfirst_job_completed.ALTIMATE_DISABLE_WORKSPACEset, a project linked before the switch was set sends nofirst_prompt_sentorfirst_job_completedfor that workspace, and an onboarding session's own rows name no workspace. Nothing is marked, so the workspace's first rows are sent once the switch is off. Other rows still name the link, as before; the docs say so.How did you verify your code works?
Each fix's new test fails without it: a missing directory with a slower ranged file, a late return after a tenant switch, a switch mid-turn in a real loop, and the kill switch in a linked turn, an onboarding turn and the onboarding hook. With the feature on, the first-events loop file passes 15/15 and the pinned and turn-boundary files 15/15; each logs one unhandled "All fibers interrupted" error, as on
main.test/altimate/telemetry,workspaceandpluginpass (1,622 tests). Typecheck and Marker Guard pass.Cost side effects
None. A failed prompt waits for its other attachments before it reports the error.
Screenshots / recordings
Not a UI change.
Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01VEmKQswEEpkYFmMEngESx8
Summary by cubic
Fixes a bug where a failed prompt could leave a stale workspace verdict for the next loop to take, and makes the first-job mark use the credential of the turn that ran the tool.
Bug Fixes
pinned-workspace-loop.test.tscovers the race.Follow-ups from the workspace telemetry review
ALTIMATE_DISABLE_WORKSPACEset, no first event names the saved link: a project linked before the switch was set sends nofirst_prompt_sentorfirst_job_completed, onboarding rows name no workspace, and nothing is marked, so the workspace's first rows are sent once the switch is off. All other rows still name the link.Written for commit 88756ea. Summary will update on new commits.
Summary by CodeRabbit
Bug Fixes
Tests