| … | … | @@ -436,6 +436,14 @@ const make = Effect.gen(function* () { | |
| 442 | + | // lands). A compare-and-swap drops the rename instead of | |
| 443 | + | // overwriting that correction with a branch nothing has | |
| 444 | + | // checked out. | |
| 445 | + | expectedBranch: oldBranch, | |
🟡 Medium orchestration-v2/ThreadLaunchService.ts:445
When the agent switches branches during the first turn, the thread can remain recorded with the generated branch instead of the branch actually checked out. expectedBranch and expectedWorktreePath only guard the metadata dispatch; git.renameBranch still renames oldBranch by name, and a rename dispatch can commit after followBranchDrift reads the stale projection, causing its guarded correction to be dropped with no later retry. Serialize these updates or re-check the live checkout before renaming or committing the rename metadata.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration-v2/ThreadLaunchService.ts around line 445: |
| 2 | |
| 3 | When the agent switches branches during the first turn, the thread can remain recorded with the generated branch instead of the branch actually checked out. `expectedBranch` and `expectedWorktreePath` only guard the metadata dispatch; `git.renameBranch` still renames `oldBranch` by name, and a rename dispatch can commit after `followBranchDrift` reads the stale projection, causing its guarded correction to be dropped with no later retry. Serialize these updates or re-check the live checkout before renaming or committing the rename metadata. |
Fixed in 30d4195a41. The rename now re-checks the worktree's checked-out branch right before renaming. If it's no longer the temp branch, both the git rename and the metadata dispatch are skipped. A new test confirms renameBranch isn't called once the branch has moved.
7ffe8eeOpen thread| … | … | @@ -104,8 +276,12 @@ export const observerLive = Layer.effect( | |
| 282 | + | yield* followBranchDrift(thread, local.refName, runId); | |
| 283 | + | return; | |
| 284 | + | } | |
| 109 | 285 | yield* vcsStatus.refreshPullRequestStatus(cwd).pipe( | |
🟡 Medium orchestration-v2/RunFinalizationService.ts:277
Switching a dedicated worktree to the repository default branch leaves the thread stamped with its old feature branch, so branch drift is not reconciled. refresh returns on local.isDefaultRef before reaching thread.branch !== local.refName; move that early return after drift reconciliation and before the pull-request refresh.
| 1 | - if (local.refName === null || local.isDefaultRef) return; |
| 2 | + if (local.refName === null) return; |
| 3 | const thread = yield* projections.getThreadShell(threadId); |
| 4 | if (!thread) return; |
| 5 | if (thread.activeRunId !== null && thread.activeRunId !== runId) return; |
| 6 | if (thread.branch !== local.refName) { |
| 7 | yield* followBranchDrift(thread, local.refName, runId); |
| 8 | return; |
| 9 | } |
| 10 | + if (local.isDefaultRef) return; |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration-v2/RunFinalizationService.ts around lines 277-285: |
| 2 | |
| 3 | Switching a dedicated worktree to the repository default branch leaves the thread stamped with its old feature branch, so branch drift is not reconciled. `refresh` returns on `local.isDefaultRef` before reaching `thread.branch !== local.refName`; move that early return after drift reconciliation and before the pull-request refresh. |
Fixed in a28254eac0. The default-ref early return now comes after drift reconciliation and before the PR refresh, so switching a dedicated worktree to the default branch updates the stamp. A new test covers it.
| … | … | @@ -2612,6 +2612,30 @@ export const OrchestrationV2Command = Schema.Union([ | |
| 2627 | + | * so the whole command is rejected instead. | |
| 2628 | + | */ | |
| 2629 | + | requireExclusiveWorktree: Schema.optional(Schema.Boolean), | |
| 2630 | + | /** | |
🟠 High src/orchestrationV2.ts:2630
A client can set preserveProviderSession: true on thread.metadata.update, causing a worktree change to skip the normal provider-session detach. The provider then keeps using the old cwd while the thread records the new worktreePath, so a user-initiated handoff can continue agent work in the previous worktree; keep this server-only flag out of the client command schema or strip/reject it at the RPC boundary.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @packages/contracts/src/orchestrationV2.ts around line 2630: |
| 2 | |
| 3 | A client can set `preserveProviderSession: true` on `thread.metadata.update`, causing a worktree change to skip the normal provider-session detach. The provider then keeps using the old cwd while the thread records the new `worktreePath`, so a user-initiated handoff can continue agent work in the previous worktree; keep this server-only flag out of the client command schema or strip/reject it at the RPC boundary. |
Fixed in 9e08f00007. You're right: thread.metadata.update is also the wire schema for the client dispatchCommand RPC. ThreadMessageIntake.dispatchCommand now strips preserveProviderSession before the command reaches the orchestrator, so only the server's own turn-end follow can set it. There's a test for a client-sent command.
| … | … | @@ -4502,6 +4515,21 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | |
| 4519 | + | const hasSiblingThreadWithWorktreePath: ProjectionStoreV2Shape["hasSiblingThreadWithWorktreePath"] = | |
| 4520 | + | (input) => | |
| 4521 | + | sql<{ readonly thread_id: string }>` | |
| 4522 | + | SELECT thread_id FROM orchestration_v2_projection_threads | |
🟡 Medium orchestration-v2/ProjectionStore.ts:4522
Concurrent thread completions can both adopt the same previously unrecorded worktree, leaving two active threads claiming the same worktree/branch. hasSiblingThreadWithWorktreePath is only a standalone read, while Orchestrator.dispatch serializes by command.threadId; separate threads can both pass this predicate before either thread.metadata-updated projection commits. Coordinate this check with the update using a shared worktree lock or an atomic guarded write.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration-v2/ProjectionStore.ts around line 4522: |
| 2 | |
| 3 | Concurrent thread completions can both adopt the same previously unrecorded worktree, leaving two active threads claiming the same worktree/branch. `hasSiblingThreadWithWorktreePath` is only a standalone read, while `Orchestrator.dispatch` serializes by `command.threadId`; separate threads can both pass this predicate before either `thread.metadata-updated` projection commits. Coordinate this check with the update using a shared worktree lock or an atomic guarded write. |
This one is real but narrow, and I left it as is (94bbbbdd0e documents it in place). The orchestrator serializes per thread, so two different threads can both pass the exclusivity read before either commits. Closing it would need a second lock keyed by worktree path, ordered against the per-thread lock. The worst case is a branch label shown on two threads until the next branch or location change corrects it.
| … | … | @@ -6975,6 +6999,27 @@ export function makeClaudeAdapterV2( | |
| 7010 | + | // agent_id is present only for a subagent's own | |
| 7011 | + | // tool call; a subagent's worktree is its own | |
| 7012 | + | // isolated concern, not this (root) session's. | |
| 7013 | + | if (hookInput.hook_event_name === "PostToolUse" && !hookInput.agent_id) { | |
🟡 Medium Adapters/ClaudeAdapterV2.ts:7013
Subagent EnterWorktree/ExitWorktree events are treated as root-session events, so their cwd updates the parent thread to the subagent's unrelated worktree and leaves branch/location and file previews incorrect. agent_id is not present on PostToolUse in SDK 0.3.276; use a root/subagent signal available on this hook path, or skip these hook events.
No longer relevant as of 916171bfc88057c72f0b8e7efc77c963d8fd3478
Not a bug as far as I can see. In the installed @anthropic-ai/claude-agent-sdk 0.3.276, agent_id is on BaseHookInput, so every hook including PostToolUse has it, and the SDK's doc comment says to use it to tell subagents from the root session. sdk.mjs passes it through unchanged from the CLI. parent_tool_use_id isn't on this hook input. ec525cc945 adds a comment with this evidence.