diff --git a/docs/design/channel-named-sessions-part4b.md b/docs/design/channel-named-sessions-part4b.md new file mode 100644 index 00000000000..a344575ed74 --- /dev/null +++ b/docs/design/channel-named-sessions-part4b.md @@ -0,0 +1,1434 @@ +# Channel Named Sessions: Part 4B + +## Status + +Proposed. Parts 1, 2, 3A, 3B, and 4A are merged. This design is the final +planned delivery for issue #10103. It enables conversation reset for +worktree-isolated tasks and resolves the review findings whose dispositions +point at this part. The disposition of every standing Part 4A finding — +including the ones this part does **not** absorb — is recorded in +"Disposition of standing Part 4A findings" below; issue #10103 closes only +when those dispositions are also satisfied. + +Part 4A landed in #10643 (merge commit `37cb9ac161`). Its design deferred +selected-task reset for worktree tasks to this part. The Part 4A exit +criterion opens with a start gate — "Part 4B may start only after Part 4A +proves exact creation and restart recovery" — and then requires: + +> Its design must define an atomic or compensatable daemon operation that +> creates a fresh conversation while retaining the selected task's exact +> verified worktree, transfers marker/sidecar ownership safely, does not +> delete files, and fails closed on active, stale, foreign, ambiguous, or +> partial state. + +The start gate was judged passed when Part 4A merged under human approval, +backed by the reviewer-run real-daemon verification on #10643 (Reviewer Plan +15/15, tamper matrix 17/17). This design satisfies the six transfer +requirements element by element. It also deletes no user-visible state: the +one destructive candidate identified during this design's review +(orphan-reap worktree cleanup) was moved out to follow-up issue #11024, and +the transfer's own rollback removes only artifacts the reset created — the +replacement session's sidecar and record — never the worktree, its files, +its branch, the ownership marker, or the old session's state. + +## Decision + +Enable `/clear`, `/new`, and `/reset` for a selected worktree task. Resetting +a worktree task keeps its exact daemon-attested worktree — every file, +including uncommitted changes — and replaces only the conversation with a +fresh daemon session. + +The primitive is a new daemon route: + +```text +POST /session/:id/worktree-reset +``` + +Given an existing worktree-owning session (`S_old`), the daemon validates its +ownership chain under a worktree-keyed serialization lock, arms a +reset-pending barrier so no prompt — and no other operation that can write +the checkout or move the session cwd — can start on `S_old` mid-transfer, +spawns a fresh session (`S_new`) in the same registered root workspace, +relocates `S_new` into the same worktree, marks the old sidecar superseded, +writes a new sidecar for `S_new`, re-verifies quiescence, and flips the +in-worktree ownership marker from `S_old` to `S_new` last — with an atomic +compare-and-swap, never a blind overwrite. Only then does it attest +`worktreeState: "persisted-v1"` for `S_new`. The response payload has the +same shape as the Part 4A create/load response, so the Channel worker +validates it through the existing exact-identity chain. + +The old session is never deleted by reset. Its transcript and persisted +record remain in the daemon catalog, and its superseded sidecar makes any +later restore of `S_old` fail closed with a typed `worktree_session_superseded` +signal carrying the replacement session ID, which lets the Channel registry +self-heal if a crash interrupted a committed reset. + +The daemon advertises a new capability `session_worktree_reset_v1`; the +worker checks it before sending a reset request, exactly as Part 4A gated +creation on `session_worktree_persistence_v1`. + +This part resolves these findings from the #10643 review record: + +1. Missing-marker recovery (yiliang114 on `session.ts:4189`; chiga0 F1; and + the review bot's R3-2, the standing Critical carried from round 1): + restore distinguishes a missing marker from a tampered one, and reset is + the sanctioned recovery path — the transfer recreates the marker only + when the remaining chain proves ownership. +2. Deferred-prompt restore attestation (yiliang114 on `session.ts:4201`; + chiga0 F2/R1-2): the under-attesting `hasUnlocatedRestoredPrompt` branch + is removed so the genuinely-active-prompt shape fails closed through the + existing active-session check. See "Deferred-prompt restore attestation" + for why relocation is not the fix — the first revision of this document + proposed relocating there, and review showed that premise was inverted. +3. Deferred restore-prompt visibility (R8-2, `bridge.ts:8462`): a parked + deferred restore prompt sets neither `promptActive` nor any pending + interaction, so it is invisible to `hasActivePrompt` and + `pendingInteractionCount`. That invisibility affects both the + coalesced-restore waiter (R8-2's report) and this part's quiescence check; + the bridge surfaces the deferred state and both consumers are fixed + together here. + +Re-scoped during this design's review, with reasons recorded in +"Disposition of standing Part 4A findings": + +- Orphan-reap worktree cleanup (yiliang114 on `session-archive.ts:656`, + deferred to Part 4B on #10643) moves to follow-up issue #11024. It is the + only file-deleting path in the series; its correct placement raised a + protocol interaction with reset itself (a freshly transferred `S_new` + legitimately satisfies every cleanup condition during the exact window the + superseded redirect exists to heal); and nothing about `/clear` needs it. +- The create-rollback orphan (R8-1), the reattach-guard heal (R8-3), the + exclusive-create empty-file leak (R1-1), and the `/session new --worktree` + parser wart (F3) are live defects in `main` that do not depend on the + transfer protocol; they are fixed directly in this PR (code and tests) + rather than waiting for a follow-up. + +## Goals + +1. `/clear`, `/new`, and `/reset` on a selected worktree task produce a fresh + conversation in the same verified worktree, with no deletion of + user-visible or pre-existing state anywhere in this part. +2. Ownership transfer is compensatable: every crash window either leaves the + old session authoritative or is completed by an idempotent retry or a + typed superseded redirect. No window strands the task. +3. A missing ownership marker is recoverable through reset; a tampered marker + is never recovered automatically. +4. Reset refuses a task that is running, waiting for permission, or parked + on a recovered question, with an actionable message; the daemon enforces + quiescence as a barrier, not a point-in-time sample. +5. Resolve the in-scope review findings above without weakening any Part 4A + fail-closed boundary. +6. Keep shared tasks, disabled `multiSession`, and all Part 2/3 behavior + unchanged. + +## Non-goals + +- Deleting any user-visible or pre-existing state: the worktree, its branch, + the files within it, the ownership marker, or `S_old`'s sidecar, + transcript, and record. The transfer's rollback may remove only artifacts + the reset itself created — `S_new`'s sidecar and session record. The + orphan-reap cleanup and the sibling `POST /sessions/delete` worktree leak + are tracked in follow-up issue #11024. +- Deleting a task or its registry record. Task purge is a separate feature; + names of closed tasks remain occupied, as in Part 2. +- Automatic merge-back, push, rebase, or conflict resolution for worktree + branches. +- Copying uncommitted root-checkout changes anywhere. +- Named worktrees for standalone Channels, webhooks, loops, group history, + or non-`user` session scopes. +- Resetting a running or permission-pending task. The user cancels first. +- Changing Part 3 labels, permission correlation, cancellation, registry + schema (stays version 1), transcripts, or audit hashes. + +In scope, contrary to an earlier draft of this section: the R8-1 +create-rollback orphan fix, the R8-3 reattach-guard heal, the R1-1 +marker-create leak fix, and the F3 parser fix all land in this PR alongside +the design (see the disposition table). + +## Verified baseline (from Part 4A) + +- `POST /session` with `worktree: {}` creates a user worktree below the + repository's managed root, spawns a session at the root, relocates it with + `changeSessionCwd` under server-derived `allowedRoots`, creates the marker + with `createWorktreeSessionMarkerExclusive`, persists the sidecar with + `workspaceCwd`, and attests `persisted-v1` only after all three succeed + (routes/session.ts creation branch). +- Restore re-validates strictly: sidecar `workspaceCwd` must realpath-match + the route's root, `originalCwd` must be the root or repo top-level, the + worktree path must be contained below a managed root, and the strict marker + read must be `valid` and name the restored session ID. Failure is + non-destructive: the just-attached client is detached or zero-attach-killed; + transcript, sidecar, marker, branch, and worktree are retained. +- The Channel manager rejects worktree reset inside `reset()` before any + `ChannelBase` cleanup side effect, with + `Task "" uses a worktree and cannot be cleared or reset yet. ...`. +- `SessionRouter.validateManagedSessionIdentity` is the Channel-side gate: + it requires `worktreeState === 'persisted-v1'`, worktree metadata with an + absolute path distinct from the root, and an exact match against the + expected task cwd when given. +- The marker is `.qwen-session` at the worktree root, content is the owning + session ID, created with `O_EXCL | O_NOFOLLOW` plus inode pinning, read + with the strict no-follow reader (`readWorktreeSessionMarkerStrict`), and + git-ignored through the repository's common `info/exclude`. +- The sidecar is a per-session JSON file in daemon session storage + (`sessionService.getWorktreeSessionPath(sessionId)`), holding `slug`, + `worktreePath`, `worktreeBranch`, `originalCwd`, optional `workspaceCwd`, + `originalBranch`, `originalHeadCommit`; the strict reader returns + `missing | valid | invalid` without collapsing corruption into absence. +- `deleteDaemonSessionIfOrphan` removes the persisted session record when the + session is provably orphaned, but never touches the worktree or branch — + the leak yiliang114 flagged. Its sibling `deleteDaemonSessions` (the + `POST /sessions/delete` path) shares `deletePersistedSessionWithLease` and + has the same leak. +- A deferred restore prompt is parked in + `entry.deferredRestoreAskUserQuestionPrompts` and sets neither + `promptActive`/`goalTurnActive` nor any entry in `pendingInteractions`, so + `getSessionSummary`'s `hasActivePrompt` and `pendingInteractionCount` both + read it as quiescent. The same invisibility exists in the coalesced-restore + waiter branch (R8-2). +- The restore _response_'s `hasActivePrompt` is a separate computation from + the summary's: `restorePromptAdmitted || promptActive || goalTurnActive`. + The restore route reads that response object, not `getSessionSummary`. The + distinction matters for this part because a parked prompt must keep reading + `false` there — see "Deferred-prompt visibility". +- The restore route's `hasUnlocatedRestoredPrompt` branch (active prompt, no + attachment, no recorded cwd) is therefore **not** the deferred-prompt + shape: a parked prompt reads `hasActivePrompt: false` and flows through + the `else` branch, which already relocates and attests `persisted-v1`. The + branch is entered only when a cold-restored session genuinely has a live + prompt — where `changeSessionCwd` chains onto the prompt queue and throws + `CdWhilePromptActiveError`. The branch sets worktree metadata but skips + relocation and never assigns `worktreeState`, so the Channel-side identity + check fails on the next selection — chiga0 F2, with the branch's actual + shape corrected per review. +- `/session new --worktree` without a name falls through to + `create('--worktree', 'shared')`, which the name validator rejects with a + misleading message — chiga0 F3. (The claimed silent shared-task creation + does not occur; `TASK_NAME_PATTERN` rejects a leading hyphen. Only the + message is wrong.) +- `acquireWorktreeRestore` (renamed `acquireWorktreeOwnershipOp` by this part) + is keyed per bridge instance (a `WeakMap`) and per session ID, and today + covers only the deferred-prompt restore shape; the ordinary Part 4A worktree + restore takes no such lock. +- `packages/core/src/utils/atomicFileWrite.ts` already provides + `atomicWriteFile` with `noFollow: true` (an atomic replace that substitutes + a regular file for whatever occupies the path, never following a swapped-in + symlink), `renameWithRetry` (EPERM/EACCES backoff for Windows), + `flush`, and an `assertCanCommit` hook invoked immediately before the + rename commit. +- Two `atomicWriteFile` paths do **not** go through that rename, and both + matter for a marker: an ownership-preserving fast path that writes the + existing target in place — non-atomic, and symlink-following at write time — + when the target's `uid` differs from the process's effective uid, and an + `EXDEV` fallback that unlinks before an exclusive create. The `assertCanCommit` + hook is synchronous (`() => void`), so it cannot await the async strict + marker read. + +## Disposition of standing Part 4A findings + +Part 4A merged on a `land-with-residual-risk` recommendation under the +repository's five-round rule, so review findings stood at merge. Their +dispositions — none is silently dropped: + +| Finding | Shape | Disposition | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| R3-2 / chiga0 F1 + yiliang114 (missing marker bricks the task) | restore fails closed with no recovery path | **This part**: typed `worktree_marker_missing` restore signal plus marker recreation through reset | +| chiga0 F2 / R1-2 + yiliang114 (under-attested active-prompt restore) | branch never attests `persisted-v1` | **This part**: removed where a deferral was available (that shape fails closed); kept, unattested, for a fired restore prompt (see "Deferred-prompt restore attestation") | +| R8-2 (deferred prompt invisible to the coalesced restore) | waiter hangs or 500s a healthy session | **This part**: the bridge surfaces deferred restore-prompt state; the coalescer and this part's quiescence both consume it | +| R8-1 (create rollback `!spawnCompleted` gate orphans the worktree) | permanent orphan when post-spawn cleanup is inconclusive | **This PR**: the post-spawn failure block removes the unowned checkout regardless of the orphan-delete outcome, and the code comment states why that block differs from its two neighbours (see below) | +| R8-3 (reattach guard misreads a legitimately exited worktree) | permanent "lost durable worktree identity" loop after `exit_worktree` + restart | **This PR**: reattach heals on a resume response carrying no `worktree` object and keeps failing closed on a contradictory attestation, with a regression test | +| R1-1 (exclusive marker create leaks an empty file on write failure) | path wedged with `EEXIST` | **This PR**: the exclusive create unlinks its own file on failure, with a regression test | +| F3 (`/session new --worktree` without a name) | misleading name-validation message | **This PR**: the parser returns the usage line, with a regression test | +| yiliang114 reap-path leak (`session-archive.ts:656`) | orphan session deletion leaks worktree + branch | **Follow-up issue #11024** (see below), not this part | +| `SessionRouter.ts:487` (generic load paths feed the persisted worktree cwd to the daemon; cold-start `restoreSessions` drops worktree-task routes) | deferred Critical, fails-closed, new-surface; its full text is truncated in the #10643 review record | **Follow-up issue #11024**, investigated there first because its truncated record must be reproduced before it can be designed against | + +The R8-1 row needs one distinction spelled out in this PR's code comment, +because the create route deliberately does the opposite two blocks away: +relocation failure removes the checkout "only when the session was +definitively removed" (a bridge timeout is caller-facing; relocation may +still land), and the post-relocation persistence failure preserves on an +inconclusive delete and logs it. The block this PR changes is different in +kind, not a relaxation of that rule: it is the post-spawn generation-guard +catch, which runs before relocation is ever attempted — the session exists +there (and orphan-confirmed removal is attempted on it), but no session has +entered the worktree, so nothing can own the checkout regardless of how that +cleanup turned out. The `!spawnCompleted` "no session exists" rationale +belongs to the spawn-failed outer catch, a different block this PR does not +change. + +Issue #11024 (the follow-up for the last two rows) also carries the +reap-cleanup requirements gathered during this design's review, so they +survive contact with implementation there rather than being re-derived: + +- Place the deletion at the reap call site, as the create route does, never + inside the shared `deleteDaemonSessionIfOrphan` primitive — the primitive's + other call sites (ACP dispatch rollback, scheduled-task keepalive, this + part's own reset rollback) must not be armed. +- Gate on `kind === 'removed'` (equivalently `mutationApplied`), not + `!== 'error'`: the `notFound` shape means nothing was deleted. +- Refuse when the sidecar carries `supersedes`/`supersededBy` or when any + other session's sidecar names the same worktree path — a freshly + transferred replacement satisfies the naive ownership bar during exactly + the window the superseded redirect exists to heal. +- Treat a superseded predecessor as the expected post-reset state and skip it + without an operator log line, so the preserve-and-log signal keeps its + meaning. +- Keep `removeUserWorktree`'s safe-delete default: never pass + `forceDeleteBranch`, and log the `branchPreserved` outcome so "worktree + removed, branch kept" is distinguishable from "worktree kept, ownership + ambiguous". +- Require the ownership chain (strict-valid sidecar for this runtime, strict + marker naming exactly the removed session, containment) plus a clean + `git status --porcelain`; preserve and log on any doubt. +- Decide explicitly whether the sibling `POST /sessions/delete` path should + gain the same cleanup; it leaks identically today. + +## User-visible contract + +### Resetting a worktree task + +With a worktree task selected: + +```text +/clear → Task "feature-a" reset with a fresh conversation. Its worktree + files were kept. +``` + +`/new` and `/reset` behave identically, as they do for shared tasks. The task +keeps its name, its position in `/sessions`, and its exact worktree; the next +message starts a fresh conversation in that worktree. + +Reset of a worktree task that is running, waiting for permission, or parked +on a recovered question fails before any side effect: + +```text +Task "feature-a" is still running or waiting for permission. Cancel it with +/session cancel, then clear it again. +``` + +This is a deliberate difference from shared tasks, where `/clear` cancels an +in-flight turn. Transferring worktree ownership while a prompt may still be +executing in that directory is unsafe; cancellation first is the safe order. +The Part 4A rejection message is removed. + +### Recovery through reset + +When a worktree task's ownership marker is missing — the crash window between +relocation and marker write, or a task-local `git clean -fdx` — selecting or +messaging the task keeps failing closed, but with an actionable message: + +```text +Task "feature-a" cannot verify its worktree because its ownership marker is +missing. Its files were not changed. Clear the task to restart it in the same +worktree, or close it. +``` + +`/clear` then recreates the marker for the replacement session. A marker that +exists but is invalid (tampered, wrong owner, unsafe file type) is never +recreated by any command; the task stays fail-closed for operator repair. + +Both worktree recovery messages — this one and the interrupted-transfer one +below — are state-dependent, because clearing always acts on the _selected_ +task: the clear guidance is offered only when the task that failed is the +selected one. Aimed at any other task it would run a full ownership transfer +against a healthy one and destroy that conversation, so a failure on a task +that is not selected names that task and points at the two actions that reach +it — selecting it first, or `/session close `, which closes without +loading the target: + +```text +Task "feature-a" cannot verify its worktree because its ownership marker is +missing. Its files were not changed. Clearing now would reset the selected +task instead, so select this task first or close it with +/session close feature-a. +``` + +When no task name is available on the failure, the subject degrades to "The +task" and the remedy drops the command rather than risk naming the wrong task. +The name is the only text these messages interpolate outside the wrapper's +sanitizer, so it is bounded to 32 code points; the bounded-message rule is +unchanged — a task name is allowed, session IDs, paths, and daemon bodies are +not. + +### Interrupted transfer + +If a previous reset crashed after the sidecar pair was linked but before the +marker flipped, selecting or messaging the task reports the interrupted state +instead of a generic failure: + +```text +Task "feature-a" was interrupted while being reset. Its files were not +changed. Clear the task again to finish the reset. +``` + +The same state dependence applies: that text is emitted only when the +interrupted task is the selected one. A failure on a task that is not +selected names it and points at selecting it or closing it, rather than at a +clear that would transfer a different, healthy task: + +```text +Task "feature-a" was interrupted while being reset. Its files were not +changed. Clearing now would reset the selected task instead, so select this +task first or close it with /session close feature-a. +``` + +A retried `/clear` resumes the transfer and completes it. See the superseded +redirect section for how the daemon distinguishes this window. A crash that +left only the old sidecar's link — the replacement never gained its own +sidecar — is not this window: the pair disagrees, so the redirect target +carries no worktree attestation, selection reports the generic load failure, +and a retried `/clear` returns the same typed refusal instead of converging. +That state is left untouched for operator repair. + +### Listing and status + +`/sessions`, `/sessions all`, `/session current`, and `/session use` output is +unchanged; reset does not alter names, isolation labels, or ordering beyond +the existing `lastSelectedAt` bump. + +## Daemon design + +### New route: `POST /session/:id/worktree-reset` + +Request body (every field optional): `{ cwd?, modelServiceId?, approvalMode?, +sourceType?, sourceId? }`. `cwd` is the wire field for the workspace root — +the SDK maps its `workspaceCwd` option onto it — and the route resolves it +through the same resolver as load/resume: an explicit `cwd` selects that +registered workspace, omitting it falls back to the daemon's advertised +primary workspace, and the worktree path itself is never accepted as a routing +input. The session ID in the path is the worktree owner to replace (`S_old`). + +The response on success is the same session payload shape as create/load, +carrying `S_new`, the worktree metadata, and `worktreeState: "persisted-v1"`. +The route never reads `X-Qwen-Client-Id`, so `S_new` spawns unattached — but +the body is not registration-free: the fresh-transfer body's `clientId` is an +owner-style registration the bridge mints for that spawn (the replacement's +`attachCount` stays 0), and the idempotent-resume body omits it. A non-empty +client registration is what holds the daemon's idle cleanup off, so a caller +that does not keep using the minted id has to detach it or `S_new` never +becomes reap-eligible. Callers treat the response as the replacement's +identity and attach it through their normal flow; the Channel adopts the +returned client and calls `activateManagedSession`. SDK and bridge types +reuse the existing `DaemonSession` surface; no new response type is +introduced. + +Typed failures (4xx, bounded, no paths or stack traces): + +- `worktree_reset_unsupported` — target session has no Part 4A sidecar or is + not worktree-isolated. +- `worktree_reset_active` — the session has an active prompt, a pending + interaction, or a parked deferred restore prompt. +- `worktree_reset_invalid_state` — stale, foreign, tampered, containment + failure, or ambiguous ownership. Non-destructive for what this request + started: a partial transfer is rolled back before the caller sees it, while + a pre-existing interrupted state is left untouched for operator repair. + +Two further codes belong to the restore surface, not to this route, which +resolves both shapes itself and never returns them: + +- `worktree_session_superseded` — load/resume of `S_old` after the flip; see + the redirect below. +- `worktree_reset_interrupted` — load/resume of a replacement whose + `supersedes`/`supersededBy` links agree while the marker never moved. The + repair is retrying the reset against the superseded session, which rolls the + interrupted transfer back or finishes it; see the superseded redirect + section. + +### Deferred-prompt visibility (R8-2) + +The bridge learns to surface a parked deferred restore prompt: a non-empty +`entry.deferredRestoreAskUserQuestionPrompts` counts as a prompt in progress +for exactly two consumers — the coalesced-restore waiter branch (R8-2's +report) and `getSessionSummary`'s `hasActivePrompt`, which is what this part's +quiescence precondition and the Channel's `isBusy` fallback read. One shared +visibility fix closes both the reported waiter hang/`CdWhilePromptActiveError` +and this part's quiescence blind spot. + +The fix stops there deliberately. It must **not** feed the restore +_response_'s `hasActivePrompt`, which is computed separately as +`restorePromptAdmitted || promptActive || goalTurnActive` and is the value the +restore route reads. Widening it there would interact with the branch removal +below: a deferred worktree restore would start reporting an active prompt, +fall into the `else if (session.hasActivePrompt)` check with no recorded cwd, +and fail closed with `Active session is outside its worktree` — breaking the +deferred-restore path that works today and that #10643's reviewer verified end +to end. The two fixes are only compatible while the response value stays +unchanged, so "prompt in progress" is deliberately not unified across both +computations. + +### Preconditions (all fail closed) + +Serialization for this operation is keyed on the canonical worktree path, not +the session ID — after any completed or crashed transfer, two sessions hold +sidecars naming the same worktree, and per-session locks would not mutually +exclude their resets. The existing restore serialization (per bridge instance) +is widened the same way and renamed `acquireWorktreeOwnershipOp`: every +route-owned Part 4A worktree restore takes the worktree-keyed lock after its +sidecar pre-read, and this route holds it for the whole operation. A restore +of `S_old` can therefore never pass marker validation concurrently with a +transfer that is about to flip the marker, and two resets targeting the same +worktree can never both reach the flip. The runtime generation is captured +before the first side effect and re-asserted before the response, as in +create/load. + +Inside the lock, with the sidecar re-read and re-validated under the lock: + +1. `S_old` exists in the persisted catalog for this workspace runtime. +2. Strict sidecar read for `S_old` is `valid`, carries `workspaceCwd` + realpath-matching the resolved root, an `originalCwd` within the accepted + roots, and a contained `worktreePath` — the same checks as Part 4A restore. + A sidecar already carrying `supersededBy` is not a fresh precondition + failure; it enters the resume path described below. +3. Quiescence, with the deferred-prompt visibility fix in place: the live + bridge entry for `S_old`, if any, reports no active prompt (parked + deferred prompts now count) and zero pending interactions. A session + unknown to the live bridge is dormant and therefore quiescent. Attached + clients do not block reset: the worker's own client stays attached to an + open task until reset succeeds, so the transfer severs `S_old`'s residual + attaches instead of requiring none (see step 6). The Channel refuses busy + tasks before any of this (`isBusy` covers queued turns, running prompts, + and pending permissions). +4. Marker state is either `valid` naming `S_old`, or `missing`. `missing` is + the recovery hatch: it is accepted only together with a valid sidecar and + a catalog record, i.e. the ownership chain minus exactly one link. + `invalid` (tampered, foreign, unreadable, unsafe type) rejects. +5. The worktree directory exists and realpath-resolves inside a managed + worktree root. + +Quiescence is enforced as a barrier, not a sample. When the check passes, the +route marks the bridge entry for `S_old` reset-pending; the bridge refuses to +admit new work for a reset-pending session, returning the same typed +`worktree_reset_active` failure as the busy-task rejection — same meaning, +same fail-closed shape. The fence is not prompt-only: eight writers consult +it, each one able to reach the checkout or move the session cwd — +`sendPrompt`, `rewindSession` (whose file restore is relative to the session +cwd, and which is admitted precisely in the idle state this route requires), +`changeSessionCwd`, `branchSession`, `launchSessionForkAgent` (whose fork +runs its tools in that cwd), `executeShellCommand` (which runs in the +entry's effective cwd — for a relocated worktree session, the checkout +itself, so it is the strongest vector rather than a workspace-cwd one), +`controlSessionWorkflowTask` (whose action runs a saved workflow, or +restarts a live run, through the session's own tool registry in that cwd), +and `controlSessionGoal` (whose `resume` promotes a queued user turn and +queues a continuation, so it starts work that never passes the fenced +`sendPrompt` admission). Each consults the same id-keyed flag the route arms +before it chains onto the session's prompt queue or dispatches, so a refused +writer cannot wedge past the transfer when the route clears the flag. +`continueSession` is not a ninth gate: it drives an accepted continuation +through `sendPrompt` and is refused there. Those eight are the whole fence, +and the fence is not everything that reaches the child: the release and stop +paths stay usable mid-transfer by design (`cancelSessionTask`, +`clearSessionGoal`, `detachClient`, `killSession`), and +`enqueueBackgroundNotification` is unfenced as well: it is daemon-internal (a +sub-session's completion acknowledgement to its parent, which no HTTP route +calls) and enqueues a notification rather than starting a turn. This closes +the window in which a message resolved just before the reset — on the Channel +side, `resolve()` returns under the owner lock but the turn starts after it +is released — could begin executing in the worktree mid-transfer, and the +same window for any non-Channel caller (for whom the check would otherwise be +a point-in-time sample). Channel users never observe the barrier: the manager +holds the owner lock across the whole reset, so a concurrent message's +`resolve()` blocks and then resolves to the replacement session — no +chat-facing message for the barrier exists by decision, not omission. The +flag is cleared on every failure or compensation path and is subsumed by the +transfer on success — with two exceptions, both of which begin at the commit +point. When the sever step reports a surviving superseded session, the +barrier stays armed as the only fence left on that entry (see step 6). And +once the marker flip has committed ownership, releasing the barrier stops +being this request's to do: the route disarms its own release at the flip — +and equally on the committed resume path, and on the primitive's post-commit +tail failure, which reports the flip as done — so any failure in the +post-flip tail leaves the superseded entry fenced for the retry that finishes +the severance, instead of re-admitting a writer to a checkout the marker has +already handed to `S_new`. Past that point only a completed severance clears +the flag explicitly. Quiescence is additionally re-verified immediately +before the marker flip. + +If `S_old`'s sidecar already records `supersededBy: S_new`, a previous reset +crashed mid-transfer. The route reads the marker before choosing its +recovery shape, because the marker is what says which side of the commit +point the crash landed on: + +- **Marker names `S_new` — the transfer committed.** `S_new` is the + authoritative owner and is never rolled back. When its catalog record, + sidecar, and `supersedes` link revalidate and it is quiescent, the route + completes the interrupted transfer for that same `S_new` and returns it — + a crash after the rename resumes as a no-op (idempotent resume). When it + is busy, the route fails closed with `worktree_reset_active`: a busy + replacement is a session in use (for example one reached through the + superseded redirect and prompted by another client), not an invalid one, + and a later retry converges once it is idle. Any other revalidation + failure (missing record, corrupt sidecar, mismatched link, tampered + marker) fails closed for operator repair. Deleting the session the marker + names would strand the task permanently: the marker would name a deleted + session, restore of `S_old` would fail on marker mismatch, and a retried + reset would fail precondition 4, which requires the marker to be `valid` + naming `S_old` or `missing`. +- **Marker names `S_old`, or is missing while `S_new` is dormant — + pre-commit.** `S_new` is provably non-authoritative, so the destructive + rollback is correct here: orphan-confirmed-remove `S_new` first, then + delete `S_new`'s sidecar, then remove `supersededBy` from `S_old`'s + sidecar — and proceed with a fresh replacement only once that removal is + confirmed. `false` from the removal (a client attached, so + `requireZeroAttaches` bailed) fails closed with + `worktree_reset_invalid_state` instead of proceeding to spawn: the links + stay written, so a retry re-enters this same resume path and refuses again + for as long as the removal stays unconfirmed, rather than leaving a live + replacement inside the checkout with nothing on disk naming it while the + fresh transfer spawns a second writer beside it. The link undo takes the + forward link off first for the same reason the transfer writes the backward + link first: a crash mid-rollback leaves the backward link alone — the shape + a retry refuses for repair — rather than a forward link nothing scans for. + Two conditions bound that proof. An absent marker alone proves nothing: a + committed transfer whose git-excluded marker was later cleaned (a + task-local `git clean -xdf`) reads exactly like the pre-commit crash shape, + so a missing marker while `S_new` is still live on this daemon fails closed + with `worktree_reset_invalid_state` before any destructive write instead of + dismantling the committed owner and spawning a second one. And the rollback + requires the link pair to agree — `S_new`'s sidecar present and naming + `S_old` — because a backward link with no replacement sidecar (the crash + window between the two sidecar writes) authorizes nothing; that shape is + reported for operator repair, not rolled back. + +Where the links agree, a retried `/clear` converges on exactly one owner +instead of piling up replacement sessions. The fail-closed shapes — an +invalid marker, a link pair that does not agree, a marker naming a third +session, a live replacement with no marker — are left untouched, and a retry +re-reads the same state and returns the same typed `409` rather than +converging, so they need operator repair instead of a retry loop. No recovery +path deletes the session the marker names. + +### Transfer protocol + +Ordered steps, with the crash behavior of each: + +0. Preconditions pass; arm the reset-pending barrier on `S_old`. + Every non-crash failure or compensation path before the marker flip + clears the barrier explicitly; past the flip the release stops being this + request's to do and only a completed severance clears it (see step 6 and + the barrier paragraph above). A crash clears nothing — the barrier is + in-memory bridge state and is discarded with the process, deliberately + non-durable: durability of a mid-transfer state lives entirely in the + marker and the sidecar pair, which the resume path reads. +1. Spawn `S_new` in the root workspace with the same thread-scope and source + metadata conventions as a fresh worktree creation, minus worktree + creation. No branch, no directory, no slug allocation. + Crash: `S_old` fully authoritative; `S_new` is an ordinary orphan with no + worktree metadata and no sidecar. +2. Relocate `S_new` into the verified worktree path through + `changeSessionCwd` with server-derived `allowedRoots`, requiring the + returned canonical path to equal the verified real path. + Crash/failure: orphan-confirmed removal of `S_new` (the existing + create-route rollback primitive); the marker still names `S_old`, which + remains restorable. The worktree is never removed by reset rollback. +3. Rewrite `S_old`'s sidecar adding `supersededBy: S_new` (atomic write). The + backward link is written first so every pre-flip crash window leaves a + state the resume path can classify: a crash here is a disagreeing link + pair, which fails closed visibly, rather than a forward link nothing + reads. + Crash: the marker still names `S_old`; restore of `S_old` reports the + superseded link with `S_new` as the redirect target, and `S_new` — which + has no sidecar yet — restores as an ordinary root-workspace session with + no worktree attestation, so an isolation-aware caller rejects that + response and keeps its bookkeeping. A retried reset fails closed with + `worktree_reset_invalid_state` because the pair disagrees, and leaves the + interrupted state untouched for operator repair. Controlled failure: + orphan-confirmed-remove `S_new` first, then remove `supersededBy` from + `S_old`'s sidecar. +4. Write the sidecar for `S_new` via the existing atomic writer, carrying the + worktree identity from `S_old`'s sidecar (`slug`, `worktreePath`, + `worktreeBranch`, `originalCwd`, `originalBranch`, `originalHeadCommit`) + plus this daemon's `workspaceCwd`, and a `supersedes: S_old` link. + Crash: the pair now agrees while the marker still names `S_old`, so the + redirect target is not yet authoritative — restore of `S_new` fails closed + with the interrupted-transfer signal, and a retry of the reset takes the + pre-commit recovery shape: roll the partial attempt back and proceed with + a fresh replacement. This is the one window where the task is temporarily + unrestorable and a retry is the documented repair; it is also why a caller + must not persist the redirect target it was handed until a load of it + succeeds, since that retry reaps it. Controlled failure: + orphan-confirmed-remove `S_new` first, then delete the new sidecar and + remove `supersededBy` from `S_old`'s sidecar. +5. Re-verify quiescence — a prompt admitted in the check-to-arm window and + still winding down aborts the transfer here — then transfer the marker to + `S_new` (primitive below). This is the point of no return. + Crash before the commit: marker still names `S_old`; a retried reset takes + the pre-commit recovery shape (rollback plus fresh replacement). Crash + after the commit: marker, both sidecars, and the catalog agree that + `S_new` owns the worktree; `S_old` restores as superseded and redirects, + and a retried reset takes the committed recovery shape (no-op resume or + fail-closed on a busy replacement — never rollback). +6. Re-assert the runtime generation. If `S_old` is live in the bridge, sever + its residual client registrations and clear its in-memory worktree + association, so the runtime view matches the transferred on-disk + ownership; `S_old` remains persisted and superseded, never deleted. The + sever reports whether `S_old` is actually gone, and the route acts on that + report instead of assuming it: a child that still holds background work (a + dev server started inside the checkout) refuses the idle close the last + detach triggers, so the entry can survive — alive, cwd'd inside the + checkout the marker just handed to `S_new`, and absent from the catalog + once its worktree association is cleared. A survivor is surfaced, not + papered over: the barrier stays armed on it as the only fence left, the + daemon logs the hold, and the `200` body carries + `supersededSessionLive: true` so the caller knows the old id is still live + and re-attachable. That field is diagnostic surface only: no first-party + caller reads it, and the channel worker reports the same success message + either way, so nothing here depends on a consumer checking it — what + keeps the survivor out of the checkout is the armed barrier and the + on-disk sidecar link. It is published for external callers and operators + who need to tell "the old id is gone" from "still live but fenced". + Escalating to a kill is not an option — a close error becomes a channel + kill, and `S_new` was spawned onto that same workspace-bound bridge. Set + `persisted-v1` on the response and respond. + +Controlled failure at steps 3–5 (no crash) compensates by removing `S_new` +first and then unwriting the links in the reverse of the write order — +orphan-confirmed-remove `S_new`, delete `S_new`'s sidecar, then remove +`supersededBy` from `S_old`'s sidecar — restoring the exact pre-reset state. +The removal goes first and has to be confirmed: `false` (a client attached, +so `requireZeroAttaches` bailed) abandons the link undo and rethrows, keeping +the pair on disk so a retry takes the resume path above and refuses for +repair, instead of leaving that survivor live inside the checkout with +nothing naming it while a second replacement is spawned beside it. The link +order matters for the same reason the forward one does: a crash mid-rollback +leaves the backward link alone, which a retry refuses for operator repair, +rather than a forward link nothing scans for. A failure at step 6 is past the +point of no return and does not compensate: the marker already names `S_new`, +the on-disk state is consistent, and the Channel — which never saw success — +heals its registry through the superseded redirect on the next selection. + +A crash before step 3 can leave a replacement session that was relocated but +never gained a sidecar. That orphan has no worktree claim: it restores as an +ordinary root-workspace session and is eventually reaped like any orphan — +the same shape a crashed Part 4A creation can already leave. A retried reset +simply spawns a fresh replacement. + +### Marker transfer primitive + +New daemon-only helper in the core worktree service, +`transferWorktreeSessionMarkerOwner(worktreePath, expectedOwner | null, +newOwner)`. The transfer is a compare-and-swap on the current owner — never a +blind overwrite: + +1. Strict-read the marker. Require `valid` with `sessionId === +expectedOwner`, or `missing` when `expectedOwner === null` (the recovery + hatch). Any `invalid` state or owner mismatch fails closed. +2. Refuse a foreign-owned marker — but only where ownership is observable. + The comparison reads the marker's `uid` (both strict readers, the async + one and the new synchronous one, surface it alongside the three-state + result) against the daemon's effective uid, and it runs only where the + platform reports one (`process.geteuid`). Where the platform reports none + — Windows — `atomicWriteFile`'s `ownershipWouldChange()` is always false, + so the in-place path this refusal guards against is unreachable by + construction and nothing is refused. Where it does run, the refusal keeps + `atomicWriteFile` on its rename path: the ownership-preserving fast path + would otherwise write the marker in place, non-atomically and following a + symlink swapped in after the check, and a crash mid-write would leave a + truncated marker — which the strict reader reports as `invalid`, and an + `invalid` marker is never auto-recovered. Failing closed on a + foreign-owned marker is the same fail-closed shape as any other ambiguous + ownership. +3. Commit by shape: + - `valid` naming `expectedOwner`: rewrite through the existing + `atomicWriteFile` with `noFollow: true`, whose rename atomically + replaces whatever occupies the path — including a symlink swapped in + after validation — without ever following it, and whose + `renameWithRetry` covers the Windows EPERM/EACCES shape. The + `assertCanCommit` hook re-reads the marker immediately before the rename + commit and requires the same owner **and** the same file identity + (`dev`/`ino`/`uid`) as the opening strict read, closing the window + between the opening read and the commit — a foreign-owned replacement + carrying the same owner content is caught there rather than written in + place. Because that hook is synchronous (`() => void`), the re-check + uses a new synchronous no-follow strict reader alongside the async one + in the core worktree service — same checks, same three-state result. + `atomicFileWrite.ts` itself is reused unchanged. + - `missing`: commit through the existing + `createWorktreeSessionMarkerExclusive`, which stages the owner in a + `.qwen-session.<12-hex>.tmp` sibling (`O_EXCL | O_NOFOLLOW`, inode + pinned), writes and fsyncs it there, and publishes it with `link(2)`. + The publishing link _is_ the compare-and-swap against absence: it fails + `EEXIST` when the marker path is taken, so a concurrent transfer that + gets there first loses instead of overwriting — the guarantee the + `O_EXCL` create gave, without ever letting the marker path exist before + its content is complete and fsynced. +4. `atomicWriteFile`'s temporary file is a sibling named + `.qwen-session..tmp`. The transfer adds a `.qwen-session.*.tmp` rule + beside the marker's existing `info/exclude` line so a crashed transfer's + leftover temp file — which carries a session ID — cannot be staged by a + task-local `git add -A`. The glob widens the repository's shared exclude + in the main checkout as well; that widening is accepted deliberately (the + pattern matches only transfer temp siblings of the marker), as Part 4A + already accepted for the marker rule itself. The sibling placement also + keeps the temp file on the marker's own filesystem, so `atomicWriteFile`'s + `EXDEV` fallback — which unlinks before creating, the ownerless window + rejected below — is unreachable here. + +Either commit shape leaves the old marker fully intact or the complete new +content, so the strict reader never sees a truncated or empty marker from +this primitive. That holds because the foreign-uid refusal excludes the +in-place write path, the sibling temp excludes the `EXDEV` path, and the +commit-point re-check compares owner **and** file identity — leaving the +rename and the link publish as the only commits. One residual is stated +honestly: an in-place rewrite that preserves inode and ownership is +indistinguishable to any file-level check, but only a same-uid writer can do +that — the daemon itself or the local user — and for daemon writers the +worktree-keyed lock excludes it. Two concurrent transfers targeting one +worktree cannot both win: the worktree-keyed lock serializes them, and the +second-place finisher's owner re-check (or `EEXIST` from the publishing +link) fails closed regardless. + +A 0-byte `.qwen-session` is consequently not a state the `missing` hatch can +produce. It is still classified `{state:'invalid', reason:'invalid marker +owner'}` by both strict readers and is still never auto-recovered: a +pre-existing empty marker — left by pre-fix code, by +`writeWorktreeSessionMarker`'s plain `fs.writeFile`, or by external +truncation — keeps the existing non-typed fail-closed behavior. + +The window that replaces the create-then-fill one is the gap between the +publishing link and the removal of the staged sibling: there the marker holds +the correct owner but has `nlink === 2`, which both strict readers report as +`{state:'invalid', reason:'unsafe marker file type'}`. The same call closes +the gap by unlinking the sibling; a failure of that unlink surfaces as the +primitive's post-commit `WorktreeMarkerCommittedError`, so the flip stands +and is never compensated backwards. A crash inside the gap leaves a +valid-content marker plus a `.qwen-session..tmp` sibling sharing its +inode. Operator repair is to remove the leftover sibling — never the marker — +which restores the marker to `valid`. It is not auto-recovered. + +One portability consequence comes with the publish: the hatch now requires +hardlink support on the filesystem holding the worktree, where the previous +plain `O_EXCL` create worked anywhere. Where hardlinks are unavailable the +publish fails, the staged sibling is removed and no marker is created, so the +failure is fail-closed — the reset refuses with its typed +`worktree_reset_invalid_state` after rolling back what it started, rather +than corrupting. This is a documented boundary of the primitive, not a +defect, and it applies to the worktree-creation path that already committed +through the same exclusive create as well as to the hatch. + +### Sidecar schema addition + +`WorktreeSession` gains two optional fields: + +```ts +supersededBy?: string; // on the old sidecar: the replacement that owns the worktree +supersedes?: string; // on the new sidecar: the session it replaced +``` + +The forward link is the redirect signal read on restore — read on its own, +before any marker or reverse-link read, so a redirect names a candidate owner +rather than a proven one (see the redirect section below). The reverse link +makes a reset retry's resume check bidirectional, so a `supersededBy` value +is only acted on when the session it names also claims to replace `S_old` for +the same worktree. `isValidWorktreeSession` accepts both as optional strings. +Readers that do not know the fields ignore them. Only the daemon route writes +them; only the restore/reset paths read them. The registry, the marker +format, and the sidecar's versionless shape otherwise stay unchanged. + +### Missing-marker restore signal + +When a Channel restore reaches the ownership check and the strict marker read +is exactly `missing` — sidecar valid, workspace and containment proven, only +the marker absent — the route returns `409` with code +`worktree_marker_missing` instead of the generic integrity failure. Absence +is not evidence of tampering (the crash window, or a task-local +`git clean -fdx` removing the ignored file). The Channel maps this code to +the recovery message in the user-visible contract; reset is the recovery path +that recreates the marker. An `invalid` marker keeps the existing non-typed +fail-closed behavior and is never auto-recovered. This resolves the +permanent-bricking concern (chiga0 F1, bot R3-2) without weakening the tamper +boundary. + +### Superseded redirect on load/resume + +The restore route already strict-reads the sidecar before loading, to choose +the worktree-restore suppression behavior and to derive the ownership lock's +key. The superseded decision is deliberately not taken on that pre-read: a +concurrent pre-commit rollback would otherwise make the route redirect a +caller to a replacement the same daemon is about to delete. A Channel restore +re-reads the sidecar once the worktree-keyed lock is held — still before the +bridge load — and stops there with `409` code `worktree_session_superseded` +and the replacement session ID when that locked read finds a valid Part 4A +sidecar carrying `supersededBy`. Every caller receives the same typed +failure: a non-Channel restore gets it from the defense-in-depth check the +Part 4A validation repeats after the load, and simply fails closed on it, as +it does on any restore integrity error today. For the Channel this is the +self-healing channel for a reset that completed daemon-side but whose +registry commit never happened (worker crash between the reset response and +the registry write): the manager's load catches the typed signal, exact-loads +the replacement through the normal `loadManagedSession` path — requiring the +same root, the same canonical worktree path, and `persisted-v1` — and only +then commits the registry update `S_old → S_new` under the owner lock. A +failed validation of the replacement leaves the registry unchanged and the +task fail-closed. The same registry-write-failure window after a successful +reset heals through this path on the next selection or message. + +One sub-case gets its own signal. If the redirect target's restore fails +because the marker still names `S_old` while the sidecar pair agrees +(`supersededBy: S_new` on the old, `supersedes: S_old` on the new), the +transfer crashed between steps 4 and 5. The daemon returns `409` with code +`worktree_reset_interrupted`, and the Channel maps it to the +interrupted-transfer message in the user-visible contract — the one window +whose documented repair is a retry gets a message that says so. + +Note what the redirect does and does not prove. The classification reads the +`supersededBy` link alone, before any marker read, and that link is written +before the flip (step 3), so a redirect can name a session that is not the +owner yet: in the interrupted window above the target's own restore fails, +and in the step-3 window it succeeds with no worktree attestation at all. +Exact-loading the target and requiring `persisted-v1` plus the expected +canonical path is what keeps either shape out of the registry — a redirect is +an instruction to verify, not a title deed, and a caller that persists +`replacementSessionId` on the strength of the 409 alone can adopt an id the +retried reset then reaps. + +### Deferred-prompt restore attestation (F2/R1-2) + +This section corrects the first revision of this document, which proposed +relocating in the `hasUnlocatedRestoredPrompt` branch on the premise that the +prompt there is parked. Review against the bridge code showed the premise is +inverted: + +- A deferred (parked) restore prompt sets neither `promptActive` nor + `goalTurnActive`, so it reads `hasActivePrompt: false` and flows through + the `else` branch — which already relocates, requires the returned path to + match, and attests `persisted-v1`. The deferred shape was never broken. + (This is why the R8-2 visibility fix deliberately stops short of the + restore response value: widening it there would push this working shape + into the fail-closed branch below.) +- The branch is entered only when a cold-restored session genuinely has a + live prompt (`hasActivePrompt && !attached && currentCwd === undefined`). + Relocating there is impossible: `changeSessionCwd` chains onto the prompt + queue and throws `CdWhilePromptActiveError` while a prompt is live, so the + proposed sequence would hang the load until timeout and then fail it, + discarding the recovered session — a regression against today's behavior, + which returns the session merely unattested. + +The Part 4A refusal to relocate this shape was therefore right on the merits +and stands. What remains wrong in `main` is chiga0's actual finding: the +branch silently under-attests (worktree metadata without `persisted-v1`), so +a later Channel load fails the identity check for reasons no signal names. + +The fix is chiga0's second offered shape — fail closed instead of attesting +an unrelocated session: remove the `hasUnlocatedRestoredPrompt` branch so the +shape falls into the existing `else if (session.hasActivePrompt)` check, +which throws `Active session is outside its worktree` when `currentCwd` is +undefined, and the restore fails the integrity path like every other +unverifiable state. + +The reachability premise that came with the removal was too wide, and round 4 +corrects it. The reviewer-run E2E on #10643 did find the branch unreachable +for the shapes it exercised, but "raw non-Channel load has a known +`currentCwd`" does not hold for a cold restore: the bridge's cold-restore +response reports no `currentCwd` at all, and its `hasActivePrompt` counts the +restore `ask_user_question` prompt it admitted (`restorePromptAdmitted || +promptActive || goalTurnActive`). Parking versus firing that prompt is what +decides the shape — a Channel restore parks it through the deferral above, +while a restore that asked for no deferral fires it and returns +`hasActivePrompt: true` with no recorded cwd. A supported non-Channel cold +restore of a Part 4A session whose transcript ends on an unanswered question +— with the daemon's opt-in `restoreAskUserQuestion` enabled, which is what +re-hangs it, and a client id supplied, which is what lets it fire — therefore +reaches the check with a live prompt inside the worktree the child restored +for itself, and an unconditional throw turns it into a 500 that kills the +session it just recovered. + +The invariant is consequently narrower than a blanket "an unlocated active +prompt fails closed": a restore whose prompt was fired rather than parked is +not treated as outside its worktree. Parking is what makes an unlocated +prompt readable as inactive; a fired prompt is a live turn whose location the +response simply does not report. The fail-closed throw keeps its purpose for +a prompt the route cannot locate and that the restore did not fire. + +The shipped shape gates the throw on the deferral having been available, +rather than parking the restore prompt for every Part 4A worktree restore: +the under-attesting branch stays removed for any restore that could have +deferred, and returns only for one that could not +(`hasActivePrompt && !attached && currentCwd === undefined && +!deferRestoreAskUserQuestionPrompt`). A restore that asked for the deferral +and still reports an unlocated active prompt fails closed through the +existing `Active session is outside its worktree` check; a restore that never +asked for one keeps the pre-4B outcome — worktree metadata attached without +the `persisted-v1` attestation the route cannot earn, no relocation, no kill. +That retains R1-2's silent under-attestation for exactly this one shape, +deliberately: an isolation-aware caller rejects an unattested response at its +own identity gate, which is a recoverable refusal, while failing the restore +closed destroys a session the caller just recovered inside a checkout a +non-suppressed restore lets the child place itself in. A non-Channel +regression test pins it — no persisted source, a load response with an active +prompt and no `currentCwd`: `200`, worktree attached, no `changeSessionCwd`, +no kill — and goes red if the unconditional throw returns. + +### Capability + +Add `session_worktree_reset_v1` to the serve capability registry, advertised +only when the reset route and transfer primitive are present. The worker reads +it at startup beside `session_worktree_persistence_v1` and passes a +`sessionWorktreeReset` boolean into `DaemonChannelBridge`; the bridge rejects +a worktree reset request before invoking its session factory when the flag is +absent. A new worker against an old daemon therefore fails before any daemon +side effect; an old worker never sends the request. + +## Channel design + +### Manager reset + +`NamedSessionManager.reset()` replaces the Part 4A rejection with: + +1. Under the owner lock, resolve the selected open task as today. +2. If the task is shared, keep the exact current flow. +3. If the task is worktree-isolated, require `!isBusy(task.sessionId)` and + throw the actionable busy message otherwise. Then call the new router + operation below, swap `sessionId` in the task record (name, cwd, + isolation, target, and creation timestamp preserved; the update and + selection timestamps bump as they do on a shared reset), commit the + registry atomically, activate the new session with the task's stored cwd, + and forget the old session — the same post-success steps as a shared + reset. + +Registry schema stays version 1: only the task's `sessionId` value changes. + +### Router + +New `SessionRouter.replaceManagedWorktreeSession(target, workspaceCwd, +expectedCwd, oldSessionId)`: + +- calls a new bridge method `resetWorktreeSession(oldSessionId, workspaceCwd, +options, bindingToken)`; +- validates the response through the existing + `validateManagedSessionIdentity` with `expectedCwd` set to the task's stored + cwd — an adoption that relocated anywhere else, or that lacks + `persisted-v1`, detaches the new client and fails without publishing + target/cwd/live-route state; +- on success records the new session's target and canonical cwd and returns + the new session ID. + +The load path learns the superseded redirect inside the router, not the +manager: when the daemon surfaces the typed `worktree_session_superseded` +error, `loadManagedSession` reads the replacement ID off it, exact-loads that +ID once (a second superseded response is a genuine failure, not another +hop), heals the route mappings that pointed at the superseded session, and +returns the healed ID alongside `loaded`. The manager's `loadTask` consumes +that ID for every registry write it makes — a heal that is folded into the +stored owner rather than rebuilt from the pre-heal snapshot — so `use`, +`close`, and lock resolution cannot clobber it. + +The typed restore failures map to the bounded messages shown above on the +shared named-session error surface, which every selection, message, and +`/clear` path already reports through: `worktree_marker_missing` to the +recovery message and `worktree_reset_interrupted` to the interrupted-transfer +message. Both codes are produced only by the load/resume route, so they reach +that surface wrapped in the manager's generic load failure rather than the +reset call — the reset route resumes an interrupted transfer itself. A reset +refused because a session is busy keeps its own cancel-first wording, and +every other daemon failure keeps the existing generic named-session message. + +### Bridge, worker, SDK + +- `ChannelAgentBridge` gains an optional `resetWorktreeSession`; the daemon + bridge implements it with a capability gate + (`options.sessionWorktreeReset`) and forwards through the existing session + factory pipeline with an added `worktreeReset: { sessionId }` request field. +- The daemon worker reads `session_worktree_reset_v1` from the capabilities + envelope and passes the flag into `DaemonChannelBridge`, mirroring + `sessionWorktreePersistence`. +- The SDK adds a `DaemonSessionClient.resetWorktree(...)` entry point that + POSTs the new route and constructs a client for the returned replacement + session; the worker's session factory branches to it when + `worktreeReset` is present. The existing reattach identity guard applies to + the replacement client unchanged. + +### Command and messaging surface + +- `doClear` is unchanged: the manager's reset either succeeds and the + existing per-session cleanup retires `S_old` (non-destructive; no worktree + path is ever passed to it), or throws before any side effect. The manager's + reset return gains the task isolation so the acknowledgement can note that + worktree files were kept. +- The worktree reset acknowledgement notes that files were kept. +- Busy worktree reset returns the cancel-first message above. +- Restore-time marker-missing failure returns the recovery message above, and + an interrupted transfer the retry message above — each in its selected-task + or not-selected shape. The daemon error carries no task identity, so the + manager threads the failing task's name to the message surface on the error + it throws (`NamedSessionTaskError.taskName`); without it the message can + neither name the task nor tell whether that task is the selected one, and + falls back to the unnamed, clear-free wording. + +## Compatibility + +- `multiSession` absent or false: no change of any kind. +- Shared tasks: reset, create, load, close unchanged. +- Old daemon + new worker: missing `session_worktree_reset_v1` fails the + reset request in the bridge before any daemon call; restore behavior is + unchanged. +- New daemon + old worker: the worker never calls the route; Part 4A behavior + unchanged. +- Part 4A registries: no schema change; the session-ID swap is a value change + only. A registry committed after a completed reset points at `S_new`, whose + sidecar and marker validate normally on any binary that understands + `isolation: "worktree"`. +- Downgrade between reset and restart: an older worker reads the same + registry and restores `S_new` through the Part 4A path; `supersededBy` is + ignored by readers that predate it. +- `S_old` after reset: its transcript and catalog record persist, and restore + fails closed for every caller with the typed `worktree_session_superseded` + redirect and the replacement ID. The pre-load stop that emits it is + Channel-scoped, but the Part 4A validation every non-live restore runs + repeats the check before it consults the marker, so a non-Channel caller + receives the same typed code rather than the ordinary non-typed Part 4A + integrity `409` — and simply fails closed on it. Every non-live Part 4A + restore takes the worktree-keyed ownership lock for the same reason: the + reset route is public, so a source-less restore must not attest exclusive + ownership while a transfer moves it. `S_old` never reclaims the worktree. + +## User-facing error classes + +Bounded messages, no paths, session IDs, or daemon bodies — a task name is +allowed, and the two recovery classes below interpolate one: + +- busy worktree reset (cancel-first guidance); the reset-pending barrier + reuses the same typed `worktree_reset_active` refusal, and no chat-facing + message exists for it by decision — the Channel owner lock keeps users out + of the window, so only a non-Channel API caller can meet it; +- daemon without the reset capability; +- invalid or unavailable worktree state during reset (generic named-session + failure with the existing narrow categories); +- marker missing at restore (recovery guidance: clear to recreate, or close) + — offered only when the failed task is the selected one, since clearing + always acts on the selection; otherwise the message names the failed task + and points at selecting it first or closing it with `/session close`, which + needs no load of the target; +- interrupted transfer (retry guidance: clear again to finish), under the + same selected-task condition and with the same not-selected alternative; +- superseded task restoration otherwise heals silently, surfacing only if + the replacement fails validation, as a generic load failure. + +## Implementation sequence + +1. Bridge deferred-prompt visibility (R8-2): one shared definition of + prompt-in-progress covering the parked map, consumed by the coalescer and + `getSessionSummary` — and explicitly not by the restore response's + `hasActivePrompt`; the reset-pending barrier primitive (set, refuse + admission, clear); the attach-severing and worktree-association clearing + surfaces the transfer's step 6 needs, with severing reporting whether the + superseded entry is actually gone so step 6 can surface a survivor. +2. Core marker primitive: a synchronous no-follow strict marker reader beside + the async one (for the `assertCanCommit` re-check), then + `transferWorktreeSessionMarkerOwner` with the foreign-uid refusal and the + compare-and-swap commit shapes, and strict-reader tests for every crash + window. +3. Daemon route `POST /session/:id/worktree-reset`: worktree-keyed + serialization (widening the restore lock into + `acquireWorktreeOwnershipOp`), preconditions, resumable transfer protocol, + rollback, capability registration. +4. Restore changes: superseded redirect, interrupted-transfer signal, + missing-marker typed failure, and the narrowing of the under-attesting + `hasUnlocatedRestoredPrompt` branch to the restores that could not defer + their prompt — every other unlocated active prompt fails closed. +5. SDK route method and worker forwarding; bridge capability gate; router + replace operation and superseded handling; manager reset; messages. +6. Documentation updates (see below). +7. Focused tests at each layer, then repository build/typecheck/lint, the + daemon-backed Channel E2E plan, and the required audit passes. + +## Expected production scope + +- `packages/acp-bridge/src/bridge.ts`, `bridgeTypes.ts`, and + `bridgeErrors.ts` (deferred-prompt visibility in the summary and coalescer, + the reset-pending barrier and its typed admission error, attach severing, + worktree-association clearing) +- `packages/core/src/services/gitWorktreeService.ts` (transfer primitive, + synchronous strict marker reader) +- `packages/core/src/services/worktreeSessionService.ts` (`supersededBy`, + `supersedes`) +- `packages/core/src/utils/atomicFileWrite.ts` (reuse only; no change + expected) +- `packages/cli/src/serve/routes/session.ts` (new route, restore changes) +- `packages/cli/src/serve/capabilities.ts` (`session_worktree_reset_v1`) +- `packages/sdk-typescript/src/daemon/DaemonClient.ts` and + `DaemonSessionClient.ts` (route method, replacement client) +- `packages/channels/base/src/ChannelAgentBridge.ts`, + `DaemonChannelBridge.ts`, `SessionRouter.ts`, `named-session-manager.ts`, + `ChannelBase.ts` (reset plumbing, capability gate, messages) +- `packages/cli/src/commands/channel/daemon-worker.ts` (capability read) + +Documentation updates: `docs/users/features/channels/overview.md` drops the +"reset is deferred" limitation (the only user-doc location carrying it) and +documents cancel-first reset, file retention, and marker recovery; +`docs/developers/qwen-serve-protocol.md` and +`docs/developers/daemon/08-session-lifecycle.md` define the new route, +capability, and typed errors. + +Tests remain collocated. An E2E plan lands in `.qwen/e2e-tests/` during +implementation and is not committed. + +## Verification plan + +### Manager, router, command + +- `/clear`, `/new`, `/reset` on a selected worktree task succeed on an idle + task, keep name/cwd/isolation, and swap only the session ID. +- Busy (running, queued, or permission-pending) worktree reset rejects before + any `ChannelBase` cleanup side effect; shared busy reset behavior + unchanged. +- Reset response lacking `persisted-v1`, naming a different worktree, or + naming the root detaches the new client and commits nothing. +- Superseded restore self-heals: registry moves to the replacement only after + the replacement passes exact validation. +- An interrupted transfer surfaces the retry message, and the retried + `/clear` completes the transfer. +- Recovery advice is state-dependent: with a second, healthy task selected, a + marker-missing or interrupted failure on another task names that task, + points at `/session close `, and never tells the user to clear again + — a following `/clear` still acts on the selection, which is exactly why + the advice must not recommend one. The selected-task wording is pinned + verbatim by its own cases. +- Cross-owner isolation, the eight-open-task cap, and case-insensitive name + uniqueness are unchanged by the session-ID swap. + +### Daemon route and transfer + +- Full precondition matrix: unknown session, shared session, foreign + workspace, wrong `originalCwd`, containment failure, active prompt, pending + interaction, parked deferred prompt (rejected via the visibility fix), + invalid marker, missing marker with valid sidecar (accepted), missing + marker without catalog record (rejected), and residual attaches severed by + the transfer rather than blocking it. +- The reset-pending barrier refuses a prompt admitted after the quiescence + check and is cleared by every pre-flip failure path — past the flip the + release belongs to a completed severance, not to the failing request; the + pre-flip re-verify aborts a transfer whose prompt state changed. The same + barrier refuses the other writers that can reach the checkout or move the + session cwd — a rewind, a cwd change, a branch, a fork-agent launch, a + shell command, a workflow-task action, or a goal control admitted + mid-transfer must each reject with the same typed failure, and deleting any + one guard must turn its test red. +- Two concurrent resets against sessions sharing one worktree (the + post-transfer shape) cannot both win: the second fails the lock, the owner + re-check, or the publishing link's `EEXIST`. +- Crash injection at every transfer step proves the documented outcome: + pre-transfer crashes leave `S_old` authoritative; post-transfer crashes + restore `S_new`; a retried reset completes a half-finished transfer exactly + once, and a marker that already names `S_new` makes the resume a no-op. The + backward-link-only window (step 3 crashed, no replacement sidecar) is + pinned as fail-closed `worktree_reset_invalid_state` with the interrupted + state untouched — the write order is what makes that window observable to + the retry instead of invisible. +- Post-flip resume with a busy replacement: crash past the marker flip, make + `S_new` busy (a client reached it through the superseded redirect), retry + the reset of `S_old` — the route fails closed with `worktree_reset_active` + and the marker, `S_new`'s sidecar, and `S_new`'s record all survive; a + later retry once `S_new` is idle completes as a no-op resume. The + destructive rollback must never run against the session the marker names. +- Controlled failure at steps 3–5 compensates to the exact pre-reset state. +- Marker transfer never follows a swapped-in symlink, never leaves a partial + or empty marker, and re-reads the owner at the commit point. +- A marker owned by a different uid than the daemon is refused rather than + written in place, so `atomicWriteFile`'s non-atomic ownership-preserving + path is never selected for a marker. +- The worktree directory, its files (tracked, modified, untracked), and its + branch are byte-identical across reset; only the marker content changes. + +### Restore and bridge + +- Missing marker + valid sidecar: restore fails with the typed missing-marker + signal; reset recovers; the recovery message reaches the chat. +- Invalid marker: restore and reset both fail closed; nothing is recreated. +- A restored session whose prompt is live and unlocated fails closed through + the existing active-session check — no silent under-attestation — and the + removed branch's shape is covered by a test that fails if the branch + returns. Its fired-prompt counterpart is covered beside it: a non-Channel + cold restore of a Part 4A session whose transcript ends on an unanswered + question, whose load response reports an active prompt and no `currentCwd`, + returns 200 with the worktree attached instead of a 500 that kills the + session it just recovered. +- Deferred-prompt visibility: a parked restore prompt reads as active in + `getSessionSummary` and the coalescer; the R8-2 concurrent-restore probe + (waiter observes the deferred state, no `CdWhilePromptActiveError` 500) is + reproduced as a regression test. +- The same visibility fix leaves a deferred worktree restore reaching + `persisted-v1` through the relocating branch — asserted directly, so a + later widening of the restore response's `hasActivePrompt` fails the test + instead of silently failing the restore. +- Superseded restore pre-empts the bridge load and carries the replacement + ID; the interrupted sub-case carries `worktree_reset_interrupted`. +- Capability absent: the bridge rejects before the factory runs; old-daemon + responses fail Channel validation without creating state. + +### Concurrent E2E + +Extend the Part 4A daemon-backed plan: create shared + worktree tasks, run +work, reset a worktree task mid-plan, verify the fresh conversation, the +intact files (including uncommitted changes), the unchanged task list, and +Part 3A labels; reset recovery after a simulated marker deletion; worker and +daemon restart before and after reset; reset rejection while a task runs, +then success after `/session cancel`. + +### Repository checks + +Focused package tests, then `npm run build`, `npm run typecheck`, +`npm run lint`; test-engineer daemon-backed E2E; the required self-audit +passes (two consecutive clean) before delivery. + +## Risks and controls + +### Transfer crash windows + +Risk: a crash mid-transfer strands the task between two owners. + +Control: the transfer is ordered so the marker — the single ownership +authority — flips last, the superseded sidecar makes the flip discoverable, +and the route resumes a half-finished transfer on retry. The two sidecar +writes are ordered for the same reason: `supersededBy` on `S_old` lands +first, so no window leaves a forward link that nothing reads and a retry +cannot see. Every window is enumerated in the protocol above with its +outcome, including which ones a retry rolls back (an agreeing link pair) and +which ones fail closed for operator repair (a disagreeing pair, an invalid +marker, an ambiguous owner, a live replacement with no marker). + +### Reset of a busy task + +Risk: transferring ownership while a prompt executes in the worktree races +file writes and the marker flip. + +Control: quiescence is enforced as a barrier plus a re-check, not a sample — +the bridge refuses new work on a reset-pending session from the precondition +gate until the flip, covering prompt admission and the seven other writers +that can reach the checkout or move the session cwd (`sendPrompt`, +`rewindSession`, `changeSessionCwd`, `branchSession`, +`launchSessionForkAgent`, `executeShellCommand`, +`controlSessionWorkflowTask`, `controlSessionGoal`), and quiescence is +re-verified immediately before the flip. A rewind is the reason the fence +cannot stop at prompts: it is admitted precisely in the idle state the route +requires, chains onto the same queue, and sets none of the flags the pre-flip +re-check reads — and so do the two control writers, a workflow action that +runs through the session's own tool registry in that cwd and a goal `resume` +that starts a turn never passing `sendPrompt`. A shell command is the +strongest of the eight — it runs in the entry's effective cwd, which for a +relocated worktree session is the checkout itself — so it is fenced too. +Those eight are the whole fence, and it is not a fence on everything that +reaches the child: the release and stop paths stay usable mid-transfer by +design (`cancelSessionTask`, `clearSessionGoal`, `detachClient`, +`killSession`), `continueSession` is refused transitively at the +`sendPrompt` admission it drives its continuation through rather than gated +itself, and the daemon-internal `enqueueBackgroundNotification` is ungated +because no HTTP route calls it and it enqueues a notification rather than +starting a turn. The Channel refuses busy tasks up front (`isBusy` covers +queued, running, and permission-pending states); the user cancels first. A +parked deferred restore prompt counts as busy once the visibility fix lands, +so a task stopped on a recovered question cannot be reset out from under the +question. + +### Concurrent reset double-win + +Risk: after a completed or crashed transfer, two sessions hold valid sidecars +naming the same worktree; two concurrent resets could both claim the marker. + +Control: serialization is keyed on the canonical worktree path, the commit is +a compare-and-swap (owner re-check at the rename commit for the `valid` +shape, `EEXIST` from the publishing link for the `missing` shape), and a +superseded sidecar routes to the resume path rather than starting a +competing transfer. The second-place finisher fails closed in all shapes. + +### Silent downgrade of the old session + +Risk: after reset, a restore of `S_old` could fall back to the shared root +workspace and resume a stale conversation in the wrong directory. + +Control: `S_old`'s sidecar is retained and marked superseded; its marker no +longer matches, so every restore path fails closed. The typed superseded +signal is the only redirect, and it requires the replacement to pass full +identity validation before the Channel registry moves. + +### Recovery-hatch abuse + +Risk: the missing-marker hatch could recreate ownership on a worktree that +was deliberately stripped. + +Control: the hatch requires the conjunction of a strict-valid Part 4A +sidecar, a matching catalog record, containment, quiescence, and an invalid +(rather than absent) marker still fails closed. Recovery replaces the +conversation; it never repairs or trusts a tampered marker. + +## Alternatives reviewed + +### Extend `POST /session` creation with an adopt option + +Rejected. The create route's invariants were hardened through nine review +rounds in #10643; threading adoption preconditions, a different rollback +contract (never remove the worktree), and the supersede step through it +obscures exactly the boundaries reviewers signed off. A dedicated route keeps +the new lifecycle operation explicit, shares implementation helpers without +sharing control flow, and fails closed on old daemons by absence. + +### Reuse the existing shared reset for worktree tasks + +Rejected — this is the Part 4A design's own rejection. Creating the +replacement session at the worktree path routes through an unregistered +workspace; creating it at the root strands the worktree's ownership on the +old session. Neither preserves exact recovery. + +### Transfer ownership by blind temp-file rename without a commit-point check + +Rejected during this design's review. A plain rename is not a +compare-and-swap: with the marker absent it degrades to last-writer-wins, and +even with the marker present it leaves the window between the opening strict +read and the rename unguarded. The commit must re-check the owner +(`assertCanCommit`) or exclude by absence (`EEXIST` on the exclusive +publish). + +### Transfer ownership by unlink + exclusive create + +Rejected for the `valid`-owner shape. The window between unlink and re-create +leaves the worktree ownerless; a crash there forces reliance on the recovery +hatch for a routine operation. (For the `missing` shape an exclusive create +is exactly right and is what the primitive uses — staged in a sibling and +published by the link.) + +### Reattach the old session ID to a fresh conversation + +Rejected. Reusing the session ID conflates two conversations under one +transcript identity and breaks the daemon's per-session storage addressing. +A fresh ID keeps reset semantically identical to the shared-task reset the +rest of the system already models. + +### Cancel-then-reset inside the daemon route + +Rejected. Prompt cancellation is Channel-owned semantics with bounded +wind-down and wedged-turn handling; pushing it into the daemon route +duplicates that policy where it cannot observe the chat. Requiring +quiescence keeps one cancellation implementation; the reset-pending barrier +refuses new admissions without cancelling anything. + +### Delete the old session record during reset + +Rejected. Shared reset retains the previous conversation in the daemon +catalog; worktree reset does the same. The superseded sidecar, not deletion, +is what prevents the old session from reclaiming the worktree. + +### Land the orphan-reap worktree cleanup in this part + +Rejected during this design's review. It is the only path in the series that +deletes user-visible state, and review showed its correct shape (call-site +placement, `kind === 'removed'` gating, supersede-link refusal, the +safe-delete branch invariant) needs a dedicated review cycle rather than +riding beside a part whose contract is "no deletion of user-visible state". +It is fully specified in follow-up issue #11024. + +## Exit criteria + +Part 4B is complete when: a worktree task resets in place with files intact; +busy and deferred-prompt-parked reset refuse cleanly; a missing marker is +recoverable only through reset; a tampered marker never recovers +automatically; a half-finished transfer is completed by retry and healed +through the superseded redirect; the under-attesting restore branch is gone +and the deferred-prompt visibility fix closes R8-2; and no path deletes +user-visible or pre-existing state — rollback removes only the replacement +session's own sidecar and record. + +Issue #10103 closes when the above holds **and** the remaining dispositions +in the table are satisfied: the four residual fixes (R8-1, R8-3, R1-1, F3), +included in this PR, and follow-up issue #11024 (reap cleanup, the sibling +delete path, and the `SessionRouter.ts:487` investigation) resolved or +explicitly accepted by a maintainer. diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index 2483cc1419a..c7e7ae8e7ab 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -126,7 +126,7 @@ Use these anchors when moving from the docs into the latest `main` code: | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | HTTP routes | The route catalog lives in `qwen-serve-protocol.md`; this daemon set only references it and explains implementation ownership. | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md), [`20`](./20-quickstart-operations.md) | | Event schema | `EVENT_SCHEMA_VERSION = 1`; 53 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped by `EventBus.publish()` (with `formatSseFrame()` fallback for synthetic frames). | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | -| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 157 registered tags; 45 conditional tags. | [`11`](./11-capabilities-versioning.md) | +| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 158 registered tags; 45 conditional tags. | [`11`](./11-capabilities-versioning.md) | | Session shell | `POST /session/:id/shell` exists behind `--enable-session-shell`, bearer or trusted-loopback authority, and session-bound `X-Qwen-Client-Id`; capability tag is conditional. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md), [`20`](./20-quickstart-operations.md) | | Rate limiting | Optional per-tier HTTP rate limit is exposed by CLI flags/env and conditional capability tag. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md) | diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md index 61703c865fa..58a435585e4 100644 --- a/docs/developers/daemon/08-session-lifecycle.md +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -110,7 +110,9 @@ Both: 1. Use a per-session `pendingRestoreIds` set on the channel so concurrent restore calls coalesce (`RestoreInProgressError`). 2. Cache `restoreState` on the entry so a late attacher gets the same payload the original restorer did. -For a persisted Part 4A worktree session, restore is an integrity-gated extension of this lifecycle. The sidecar identifies the requested workspace root explicitly, the checkout must be canonically contained below the corresponding `.qwen/worktrees/` directory, and its marker must be a single-link regular file containing the exact restored session ID. The daemon relocates an idle restored child only after those checks; an active child is accepted only when its reported cwd already equals the worktree. Those responses return canonical worktree metadata with `worktreeState: "persisted-v1"`. A cold restore that reports an active prompt without a current cwd returns unverified worktree metadata without that attestation, so isolation-aware clients reject it and may retry after the prompt settles. Invalid Part 4A state detaches an existing attachment or kills a cold restore with `requireZeroAttaches`; a missing sidecar likewise yields no attestation. Whenever the effective restore source is Channel-owned, the route suppresses the ACP agent's best-effort cleanup for Part 4A or unclassifiable sidecar state, so validation failure preserves the uncertain checkout evidence. Persisted source metadata takes precedence; when it is absent, the load/resume request supplies the effective source. A structurally valid legacy sidecar without `workspaceCwd` instead retains the existing best-effort agent restore: it must identify either the requested workspace root or its Git repository top-level, is containment-checked without marker attestation, may be cleaned up by the agent, and may return `worktree` without `worktreeState`. Apart from that explicit legacy compatibility case, only sessions whose effective restore source is not Channel-owned retain the existing best-effort cleanup before route validation. +For a persisted Part 4A worktree session, restore is an integrity-gated extension of this lifecycle. The sidecar identifies the requested workspace root explicitly, the checkout must be canonically contained below the corresponding `.qwen/worktrees/` directory, and its marker must be a single-link regular file containing the exact restored session ID. The daemon relocates an idle restored child only after those checks; an active child is accepted only when its reported cwd already equals the worktree, and an active child whose reported cwd is absent or elsewhere fails closed instead of being relocated under its prompt — except for a cold restore that could not defer its restore-prompt (`suppressWorktreeContextRestore` off, so the bridge fired the re-hung question instead of parking it): that shape keeps the pre-4B outcome, returning canonical worktree metadata without `worktreeState`, unrelocated, with the session surviving. The relocated and accepted responses return canonical worktree metadata with `worktreeState: "persisted-v1"`. A sidecar carrying `supersededBy` never restores: the route returns `409 worktree_session_superseded` with the replacement session id, a classification decided from that link alone before any marker read, so callers redirect and heal their bookkeeping only once a load of that id has succeeded — a pre-commit interrupted transfer names a replacement that is not the marker owner, cannot itself be restored, and is reaped by the retried reset; a restored replacement whose `supersedes` link agrees with the old sidecar while the marker never moved — or is absent — returns `409 worktree_reset_interrupted`, whose repair is retrying the reset against the superseded session; and a missing marker without that agreeing link pair returns `409 worktree_marker_missing`, whose repair is resetting the task rather than retrying the restore, because no restore path recreates a marker. The interrupted classification is checked first. Invalid Part 4A state detaches an existing attachment or kills a cold restore with `requireZeroAttaches`; a missing sidecar likewise yields no attestation. Whenever the effective restore source is Channel-owned, the route suppresses the ACP agent's best-effort cleanup for Part 4A or unclassifiable sidecar state, so validation failure preserves the uncertain checkout evidence. Persisted source metadata takes precedence; when it is absent, the load/resume request supplies the effective source. A structurally valid legacy sidecar without `workspaceCwd` instead retains the existing best-effort agent restore: it must identify either the requested workspace root or its Git repository top-level, is containment-checked without marker attestation, may be cleaned up by the agent, and may return `worktree` without `worktreeState`. Apart from that explicit legacy compatibility case, only sessions whose effective restore source is not Channel-owned retain the existing best-effort cleanup before route validation. + +Worktree ownership transfer (`POST /session/:id/worktree-reset`, advertised by `session_worktree_reset_v1`) extends this lifecycle for Channel task resets: the daemon spawns a fresh thread-scoped replacement in the root workspace, relocates it into the verified checkout, links the sidecar pair (`supersededBy` on the old session first, then `supersedes` on the replacement), flips the marker to the replacement under a per-checkout route lock and an admission barrier that fences prompt admission plus the seven other writers that start work in the checkout or move the session cwd (rewind, cwd change, branch, fork, shell, goal control, workflow-task action), while the release and stop paths stay unfenced by design, and then severs the superseded session's client registrations and in-memory worktree association. The sever reports whether the superseded session is actually gone: a survivor whose child still holds background work keeps the barrier armed, is logged, and is reported to the caller as `supersededSessionLive: true` rather than being papered over by the success response. A fenced writer admitted on the superseded session mid-transfer is refused with `409 worktree_reset_active`; the full failure taxonomy, including which crash windows a retry rolls back and which fail closed for operator repair, is documented with the route in `qwen-serve-protocol.md`. ### Heartbeat diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 90544fbf787..b2a3ea53431 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -1133,7 +1133,9 @@ Stable contract: when `v` increments the frame layout has changed in a backwards > **`workspaces[]`** lists every registered runtime. Newer single-workspace daemons include the primary runtime even when `multi_workspace_sessions` is absent so clients can discover the stable id required by workspace-qualified routes; older daemons may omit the array. Each entry is `{ id, cwd, displayName?, primary, trusted, removable? }`. `displayName` is presentation-only and omitted when unset. The first/primary workspace remains mirrored by `workspaceCwd`; new clients choose a non-primary runtime by passing that entry's `cwd` to `POST /session`. Untrusted workspaces are advertised for diagnostics but reject fresh session creation with `403 untrusted_workspace` until trust changes. `removable` is present on daemons that support runtime removal and is true only for process-dynamic or persistence-restored secondary runtimes. -> **`session_worktree_persistence_v1`** means the daemon can persist and verify Part 4A worktree ownership. Successful worktree creation responses, and restore responses whose child is either relocated while idle or already reports the verified worktree cwd, carry `worktree` metadata plus `worktreeState: "persisted-v1"`. A legacy best-effort restore or a cold active-prompt restore with no reported current cwd may return `worktree` without that attestation. Clients requesting isolation must pre-flight this tag and verify each response; the `worktree` object alone is not durable-ownership proof. +> **`session_worktree_persistence_v1`** means the daemon can persist and verify Part 4A worktree ownership. Successful worktree creation responses, and restore responses whose child is either relocated while idle or already reports the verified worktree cwd, carry `worktree` metadata plus `worktreeState: "persisted-v1"`. A legacy best-effort restore, or a cold restore whose restore-prompt was fired rather than parked (`suppressWorktreeContextRestore` off, so the route asked the bridge for no deferral) and which therefore reports an active prompt with no current cwd, may still return `worktree` without that attestation. Clients requesting isolation must pre-flight this tag and verify each response; the `worktree` object alone is not durable-ownership proof. + +> **`session_worktree_reset_v1`** means the daemon supports worktree ownership transfer: `POST /session/:id/worktree-reset` moves a persisted worktree session's checkout ownership to a fresh replacement session. Restore responses gain three typed 409 classifications alongside it: `worktree_session_superseded` (the session's sidecar carries a `supersededBy` link — the classification is decided from that link alone, before any marker read, and the link is written before the marker flips, so the body's `replacementSessionId` is a redirect to verify rather than proof of ownership: in a pre-commit interrupted state it names a session that is not the marker owner, cannot itself be restored, and is reaped by the retried reset), `worktree_marker_missing` (the checkout marker is absent; reset the task to recreate it — a restore retry cannot, because no restore path writes a marker), and `worktree_reset_interrupted` (a previous transfer crashed mid-flight with its sidecar links agreeing; retry the reset). The interrupted classification is checked first: a missing marker whose `supersedes`/`supersededBy` links agree surfaces as `worktree_reset_interrupted`, never as `worktree_marker_missing`. See the route section below for the transfer protocol and failure taxonomy. The workspace feature tags and `workspaces[]` are dynamic. Clients that add a workspace must fetch `/capabilities` again after the mutation completes; the daemon does not broadcast capability changes to clients that cached an earlier response. Forgetting persistence does not unload an active runtime, so that runtime remains advertised until restart. @@ -2229,7 +2231,7 @@ Response: `attached: true` means a session for that workspace already existed and you're now sharing it. -`worktreeState` is present only for an attested worktree session. Legacy best-effort restore may return `worktree` without `worktreeState`. A cold Part 4A restore that still reports an active prompt but no current cwd also returns unverified `worktree` metadata until the prompt settles. Neither response is durable execution-root proof. A client that requested worktree isolation must require `worktreeState: "persisted-v1"` and the expected canonical path before routing a prompt; an initial load may be retried after the active prompt settles. On SDK reattach, a missing attestation or changed path is terminal for that client: the new attachment is detached and the prompt is not retried. +`worktreeState` is present only for an attested worktree session. Legacy best-effort restore may return `worktree` without `worktreeState`. A cold Part 4A restore whose restore-prompt was fired rather than parked (`suppressWorktreeContextRestore` off, so the route asked the bridge for no deferral) also returns unverified `worktree` metadata until the prompt settles: the child reads active with no reported cwd, relocation is impossible under a live prompt, and the route keeps the session rather than killing the one the caller just recovered. Neither response is durable execution-root proof. A client that requested worktree isolation must require `worktreeState: "persisted-v1"` and the expected canonical path before routing a prompt; an initial load may be retried after the active prompt settles. On SDK reattach the outcome splits on whether the response still carries `worktree` metadata: a response that carries it with a missing attestation or a changed path is terminal for that client — the new attachment is detached and the prompt is not retried — while a response with no `worktree` object at all heals: the client drops its cached worktree claim, keeps the new attachment, and retries the prompt in the same call. The heal does not gate that retry, so one turn can still execute in a directory the client can no longer attest (an `exit_worktree` the client never saw is the ordinary cause, and the client cannot distinguish it from a cleared in-memory association). An isolation-aware caller must therefore re-verify the worktree attestation itself before dispatching a prompt; the dropped claim only makes that caller fail closed at its own identity gate on its next load or selection. Caller-supplied IDs are unique across all currently registered workspace runtimes and every still-live bridge generation, including draining replacements. A live, pending, active, archived, or worktree-backed duplicate returns `409 session_id_conflict`. Invalid values return `400 invalid_session_id`; an unavailable live-owner or persisted-state check returns retryable `503 session_id_admission_unavailable`. Retry with bounded backoff after bridge or storage health changes; `retryable` means another attempt is safe, not that an immediate retry will succeed. If the downstream agent returns a different ID, the daemon removes that orphan and returns `500 session_id_not_honored`. After an ambiguous response, load or resume the known ID instead of retrying create as an attach. @@ -2312,7 +2314,7 @@ Response: `attached: true` means the session was already live (either from a prior `session/load`/`session/resume`, or because a coalesced concurrent caller raced just ahead). -When the persisted session owns a Part 4A worktree, load/resume validates its sidecar, canonical containment, and exact checkout marker. An idle child is relocated before the response; an active child is attested only when its reported cwd already equals the worktree. Those responses return `worktree` plus `worktreeState: "persisted-v1"`. A cold restore that reports an active prompt without a current cwd instead returns unverified `worktree` metadata without `worktreeState`; isolation-aware clients reject that response and may retry after the prompt settles. Whenever the effective restore source is Channel-owned, the route suppresses the agent's best-effort cleanup for Part 4A or unclassifiable sidecar state, so stale, foreign, ambiguous, or ownership-mismatched persisted state is preserved and fails the request. Persisted source metadata takes precedence; when it is absent, the load/resume request supplies the effective source. A structurally valid legacy sidecar without the Part 4A `workspaceCwd` field retains the existing best-effort agent restore, may be cleaned up by that path, and may return `worktree` without `worktreeState`; clients must not infer isolation from that compatibility metadata. Apart from that explicit legacy compatibility case, only sessions whose effective restore source is not Channel-owned retain the existing best-effort cleanup before route validation. A missing sidecar returns no worktree attestation; isolation-aware clients must reject that response rather than rebinding the session to the shared workspace. +When the persisted session owns a Part 4A worktree, load/resume validates its sidecar, canonical containment, and exact checkout marker. An idle child is relocated before the response; an active child is attested only when its reported cwd already equals the worktree, and an active child whose reported cwd is absent or elsewhere fails closed instead of being moved under its prompt — except for a cold restore that could not defer its restore-prompt (`suppressWorktreeContextRestore` off — any restore whose effective source is not Channel-owned — or a bridge without the deferral API): that shape still returns `worktree` without `worktreeState`, unrelocated, and the session survives. The relocated and attested responses return `worktree` plus `worktreeState: "persisted-v1"`. Restore-side typed 409 classifications (advertised by `session_worktree_reset_v1`): `worktree_session_superseded` when the session's sidecar carries a `supersededBy` link — the classification reads that link alone, under the per-checkout ownership lock and before any marker read, so a transfer that rolls back while the load waits cannot redirect the caller to a session the same daemon is deleting; the link is written before the marker flips, though, so the body's `replacementSessionId` is a redirect to verify rather than proof that ownership moved: after a committed transfer callers should load that id instead and update their bookkeeping, but in a pre-commit interrupted state it names a replacement that is not the marker owner, whose own restore returns `worktree_reset_interrupted`, and that the retried reset then reaps — so `replacementSessionId` must not be persisted as the task's session id until a load of it succeeds; `worktree_reset_interrupted` when the restored session's `supersedes` link and the named session's `supersededBy` link agree but the marker never moved or is absent — a previous reset crashed mid-transfer and a retried reset is the repair; and `worktree_marker_missing` when the checkout marker is absent without that agreeing link pair — resetting the task is the repair, because no restore path recreates a marker and a restore retry returns the same 409. The interrupted classification is checked first, so an agreeing link pair never surfaces as `worktree_marker_missing`. Whenever the effective restore source is Channel-owned, the route suppresses the agent's best-effort cleanup for Part 4A or unclassifiable sidecar state, so stale, foreign, ambiguous, or ownership-mismatched persisted state is preserved and fails the request. Persisted source metadata takes precedence; when it is absent, the load/resume request supplies the effective source. A structurally valid legacy sidecar without the Part 4A `workspaceCwd` field retains the existing best-effort agent restore, may be cleaned up by that path, and may return `worktree` without `worktreeState`; clients must not infer isolation from that compatibility metadata. Apart from that explicit legacy compatibility case, only sessions whose effective restore source is not Channel-owned retain the existing best-effort cleanup before route validation. A missing sidecar returns no worktree attestation; isolation-aware clients must reject that response rather than rebinding the session to the shared workspace. **History replay over SSE.** While `loadSession` is in flight on the agent side, the agent may emit `session_update` notifications for persisted turns, or return bulk replay updates in the response metadata. The daemon seeds those events into the session's bounded replay snapshot window before the route response returns. For live sessions, `POST /session/:id/load` only promises that bounded window (`compactedReplay`, `liveJournal`, `lastEventId`), not the full transcript. The window is byte-capped by `--compacted-replay-max-bytes` (default 4 MiB, maximum 256 MiB); if older replay entries were dropped, `compactedReplay[0]` is an id-less `history_truncated` marker. The in-flight `liveJournal` is separately capped by `--max-journal-events` (default 10 000 replay entries) and `--max-journal-bytes` (default 8 MiB of serialized source events). These are per-session **baseline** caps. When an in-flight turn outgrows them, the daemon first tries adaptive growth: it raises that session's caps toward double (up to a per-session hard cap of 256 MiB, entries scaled proportionally, limited by the remaining pool headroom) while the growth granted across every live session fits in one daemon-wide growth pool sized at 5% of the daemon's effective memory budget — the `--memory-budget-mb` value when passed, capped at resolved available memory, otherwise 50% of auto-detected memory — capped at `1024` MB. Accounting is daemon-wide — a multi-workspace daemon runs one bridge per workspace and all of them share the single pool. Growth is on demand and only as far as the pool allows; an operator-pinned `--max-journal-events` or `--max-journal-bytes` disables it, as does a host whose effective budget falls below the 1024 MB minimum (`insufficientMemory`): the pool is 0 and adaptive growth is disabled outright. Consecutive compatible `agent_message_chunk` or `agent_thought_chunk` source events share a replay entry, up to 256 source events per entry, while tool, attribution, provenance, and discrete-message boundaries remain intact. When the journal still exceeds its (possibly grown) caps after the growth the pool allows — including when no headroom is granted or a grant covers only part of the overshoot — the oldest entries are dropped whole (so the retained tail can be much smaller than the byte cap) and a `history_truncated` marker with `scope: 'live_journal'` is prepended; its `truncatedEvents` and `retainedEvents` fields count source events, not replay entries, and its `maxBytes` / `maxEvents` reflect the caps in force (which may already have grown). Clients should render that marker as status and continue applying retained events. Full persisted transcript access is exposed separately through `GET /session/:id/transcript`. @@ -2424,6 +2426,38 @@ Use `/load` when the client has no history rendered (cold reconnect, picker → > ⚠️ **Why is `unstable_session_resume` still advertised?** The daemon's HTTP route and `session_resume` capability are stable for v1, but the bridge still calls ACP's `connection.unstable_resumeSession`. The old tag remains only so SDKs that shipped before `session_resume` can keep working. +### `POST /session/:id/worktree-reset` + +Transfer a persisted Part 4A worktree session's checkout ownership to a fresh replacement session. Pre-flight `session_worktree_reset_v1` — older daemons return `404` for this route. The route is how Channel named-task `/clear` resets a worktree task without discarding its files: the replacement gets a brand-new conversation, and the superseded session keeps its transcript in the catalog but can never be restored again. + +Request body (all fields optional): + +```json +{ + "cwd": "/canonical/path", + "modelServiceId": "service-id", + "approvalMode": "plan", + "sourceType": "channel", + "sourceId": "dingtalk-main" +} +``` + +`cwd` follows the same resolution as `POST /session/:id/load` and may be omitted for the daemon's primary workspace. The metadata fields stamp the replacement session with the same thread-scope and source conventions as a worktree creation. + +On success the route returns `200` with the replacement session's create-shape response — the new `sessionId`, `workspaceCwd`, `currentCwd` equal to the verified canonical worktree path, `worktree` metadata, and `worktreeState: "persisted-v1"`. Callers discard their handle on the old id and use the replacement id from then on; the old id's later restore returns `worktree_session_superseded` with `replacementSessionId`, which clients may also use to self-heal stale bookkeeping — but only once a load of that id has succeeded. The classification is decided from the sidecar link alone, and that link is written before the marker flips, so an interrupted pre-commit transfer hands back a replacement that is not the marker owner, whose own restore returns `worktree_reset_interrupted`, and that the retried reset then reaps. + +This route never reads `X-Qwen-Client-Id`, so the replacement spawns unattached — but the success response is not registration-free. A fresh transfer mints an owner-style `clientId` for that spawn, registers it on the replacement, and reports it in the body; an idempotent resume of a committed transfer reports none. The minted id is not an attachment (the replacement's attach count is unchanged, so `killSession` with `requireZeroAttaches` still sees it as unattached), but a live client registration is exactly what holds the daemon's idle cleanup off: detach an id you do not keep using, or the replacement stays live indefinitely — and a replacement that is still live with no marker is the shape a later reset refuses to dismantle. Treat the response as the replacement's identity, and attach it through the normal `POST /session/:id/load` or `/resume` surface when a registered client is required. + +The transfer is serialized per checkout against worktree restores and other resets, refuses to run while either session is busy (prompt in flight or a pending interaction), and arms an admission barrier on the superseded session: while it is armed, exactly eight writers are refused with `worktree_reset_active` at admission — `POST /session/:id/prompt`, `/rewind`, `/cd`, `/branch`, `/fork`, `/shell`, `/goal`, and `/tasks/:taskId/workflow-action`. The other seven are fenced because each moves the session cwd or starts work in it without passing prompt admission: a shell command runs in the session's effective cwd, which for a relocated worktree session is the checkout itself; a fork agent runs its tools in that cwd; a rewind restores files relative to it; a cd moves it, including into a subdir of the checkout; a branch mutates the superseded session's persisted history and spawns a derivative session while ownership is in flux; a goal `resume` promotes a queued turn and queues a continuation; and a workflow action runs a saved workflow, or restarts a live run, through the session's own tool registry. `POST /session/:id/continue` is not a ninth gate: it drives an accepted continuation through the fenced prompt admission and is refused there. That list is the whole fence, and the fence is not everything that reaches the child — the release and stop paths stay usable mid-transfer by design (cancelling a task, clearing a goal, detaching a client, killing the session), and the daemon-internal background-notification enqueue — a sub-session's completion acknowledgement to its parent, which no HTTP route calls — is unfenced as well, because it enqueues a notification rather than starting a turn. The transfer's last step severs the superseded session's client registrations, clears its in-memory worktree association, and reports whether the superseded session is actually gone. A survivor — a child that still holds background work, so the idle close the last detach triggers is refused and deferred — is surfaced rather than papered over: the daemon logs it, the barrier stays armed on that entry as the only fence left, and the `200` body carries `supersededSessionLive: true` so the caller knows the superseded id is still live and re-attachable inside the checkout whose ownership just moved. Crash safety is per window, and the write order defines the windows: the old sidecar's `supersededBy` link is written first, then the replacement's sidecar carrying `supersedes`, and the marker flips last. A crash before the flip therefore leaves the old session authoritative in one of three shapes a retry tells apart — no links at all (the replacement is an ordinary orphan and a retry simply starts a fresh transfer), the backward link alone with the replacement's sidecar missing (a retry fails closed with `worktree_reset_invalid_state` and leaves the interrupted state untouched for operator repair), or both links present (a retry rolls the partial transfer back and completes a fresh one — unless the interrupted replacement cannot be removed because a client is still attached to it, in which case the rollback fails closed with `worktree_reset_invalid_state` and keeps both links, so a later retry converges once that client is gone). A crash after the flip leaves the replacement authoritative and a retry resumes as a no-op. The old session is never deleted and the worktree checkout is never removed by reset. + +**Errors:** + +- `404` — `session_not_found` when the session record does not exist. +- `409` — `worktree_reset_unsupported` when the session has no valid Part 4A sidecar (not a worktree session). +- `409` — `worktree_reset_active` when a session involved in the transfer is busy; retry once it settles. Admission of any writer the barrier covers — a prompt, rewind, cwd change, branch, fork, shell command, goal control, or workflow-task action — on a session mid-transfer fails with the same code. +- `409` — `worktree_reset_invalid_state` for corrupt sidecar, invalid marker, containment failure, or an inconsistent supersede link pair. Non-destructive for what this request started: any partial transfer this request began is rolled back before the caller sees it, while the fail-closed resume branches — an invalid marker, a supersede link pair that does not agree (including a backward link whose replacement sidecar is missing), an ambiguous marker owner, and a replacement that is still live on this daemon while the marker is absent — leave the pre-existing interrupted state untouched for operator repair, so that shape stays visible to a later reset or restore. A retry against one of those states re-reads it and returns the same 409: it does not converge, so the state needs operator repair rather than a retry loop. The specific reason is written to the daemon log rather than the wire. +- `500` — fail-closed internal errors (for example a bridge without reset support, or a relocation rejection after rollback); not retryable as-is. One `500` differs in kind: a marker transfer whose own post-commit tail failed _after_ the flip durably committed reports that ownership moved, never runs the destructive rollback, and is repaired by retrying the reset, which resumes the committed transfer idempotently. + ### `GET /workspace/:id/session-info` and `GET /workspaces/:workspace/session-info` Return aggregate persisted session counts for the selected workspace without changing the paginated session-list path: diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index b8375bf7804..d3340600899 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -118,7 +118,7 @@ The catalog is private to the exact channel, chat, and sender. Task names use 1 Named results identify their originating task: direct chats use `[task]`, while group chats use `[sender · task]`. Named text permission prompts also show the exact request ID and the corresponding `/approve `, `/approve-always `, and `/deny ` commands. The label is presentation-only and is not stored in the model transcript. -One task remains selected to receive the next normal message, but other named tasks may keep running concurrently. `/session new ` shares the configured workspace, while `/session new --worktree` creates an isolated checkout for that task under the daemon workspace's `.qwen/worktrees/` directory. The daemon verifies the persisted worktree owner before reopening the task after a restart; a missing, changed, or foreign ownership record fails closed instead of silently moving the task into the shared workspace. Creating or selecting another task does not cancel or retarget earlier work, and late results retain their originating task label. A busy task cannot be closed, but its active prompt can be cancelled with `/session cancel []` through the existing Channel cancellation behavior. Independently queued turns are not cancelled, but in `collect` dispatch mode any follow-ups buffered behind the cancelled prompt are discarded by that existing behavior. Media preparation is not targeted. Bare permission commands apply only to the selected task, while an explicit request ID can answer an owned inactive task. `/clear`, `/new`, and `/reset` do not replace a selected worktree task; select or create a shared task first. Channel memory remains scoped to the chat rather than to a named task. +One task remains selected to receive the next normal message, but other named tasks may keep running concurrently. `/session new ` shares the configured workspace, while `/session new --worktree` creates an isolated checkout for that task under the daemon workspace's `.qwen/worktrees/` directory. The daemon verifies the persisted worktree owner before reopening the task after a restart; a missing, changed, or foreign ownership record fails closed instead of silently moving the task into the shared workspace. Creating or selecting another task does not cancel or retarget earlier work, and late results retain their originating task label. A busy task cannot be closed, but its active prompt can be cancelled with `/session cancel []` through the existing Channel cancellation behavior. Independently queued turns are not cancelled, but in `collect` dispatch mode any follow-ups buffered behind the cancelled prompt are discarded by that existing behavior. Media preparation is not targeted. Bare permission commands apply only to the selected task, while an explicit request ID can answer an owned inactive task. `/clear`, `/new`, and `/reset` also work on a selected worktree task: the task gets a fresh conversation while its worktree and files are kept. A busy worktree task refuses the reset until its prompt finishes, and a task whose worktree record was damaged reports the failure without touching files. Channel memory remains scoped to the chat rather than to a named task. This mode is unavailable in standalone `qwen channel start`, with webhooks, with non-zero channel or group `groupHistoryLimit`, or with Channel loops. If an enabled loop already exists for that channel, the daemon worker refuses to start until the loop is disabled. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index c2c2a59f495..a7de974ab8e 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -474,6 +474,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_session_live_state', 'workspace_session_metadata', 'session_worktree_persistence_v1', + 'session_worktree_reset_v1', 'voice_transcribe', 'web_terminal', ]); diff --git a/packages/acp-bridge/src/bridge-worktree-reset.test.ts b/packages/acp-bridge/src/bridge-worktree-reset.test.ts new file mode 100644 index 00000000000..f6180dfc602 --- /dev/null +++ b/packages/acp-bridge/src/bridge-worktree-reset.test.ts @@ -0,0 +1,690 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'; +import type { InitializeResponse } from '@agentclientprotocol/sdk'; +import { + makeBridge, + makeChannel, + WS_A, + WS_B, + type ChannelHandle, +} from './internal/testUtils.js'; +import { + SessionNotFoundError, + SessionResetPendingError, +} from './bridgeErrors.js'; +import { SERVE_CONTROL_EXT_METHODS } from './status.js'; +import { + ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM, + ACTIVE_WORK_HEARTBEAT_INTERVAL_MS, + ACTIVE_WORK_HEARTBEAT_META_KEY, + ACTIVE_WORK_HEARTBEAT_VERSION, + ACTIVE_WORK_HOLD_CATEGORIES, + ACTIVE_WORK_NOTIFICATION_METHOD, + DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY, +} from './bridgeTypes.js'; + +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** A child that negotiated active-work reporting (mirrors bridge.test.ts). */ +function activeWorkInitializeResponse(): InitializeResponse { + return { + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'active-work-agent', version: '0' }, + authMethods: [], + agentCapabilities: {}, + _meta: { + [ACTIVE_WORK_HEARTBEAT_META_KEY]: { + v: ACTIVE_WORK_HEARTBEAT_VERSION, + intervalMs: ACTIVE_WORK_HEARTBEAT_INTERVAL_MS, + categories: [...ACTIVE_WORK_HOLD_CATEGORIES], + }, + }, + }; +} + +async function sendActiveWorkSnapshot( + handle: ChannelHandle, + seq: number, + sessions: Array<{ + sessionId: string; + holds: Array<{ category: string; id: string }>; + }>, +): Promise { + await handle.agentConnection.extNotification( + ACTIVE_WORK_NOTIFICATION_METHOD, + { v: ACTIVE_WORK_HEARTBEAT_VERSION, seq, sessions }, + ); +} + +describe('worktree reset session transfer', () => { + describe('parked deferred restore prompt activity', () => { + it('reports a parked deferred restore prompt as active in getSessionSummary', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + loadSessionImpl: () => ({ + configOptions: [], + _meta: { [DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY]: true }, + }), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + restoreAskUserQuestion: true, + }); + try { + const restored = await bridge.loadSession({ + sessionId: 'reset-deferred-auq', + workspaceCwd: WS_A, + clientId: 'client-1', + deferRestoreAskUserQuestionPrompt: true, + }); + + // The owner-side restore response deliberately keeps its pre-transfer + // computation (restorePromptAdmitted || promptActive || goalTurnActive) + // so the restore route can tell a parked prompt from a running one. + expect(restored.hasActivePrompt).toBe(false); + expect(handle.agent.promptCalls).toHaveLength(0); + // The summary is the surface that must count the parked prompt as + // in-flight: quiescence readers (reset preconditions, the Channel + // busy probe) would otherwise relocate the session under it. + expect( + bridge.getSessionSummary(restored.sessionId).hasActivePrompt, + ).toBe(true); + expect( + bridge + .listWorkspaceSessions(WS_A) + .find((s) => s.sessionId === restored.sessionId)?.hasActivePrompt, + ).toBe(true); + // The daemon status surface is what automation reads session activity + // from; no other field on it reveals the parked prompt, so it must not + // report the session as idle while the summary reports it active. + const statusSnapshotHasActivePrompt = () => + bridge + .getDaemonStatusSnapshot() + .sessions.find((s) => s.sessionId === restored.sessionId) + ?.hasActivePrompt; + expect(statusSnapshotHasActivePrompt()).toBe(true); + + // Firing the parked prompt and letting it settle returns the summary + // to quiescent — the parked state, not a sticky flag, drove the report. + expect( + bridge.fireDeferredRestoreAskUserQuestionPrompt?.( + restored.sessionId, + restored.clientId, + ), + ).toBe(true); + await vi.waitFor(() => { + expect(handle.agent.promptCalls).toHaveLength(1); + expect( + bridge.getSessionSummary(restored.sessionId).hasActivePrompt, + ).toBe(false); + expect(statusSnapshotHasActivePrompt()).toBe(false); + }); + } finally { + await bridge.shutdown(); + } + }); + + it('reports a parked deferred restore prompt as active to a coalesced restore waiter', async () => { + const loadGate = deferred(); + const handle = makeChannel({ + loadSessionImpl: async () => { + await loadGate.promise; + return { + configOptions: [], + _meta: { [DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY]: true }, + }; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + restoreAskUserQuestion: true, + }); + try { + const owner = bridge.loadSession({ + sessionId: 'reset-deferred-coalesced', + workspaceCwd: WS_A, + clientId: 'client-1', + deferRestoreAskUserQuestionPrompt: true, + }); + // Same-shape restores coalesce onto the in-flight owner; the waiter + // has no restorePromptAdmitted term of its own, so without the + // parked-prompt term it would read the session as idle. + const waiter = bridge.loadSession({ + sessionId: 'reset-deferred-coalesced', + workspaceCwd: WS_A, + clientId: 'client-2', + }); + loadGate.resolve(); + + const [ownerRestored, waiterRestored] = await Promise.all([ + owner, + waiter, + ]); + expect(ownerRestored.hasActivePrompt).toBe(false); + expect(waiterRestored.hasActivePrompt).toBe(true); + expect(handle.agent.promptCalls).toHaveLength(0); + } finally { + await bridge.shutdown(); + } + }); + }); + + describe('reset-pending prompt barrier', () => { + it('throws SessionResetPendingError synchronously from sendPrompt while armed and re-admits after clear', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // The return value reports whether a live entry exists for the id. + expect(bridge.setSessionResetPending?.(session.sessionId)).toBe(true); + + // Admission fails closed synchronously — before the route's 202 + // contract, same as PromptQueueFullError — and nothing reaches the + // child. + expect(() => + bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'blocked during reset' }], + }), + ).toThrow(SessionResetPendingError); + expect(handle.agent.promptCalls).toHaveLength(0); + + bridge.clearSessionResetPending?.(session.sessionId); + await expect( + bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'admitted after reset completed' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + expect(handle.agent.promptCalls).toHaveLength(1); + } finally { + await bridge.shutdown(); + } + }); + + it('blocks the trusted continueSession prompt source while armed', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionContinue) { + return { accepted: true, interruption: 'interrupted_prompt' }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + bridge.setSessionResetPending?.(session.sessionId); + + // continueSession admits through the same sendPrompt admission path: + // the child-side accept/decline pre-check runs, then the barrier + // refuses admission and no continuation turn is ever dispatched. + await expect( + bridge.continueSession(session.sessionId, { + clientId: session.clientId, + }), + ).rejects.toBeInstanceOf(SessionResetPendingError); + expect(handle.agent.extMethodCalls).toContainEqual( + expect.objectContaining({ + method: SERVE_CONTROL_EXT_METHODS.sessionContinue, + }), + ); + expect(handle.agent.promptCalls).toHaveLength(0); + + bridge.clearSessionResetPending?.(session.sessionId); + await expect( + bridge.continueSession(session.sessionId, { + clientId: session.clientId, + }), + ).resolves.toMatchObject({ accepted: true }); + await vi.waitFor(() => { + expect(handle.agent.promptCalls).toHaveLength(1); + }); + } finally { + await bridge.shutdown(); + } + }); + + it('stays armed for an id whose entry registers after the barrier was set', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + // Arming an id with no live entry returns false (no entry) but still + // fences the id: the transfer's replacement restore registers the + // entry later, and a prompt admitted in between would write to a + // checkout whose ownership just moved. + expect(bridge.setSessionResetPending?.('reset-pending-restore')).toBe( + false, + ); + + const restored = await bridge.loadSession({ + sessionId: 'reset-pending-restore', + workspaceCwd: WS_A, + }); + expect(() => + bridge.sendPrompt(restored.sessionId, { + sessionId: restored.sessionId, + prompt: [{ type: 'text', text: 'blocked after restore' }], + }), + ).toThrow(SessionResetPendingError); + expect(handle.agent.promptCalls).toHaveLength(0); + + // Clearing is idempotent and re-admits. + bridge.clearSessionResetPending?.(restored.sessionId); + bridge.clearSessionResetPending?.(restored.sessionId); + await expect( + bridge.sendPrompt(restored.sessionId, { + sessionId: restored.sessionId, + prompt: [{ type: 'text', text: 'admitted after clear' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + } finally { + await bridge.shutdown(); + } + }); + }); + + describe('reset-pending writer barrier', () => { + it('fences the non-prompt writers that reach the checkout or the cwd', async () => { + const handle = makeChannel({ + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionRewind) { + return { targetTurnIndex: 0, filesChanged: [], filesFailed: [] }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) { + return { previousCwd: WS_A, newCwd: WS_B, warnings: [] }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionForkAgent) { + return { launched: true }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionBranch) { + return { newSessionId: 'branch-1', title: 'Branch 1' }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionShellCommandEnabled: true, + }); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const context = { clientId: session.clientId }; + bridge.setSessionResetPending?.(session.sessionId); + + // A rewind restores files under the session cwd, a fork agent runs its + // tools there, a shell command executes in `effectiveCwd`, a branch + // mutates the session's persisted history and a cd moves the session — + // all admitted precisely in the idle state the transfer requires, so + // all must fail closed on the barrier `sendPrompt` uses. + await expect( + bridge.rewindSession( + session.sessionId, + { promptId: 'prompt-1' }, + context, + ), + ).rejects.toBeInstanceOf(SessionResetPendingError); + await expect( + bridge.launchSessionForkAgent( + session.sessionId, + 'fork the checkout writer', + context, + ), + ).rejects.toBeInstanceOf(SessionResetPendingError); + await expect( + bridge.branchSession( + session.sessionId, + { atRecordId: '11111111-1111-4111-8111-111111111111' }, + context, + ), + ).rejects.toBeInstanceOf(SessionResetPendingError); + await expect( + bridge.changeSessionCwd(session.sessionId, { path: WS_B }, context), + ).rejects.toBeInstanceOf(SessionResetPendingError); + await expect( + bridge.executeShellCommand( + session.sessionId, + 'touch written-during-transfer', + undefined, + context, + ), + ).rejects.toBeInstanceOf(SessionResetPendingError); + + // Nothing reached the child: the refusal is at admission, not a + // dispatched-then-failed mutation. + const dispatched = handle.agent.extMethodCalls.map( + (call) => call.method, + ); + for (const method of [ + SERVE_CONTROL_EXT_METHODS.sessionRewind, + SERVE_CONTROL_EXT_METHODS.sessionForkAgent, + SERVE_CONTROL_EXT_METHODS.sessionBranch, + SERVE_CONTROL_EXT_METHODS.sessionCd, + ]) { + expect(dispatched).not.toContain(method); + } + + // The barrier is the only thing refusing them, and the route clears it + // in its `finally`, so a refused writer cannot wedge past the + // transfer: the same rewind is admitted and dispatched afterwards. + bridge.clearSessionResetPending?.(session.sessionId); + await expect( + bridge.rewindSession( + session.sessionId, + { promptId: 'prompt-1' }, + context, + ), + ).resolves.toMatchObject({ targetTurnIndex: 0 }); + expect(handle.agent.extMethodCalls).toContainEqual( + expect.objectContaining({ + method: SERVE_CONTROL_EXT_METHODS.sessionRewind, + params: expect.objectContaining({ + sessionId: session.sessionId, + rewindFiles: true, + }), + }), + ); + } finally { + await bridge.shutdown(); + } + }); + + it('fences the control writers that start work in the session cwd', async () => { + const handle = makeChannel({ + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction) { + return { changed: true, status: 'running', taskId: 'run-1' }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionGoalControl) { + return { snapshot: { v: 2, activity: 'running', goal: null } }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + bridge.setSessionResetPending?.(session.sessionId); + + // No client registration is needed to reach either writer: an absent + // client-id header makes the route pass no context at all, and + // `resolveTrustedClientId(entry, undefined)` returns without checking + // registration. The barrier must still refuse — and for every action on + // the method, not only the one that starts a saved workflow, because + // `retry`/`rerun`/`resume` restart a live run through the same tool + // registry and a goal `resume` starts a turn that never passes + // `sendPrompt`. + await expect( + bridge.controlSessionWorkflowTask( + session.sessionId, + 'wf-1', + 'run-saved', + ), + ).rejects.toBeInstanceOf(SessionResetPendingError); + await expect( + bridge.controlSessionWorkflowTask(session.sessionId, 'wf-1', 'rerun'), + ).rejects.toBeInstanceOf(SessionResetPendingError); + await expect( + bridge.controlSessionGoal(session.sessionId, { + action: 'resume', + expectedGoalId: 'goal-1', + expectedRevision: 1, + }), + ).rejects.toBeInstanceOf(SessionResetPendingError); + + // Nothing reached the child: the refusal is at admission, not a + // dispatched-then-failed mutation. + const dispatched = handle.agent.extMethodCalls.map( + (call) => call.method, + ); + expect(dispatched).not.toContain( + SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + ); + expect(dispatched).not.toContain( + SERVE_CONTROL_EXT_METHODS.sessionGoalControl, + ); + + // The barrier is the only thing refusing them, and the route clears it + // in its `finally`, so a refused control writer cannot wedge past the + // transfer: both are admitted and dispatched afterwards. + const context = { clientId: session.clientId }; + bridge.clearSessionResetPending?.(session.sessionId); + await expect( + bridge.controlSessionWorkflowTask( + session.sessionId, + 'wf-1', + 'run-saved', + context, + ), + ).resolves.toMatchObject({ changed: true, taskId: 'run-1' }); + await expect( + bridge.controlSessionGoal( + session.sessionId, + { action: 'resume', expectedGoalId: 'goal-1', expectedRevision: 1 }, + context, + ), + ).resolves.toMatchObject({ + snapshot: expect.objectContaining({ v: 2 }), + }); + expect(handle.agent.extMethodCalls).toContainEqual( + expect.objectContaining({ + method: SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + params: expect.objectContaining({ + sessionId: session.sessionId, + taskId: 'wf-1', + action: 'run-saved', + }), + }), + ); + expect(handle.agent.extMethodCalls).toContainEqual( + expect.objectContaining({ + method: SERVE_CONTROL_EXT_METHODS.sessionGoalControl, + params: expect.objectContaining({ + sessionId: session.sessionId, + request: expect.objectContaining({ action: 'resume' }), + }), + }), + ); + } finally { + await bridge.shutdown(); + } + }); + }); + + describe('clearSessionWorktree', () => { + it('removes the worktree association from the session summary and bumps the catalog once', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + const worktree = { slug: 'wt', path: '/tmp/wt', branch: 'wt-branch' }; + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + worktree, + }); + expect(bridge.getSessionSummary(session.sessionId).worktree).toEqual( + worktree, + ); + const v0 = bridge.getSessionCatalogVersion().revision; + + bridge.clearSessionWorktree?.(session.sessionId); + expect( + bridge.getSessionSummary(session.sessionId).worktree, + ).toBeUndefined(); + expect(bridge.getSessionCatalogVersion().revision).toBe(v0 + 1); + + // No association left and unknown id: both no-ops, no further bumps. + bridge.clearSessionWorktree?.(session.sessionId); + bridge.clearSessionWorktree?.('missing-session'); + expect(bridge.getSessionCatalogVersion().revision).toBe(v0 + 1); + } finally { + await bridge.shutdown(); + } + }); + }); + + describe('severSessionClients', () => { + it('detaches every registered client and lets the idle-close path remove the session', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + const owner = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const attacher = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + expect(attacher.sessionId).toBe(owner.sessionId); + expect(bridge.getSessionSummary(owner.sessionId).clientCount).toBe(2); + + const severed = await bridge.severSessionClients?.(owner.sessionId); + + // The last detach runs the natural idle-close path: the child gets + // the ordinary sessionClose round trip, the entry leaves the + // catalog, and the now-empty channel follows the idle policy + // (zero-configured timeout kills it immediately in tests). + expect(severed).toBe(true); + expect(handle.agent.extMethodCalls).toContainEqual( + expect.objectContaining({ + method: SERVE_CONTROL_EXT_METHODS.sessionClose, + params: expect.objectContaining({ sessionId: owner.sessionId }), + }), + ); + expect(bridge.sessionCount).toBe(0); + expect(() => bridge.getSessionSummary(owner.sessionId)).toThrow( + SessionNotFoundError, + ); + expect(handle.killed).toBe(true); + } finally { + await bridge.shutdown(); + } + }); + + it('detaches a clientId registered twice so the superseded session cannot survive', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + const owner = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + // Reconnect shape: the client echoes the minted id, so the second + // attach refcounts the existing registration instead of adding a key. + // No count-based surface reveals it — clientCount counts keys, and the + // attach ledger holds the one recorded attach (a spawn owner seeds 0). + const reconnect = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + clientId: owner.clientId, + }); + expect(reconnect.clientId).toBe(owner.clientId); + expect(bridge.getSessionSummary(owner.sessionId).clientCount).toBe(1); + expect( + bridge + .getDaemonStatusSnapshot() + .sessions.find((s) => s.sessionId === owner.sessionId)?.attachCount, + ).toBe(1); + + await bridge.severSessionClients?.(owner.sessionId); + + // One detach per remaining registration: the surviving refcount must + // not hold the idle close off, or the superseded session stays live + // and promptable after the reset route clears the barrier. + expect(bridge.sessionCount).toBe(0); + expect(() => bridge.getSessionSummary(owner.sessionId)).toThrow( + SessionNotFoundError, + ); + } finally { + await bridge.shutdown(); + } + }); + + it('is a no-op for an unknown session id', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // Nothing is registered for the id, so the reported end state (entry + // gone) holds trivially. + const severed = await bridge.severSessionClients?.('missing-session'); + + expect(severed).toBe(true); + expect(bridge.getSessionSummary(session.sessionId).clientCount).toBe(1); + expect(bridge.sessionCount).toBe(1); + } finally { + await bridge.shutdown(); + } + }); + + it('reports a survivor when the child refuses the conditional idle close', async () => { + const handle = makeChannel({ + initializeImpl: () => activeWorkInitializeResponse(), + // The user is running a dev server inside the worktree: the child + // holds a background shell and refuses the conditional close. + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionClose + ? { closed: false, holds: [{ category: 'shell', id: 'sh1' }] } + : {}, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + const owner = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + // A fresh "no holds" snapshot so the detach path treats the session as + // an auto-close candidate and actually asks the child: the refusal, not + // the cache, is what defers the close. + await sendActiveWorkSnapshot(handle, 1, [ + { sessionId: owner.sessionId, holds: [] }, + ]); + + const severed = await bridge.severSessionClients?.(owner.sessionId); + + // The documented end state is not reached, and the caller must be able + // to see that: the entry is still registered, so the superseded + // session is alive, cwd'd inside the transferred checkout, + // re-attachable by id and — once the route clears the barrier — + // promptable. + expect(severed).toBe(false); + expect(handle.agent.extMethodCalls).toContainEqual( + expect.objectContaining({ + method: SERVE_CONTROL_EXT_METHODS.sessionClose, + params: expect.objectContaining({ + sessionId: owner.sessionId, + [ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM]: true, + }), + }), + ); + expect(bridge.sessionCount).toBe(1); + expect(bridge.getSessionSummary(owner.sessionId).clientCount).toBe(0); + } finally { + await bridge.shutdown(); + } + }); + }); +}); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 2ee3ce3fef6..a53dbc1272b 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -123,6 +123,7 @@ import { PromptDeadlineExceededError, BridgeChannelQuarantinedError, McpAuthenticationInProgressError, + SessionResetPendingError, StandaloneSessionSpawnError, } from './bridgeErrors.js'; import type { BridgeChannelUnavailableReason } from './bridgeErrors.js'; @@ -414,6 +415,35 @@ function getChannelPromptDisplayText( : undefined; } +/** + * Whether the session has prompt work in flight, counting a parked deferred + * restore prompt (`deferredRestoreAskUserQuestionPrompts`) as active. The + * parked prompt owns no promptActive/goalTurnActive flag and no + * pendingInteractions entry, so without this term a session with a deferred + * restore question reads quiescent to `getSessionSummary` consumers + * (quiescence preconditions, the Channel busy probe), to the coalesced-restore + * waiter response, and to the daemon status snapshot's per-session table. + * + * Deliberately NOT used for the restore RESPONSE's `hasActivePrompt` + * (computed as `restorePromptAdmitted || promptActive || goalTurnActive`): + * the restore route reads that value to decide between relocating the + * restored session and re-firing the deferred prompt, and counting the + * parked prompt there would push a working deferred restore into the + * fail-closed active-session branch. + */ +function hasInFlightPromptActivity( + entry: Pick< + SessionEntry, + 'promptActive' | 'goalTurnActive' | 'deferredRestoreAskUserQuestionPrompts' + >, +): boolean { + return ( + entry.promptActive || + entry.goalTurnActive === true || + (entry.deferredRestoreAskUserQuestionPrompts?.size ?? 0) > 0 + ); +} + function isDefinitiveAcpRequestError(error: unknown): boolean { if (error instanceof RequestError) return true; if (!isRecord(error)) return false; @@ -4203,7 +4233,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(entry.sourceType ? { sourceType: entry.sourceType } : {}), ...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}), clientCount: entry.clientIds.size, - hasActivePrompt: entry.promptActive || entry.goalTurnActive === true, + hasActivePrompt: hasInFlightPromptActivity(entry), isWaitingForPermission, isWaitingForUserQuestion, pendingInteractionCount: entry.pendingInteractions.size, @@ -4333,6 +4363,32 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // duplicate history frames or race two entries into `byId`. const inFlightRestores = new Map(); + // Sessions whose worktree ownership is being transferred to a replacement + // session (worktree reset). While an id is present, every writer that could + // reach the superseded session's checkout or cwd refuses admission + // synchronously. Keyed by id (not the SessionEntry) so the barrier survives + // entry replacement and dies only with the process. The reset route clears + // it on every outcome up to the marker flip; past the flip the release + // belongs to a completed severance, so a post-commit failure leaves the + // entry fenced for the retry that finishes the transfer. + const resetPendingSessions = new Set(); + + /** + * Barrier check for the writers other than `sendPrompt`: a rewind restores + * files under the session cwd, a fork agent runs tools there, a shell + * command executes in `effectiveCwd`, a branch mutates the session's + * persisted history, a cwd change moves the session inside the checkout, a + * workflow action runs through the session's tool registry in that cwd, and + * a goal control starts a turn that never passes `sendPrompt`. Each is + * admitted precisely in the idle state the transfer requires, so each fails + * closed on the same id-keyed barrier. + */ + function assertSessionResetNotPending(sessionId: string): void { + if (resetPendingSessions.has(sessionId)) { + throw new SessionResetPendingError(sessionId); + } + } + async function settleReleasedRuntimeWork( context: string, armIdleTimer = true, @@ -8098,7 +8154,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { attached: true, clientId, createdAt: entry.createdAt, - hasActivePrompt: entry.promptActive || entry.goalTurnActive === true, + // Unlike the owner-side restore responses, the waiter has no + // `restorePromptAdmitted` term; a prompt the owner parked while the + // restore was in flight must still read as active here so the + // waiter's caller never relocates or resets under a deferred prompt. + hasActivePrompt: hasInFlightPromptActivity(entry), ...(entry.sourceType ? { sourceType: entry.sourceType } : {}), ...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}), ...(sourcePersisted !== undefined ? { sourcePersisted } : {}), @@ -9314,8 +9374,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { attachCount: entry.attachCount, pendingPromptCount: entry.pendingPromptCount, pendingPermissionCount: entry.pendingPermissionIds.size, - hasActivePrompt: - entry.promptActive || entry.goalTurnActive === true, + hasActivePrompt: hasInFlightPromptActivity(entry), lastEventId: entry.events.lastEventId, ...(entry.sessionLastSeenAt !== undefined ? { lastSeenAt: entry.sessionLastSeenAt } @@ -9959,6 +10018,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ), ); } + // Worktree-reset barrier: the transfer owns this session's worktree + // from barrier-arm to outcome, and a prompt admitted in between would + // write to a checkout whose ownership just moved. Synchronous throw so + // the prompt route rejects before returning 202 (same admission + // contract as PromptQueueFullError). + if (resetPendingSessions.has(sessionId)) { + throw new SessionResetPendingError(sessionId); + } if ( isReservedStandaloneSessionSourceType(entry.sourceType) && entry.managedConversationBinding?.released !== true @@ -10995,6 +11062,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { resolveTrustedClientId(entry, context.clientId); } + // A branch mutates the superseded session's persisted history and spawns + // a derivative session while the checkout's ownership is in flux. + assertSessionResetNotPending(sessionId); + const concurrentSideTask = isSideTask && entry.promptActive; // Admission-time check: pendingPromptCount changes synchronously when a // prompt is accepted, before its queue callback sets promptActive. A @@ -11305,6 +11376,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { context?.clientId, ); + // Relocation moves the session's cwd — including into a subdir of the + // checkout the transfer is moving. The route relocates the *replacement* + // (never armed), so the superseded id fails closed here. + assertSessionResetNotPending(sessionId); + // Chain onto promptQueue and update tail — ensures: // 1. cd waits for any in-flight prompt to complete // 2. Subsequent prompts wait for cd to complete (prevents stale config.cwd) @@ -11546,6 +11622,65 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } }, + clearSessionWorktree(sessionId) { + const entry = byId.get(sessionId); + if (entry?.worktree !== undefined) { + delete entry.worktree; + markSessionCatalogChanged(); + } + }, + + setSessionResetPending(sessionId) { + // The barrier is id-keyed and survives entry replacement; arming a + // session with no live entry still counts (a dormant session is + // quiescent, but a restore racing the transfer must not re-admit it — + // the reset route holds the worktree-keyed restore lock for that). + resetPendingSessions.add(sessionId); + return byId.has(sessionId); + }, + + clearSessionResetPending(sessionId) { + resetPendingSessions.delete(sessionId); + }, + + async severSessionClients(sessionId) { + // Detach every registered client. The last detach runs the natural + // idle-close path (transcript and persisted record survive), which is + // the desired end state for a superseded session: no client may keep + // writing through it once its worktree moved to the replacement. + // + // Registrations are refcounted and one `detachClient` drops exactly one, + // so a clientId registered twice (an SDK reconnect whose first detach + // never arrived) survives a single pass over the key snapshot at count 1 + // — and a non-empty `clientIds` is precisely what holds the idle close + // off. Re-snapshot the keys each pass, once per remaining registration. + const entry = byId.get(sessionId); + if (!entry) return true; + const registrations = (): number => { + let total = 0; + for (const count of entry.clientIds.values()) total += count; + return total; + }; + let outstanding = registrations(); + while (outstanding > 0 && byId.get(sessionId) === entry) { + for (const clientId of [...entry.clientIds.keys()]) { + await bridgeApi.detachClient(sessionId, clientId); + } + // Bail on a pass that made no net progress: a client reconnecting as + // fast as we detach would otherwise spin this request forever, and the + // reset route holds the worktree ownership lock while it runs. + const remaining = registrations(); + if (remaining >= outstanding) break; + outstanding = remaining; + } + // The idle close is conditional: a child that holds work (a background + // shell inside the worktree) refuses it and the close is deferred, so + // the documented end state above is not reached and the superseded + // session stays live and re-attachable. Report which happened rather + // than leaving the caller to assume the entry is gone. + return !byId.has(sessionId); + }, + fireDeferredRestoreAskUserQuestionPrompt(sessionId, clientId) { if (clientId === undefined) return false; const entry = byId.get(sessionId); @@ -12363,6 +12498,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); resolveTrustedClientId(entry, context?.clientId); + + // A workflow action runs a saved workflow, or restarts a live run, + // through this session's own tool registry in its cwd — the checkout the + // transfer is moving — and sets none of the busy flags the barrier or the + // route's quiescence re-check reads. + assertSessionResetNotPending(sessionId); return requestSessionStatus<{ changed: boolean; status?: ServeSessionWorkflowTaskStatus['status']; @@ -12379,6 +12520,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const info = channelInfoForEntry(entry); if (!info || info.isDying) throw new SessionNotFoundError(sessionId); resolveTrustedClientId(entry, context?.clientId); + + // A goal `resume` promotes a queued user turn and queues a continuation, + // so it starts work in this session's cwd without ever passing the + // fenced `sendPrompt` admission. + assertSessionResetNotPending(sessionId); return requestSessionStatus( sessionId, SERVE_CONTROL_EXT_METHODS.sessionGoalControl, @@ -13590,6 +13736,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (!trimmed) { throw new Error('Fork directive is required'); } + // The fork agent runs its tools in this session's cwd — the checkout the + // transfer is moving — and it chains onto the same queue a prompt would. + assertSessionResetNotPending(sessionId); if (entry.pendingPromptCount > 0 || entry.promptActive) { throw new SessionBusyError( sessionId, @@ -13677,6 +13826,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { context.clientId, ); + // A shell command runs in `entry.effectiveCwd`, which for a worktree + // session is the checkout itself — the strongest writer vector on the + // superseded session, not a workspace-cwd one. + assertSessionResetNotPending(sessionId); + if (signal?.aborted) { return { exitCode: null, output: '', aborted: true }; } @@ -13845,6 +13999,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { context?.clientId, ); + // A rewind restores files relative to the session cwd, so it writes into + // the checkout the transfer is moving — and it is admitted precisely in + // the idle state the transfer requires, setting none of the flags the + // barrier or the route's re-check reads. + assertSessionResetNotPending(sessionId); + // Admission-time check: a rewind queued behind an active prompt runs // after the prompt's `finally` clears the busy flags, and client-side // timeouts cannot cancel queued work — the rewind would truncate diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index bb3aec22ef2..57483189e27 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -705,6 +705,25 @@ export class CdWhilePromptActiveError extends Error { } } +/** + * Admission refusal for a session whose worktree ownership is being + * transferred to a replacement session (worktree reset). The daemon arms the + * barrier before the transfer's first side effect, so every prompt source and + * every other writer that could reach the session's checkout or cwd fails + * closed for its duration; the session id in the message is the superseded + * one. + */ +export class SessionResetPendingError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super( + `Session ${sessionId} is mid worktree reset; prompt admission is closed until the reset completes`, + ); + this.name = 'SessionResetPendingError'; + this.sessionId = sessionId; + } +} + export class McpAuthenticationInProgressError extends Error { constructor() { super('Another MCP authentication is already in progress'); diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 084f15e8979..8ee9b05e877 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -1571,6 +1571,49 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { worktree: { slug: string; path: string; branch: string }, ): void; + /** + * Clear the in-memory worktree association of a live session. Used by the + * worktree-reset transfer after the marker moved to the replacement + * session, so the superseded session's runtime view matches the disk + * state. No-op when the session is not live or carries no worktree. + */ + clearSessionWorktree?(sessionId: string): void; + + /** + * Arm the worktree-reset admission barrier for a session id: while armed, + * `sendPrompt` and the other writers that reach the session's checkout or + * cwd (`rewindSession`, `launchSessionForkAgent`, `branchSession`, + * `changeSessionCwd`, `executeShellCommand`, `controlSessionWorkflowTask`, + * `controlSessionGoal`) throw `SessionResetPendingError` synchronously at + * admission. Returns whether a live entry currently exists for the id — a + * dormant session counts as quiescent but is still fenced against + * re-admission. Optional so lightweight fakes may omit it. + */ + setSessionResetPending?(sessionId: string): boolean; + + /** + * Disarm the worktree-reset admission barrier. Idempotent; the reset route + * calls it on every transfer outcome up to the marker flip. Past the flip + * only a completed severance clears it, so a post-commit failure leaves the + * barrier armed for the retry that finishes the transfer. + */ + clearSessionResetPending?(sessionId: string): void; + + /** + * Detach every client registered on a live session. The last detach runs + * the normal idle-close path (transcript and persisted record survive). + * No-op for unknown ids. Used by the worktree-reset transfer to sever the + * superseded session's residual attaches after the ownership flip. + * + * Resolves whether the session entry is gone from the registry once the + * detach drain finishes. `false` means the child refused the conditional + * idle close because it holds work (a background shell inside the worktree, + * for example), so the superseded session is still live, re-attachable and + * — once the barrier is cleared — promptable. Callers must not read a + * resolved call as "severed" without checking this. + */ + severSessionClients?(sessionId: string): Promise; + /** Admit a restore question deferred by the daemon's integrity gate. */ fireDeferredRestoreAskUserQuestionPrompt?( sessionId: string, diff --git a/packages/channels/base/src/ChannelAgentBridge.ts b/packages/channels/base/src/ChannelAgentBridge.ts index 30bf91be55c..05491bd4b56 100644 --- a/packages/channels/base/src/ChannelAgentBridge.ts +++ b/packages/channels/base/src/ChannelAgentBridge.ts @@ -246,6 +246,17 @@ export interface ChannelAgentBridge { options?: ChannelAgentBridgeSessionOptions, bindingToken?: object, ): Promise; + /** + * Transfer a worktree session's checkout ownership to a fresh replacement + * session and return the replacement's id. Bridges without daemon-side + * worktree reset support omit it; callers fail closed. + */ + resetWorktreeSession?( + sessionId: string, + cwd: string, + options?: ChannelAgentBridgeSessionOptions, + bindingToken?: object, + ): Promise; prompt( sessionId: string, text: string, diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index b311f7f6d90..14126688399 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -405,6 +405,32 @@ function createBridge(): ChannelAgentBridge { ); return sessionId; }), + resetWorktreeSession: vi + .fn() + .mockImplementation(async (sessionId: string, workspaceCwd: string) => { + // The daemon transfers the checkout: the replacement session owns the + // SAME worktree path, and the superseded session leaves the map. + const previous = sessions.get(sessionId); + const replacementId = `s-${++sessionCounter}`; + sessions.set( + replacementId, + previous?.worktree + ? { + sessionId: replacementId, + workspaceCwd, + hasActivePrompt: false, + worktree: previous.worktree, + worktreeState: 'persisted-v1' as const, + } + : { + sessionId: replacementId, + workspaceCwd, + hasActivePrompt: false, + }, + ); + sessions.delete(sessionId); + return replacementId; + }), prompt: vi.fn().mockResolvedValue('agent response'), btw: vi.fn().mockResolvedValue({ sessionId: 's-1', @@ -458,6 +484,17 @@ function envelope(overrides: Partial = {}): Envelope { }; } +/** A daemon conflict error by shape (channels/base keeps no SDK dependency). */ +function daemonError( + code: string, + extraBody: Record = {}, +): Error { + const error = new Error(`daemon rejected with ${code}`); + error.name = 'DaemonHttpError'; + Object.assign(error, { status: 409, body: { code, ...extraBody } }); + return error; +} + function pairingCodeOf(result: CreatePairingRequestResult): string { if ('code' in result) return result.code; throw new Error( @@ -4392,12 +4429,30 @@ describe('ChannelBase', () => { 'Usage: /session current | /session new [--worktree]', ); expect(bridge.newSession).not.toHaveBeenCalled(); + + // The flag alone is missing the task name: it must return the usage + // line, not fall through to a confusing name-validation error. + await ch.handleInbound(envelope({ text: '/session new --worktree' })); + expect(ch.sent.at(-1)?.text).toContain( + 'Usage: /session current | /session new [--worktree]', + ); + expect(bridge.newSession).not.toHaveBeenCalled(); + + // Any leading-flag token is missing the task name, not just + // --worktree: the guard covers the class, and no legal task name + // starts with '-' (TASK_NAME_PATTERN requires a leading + // alphanumeric). + await ch.handleInbound(envelope({ text: '/session new --force' })); + expect(ch.sent.at(-1)?.text).toContain( + 'Usage: /session current | /session new [--worktree]', + ); + expect(bridge.newSession).not.toHaveBeenCalled(); } finally { rmSync(stateDir, { recursive: true, force: true }); } }); - it('rejects clearing a selected worktree task before clear side effects', async () => { + it('resets a selected worktree task and keeps its worktree', async () => { const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); const ch = createChannel({ multiSession: true }, { stateDir }); try { @@ -4405,15 +4460,225 @@ describe('ChannelBase', () => { envelope({ text: '/session new feature --worktree' }), ); vi.mocked(bridge.newSession).mockClear(); - vi.mocked(bridge.cancelSession).mockClear(); - vi.mocked(bridge.discardSession!).mockClear(); await ch.handleInbound(envelope({ text: '/clear' })); - expect(ch.sent.at(-1)?.text).toContain('cannot be cleared or reset'); + expect(ch.sent.at(-1)?.text).toBe( + 'Task "feature" reset with a fresh conversation; its worktree and files were kept.', + ); + expect(bridge.resetWorktreeSession).toHaveBeenCalledWith( + 's-1', + '/tmp', + { sourceId: 'test-chan' }, + expect.anything(), + ); expect(bridge.newSession).not.toHaveBeenCalled(); - expect(bridge.cancelSession).not.toHaveBeenCalled(); + + // The next turn binds to the replacement session in the kept worktree. + await ch.handleInbound(envelope({ text: 'continue the task' })); + expect(bridge.prompt).toHaveBeenLastCalledWith( + 's-2', + expect.stringContaining('continue the task'), + expect.anything(), + ); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('keeps the plain reset message for a shared task', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + const ch = createChannel({ multiSession: true }, { stateDir }); + try { + await ch.handleInbound(envelope({ text: '/session new review' })); + + await ch.handleInbound(envelope({ text: '/clear' })); + + expect(ch.sent.at(-1)?.text).toBe( + 'Task "review" reset with a fresh conversation.', + ); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('rejects clearing a busy worktree task before any reset call', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + let finishPrompt!: (response: string) => void; + vi.mocked(bridge.prompt).mockImplementationOnce( + () => + new Promise((resolve) => { + finishPrompt = resolve; + }), + ); + const ch = createChannel({ multiSession: true }, { stateDir }); + try { + await ch.handleInbound( + envelope({ text: '/session new feature --worktree' }), + ); + const running = ch.handleInbound(envelope({ text: 'long task' })); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + + await ch.handleInbound(envelope({ text: '/clear' })); + + expect(ch.sent.at(-1)?.text).toBe( + 'Task "feature" is busy. Wait for the running prompt to finish (or cancel it), then try again.', + ); + expect(bridge.resetWorktreeSession).not.toHaveBeenCalled(); expect(bridge.discardSession).not.toHaveBeenCalled(); + + finishPrompt('done'); + await running; + await ch.handleInbound(envelope({ text: '/session current' })); + expect(ch.sent.at(-1)?.text).toContain('Current task: feature'); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('reports a daemon-rejected worktree reset as a busy task', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + const ch = createChannel({ multiSession: true }, { stateDir }); + try { + await ch.handleInbound( + envelope({ text: '/session new feature --worktree' }), + ); + vi.mocked(bridge.resetWorktreeSession!).mockRejectedValueOnce( + daemonError('worktree_reset_active'), + ); + + await ch.handleInbound(envelope({ text: '/clear' })); + + expect(ch.sent.at(-1)?.text).toBe( + 'Task is busy. Wait for the running prompt to finish (or cancel it), then try again.', + ); + // The daemon busy signal is not retried. + expect(bridge.resetWorktreeSession).toHaveBeenCalledTimes(1); + await ch.handleInbound(envelope({ text: '/session current' })); + expect(ch.sent.at(-1)?.text).toContain('Current task: feature'); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('reports an interrupted transfer when the task is selected or messaged', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + const recoveredBridge = createBridge(); + // The daemon's restore route reports the interrupted transfer, so it + // surfaces when the task is loaded — never from the reset itself. + vi.mocked(recoveredBridge.loadSession).mockRejectedValue( + daemonError('worktree_reset_interrupted'), + ); + const ch = createChannel({ multiSession: true }, { stateDir }); + const interrupted = + 'Task "feature" was interrupted while being reset. Its files were not changed. Clear the task again to finish the reset.'; + try { + await ch.handleInbound( + envelope({ text: '/session new feature --worktree' }), + ); + ch.setBridge(recoveredBridge); + + await ch.handleInbound(envelope({ text: '/session use feature' })); + expect(ch.sent.at(-1)?.text).toBe(interrupted); + + await ch.handleInbound(envelope({ text: 'continue the task' })); + expect(ch.sent.at(-1)?.text).toBe(interrupted); + + expect(recoveredBridge.prompt).not.toHaveBeenCalled(); + expect(recoveredBridge.resetWorktreeSession).not.toHaveBeenCalled(); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('names the broken task instead of pointing a clear at the selected one', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + const ch = createChannel({ multiSession: true }, { stateDir }); + try { + await ch.handleInbound( + envelope({ text: '/session new feature --worktree' }), + ); + await ch.handleInbound( + envelope({ text: '/session new review --worktree' }), + ); + // Only feature reports the interrupted transfer; review, the task a + // clear would act on, is healthy. + vi.mocked(bridge.loadSession).mockImplementation( + async (sessionId: string) => { + if (sessionId === 's-1') { + throw daemonError('worktree_reset_interrupted'); + } + return sessionId; + }, + ); + ch.setBridge(bridge); + + await ch.handleInbound(envelope({ text: '/session use feature' })); + + const reply = ch.sent.at(-1)?.text ?? ''; + expect(reply).toContain('feature'); + expect(reply).toContain('/session close feature'); + // Telling the user to clear again would run a full ownership transfer + // against review and destroy a conversation that is not broken. + expect(reply).not.toContain('Clear the task again'); + await ch.handleInbound(envelope({ text: '/session current' })); + expect(ch.sent.at(-1)?.text).toContain('Current task: review'); + + await ch.handleInbound(envelope({ text: '/clear' })); + expect(bridge.resetWorktreeSession).toHaveBeenCalledWith( + 's-2', + '/tmp', + { sourceId: 'test-chan' }, + expect.anything(), + ); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('reports a missing worktree marker when the task is selected', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + const recoveredBridge = createBridge(); + vi.mocked(recoveredBridge.loadSession).mockRejectedValue( + daemonError('worktree_marker_missing'), + ); + const ch = createChannel({ multiSession: true }, { stateDir }); + try { + await ch.handleInbound( + envelope({ text: '/session new feature --worktree' }), + ); + ch.setBridge(recoveredBridge); + + await ch.handleInbound(envelope({ text: '/session use feature' })); + + expect(ch.sent.at(-1)?.text).toBe( + 'Task "feature" cannot verify its worktree because its ownership marker is missing. Its files were not changed. Clear the task to restart it in the same worktree, or close it.', + ); + expect(recoveredBridge.resetWorktreeSession).not.toHaveBeenCalled(); + await ch.handleInbound(envelope({ text: '/session current' })); + expect(ch.sent.at(-1)?.text).toContain('Current task: feature'); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('falls back to the generic named-session error for other reset failures', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + const ch = createChannel({ multiSession: true }, { stateDir }); + try { + await ch.handleInbound( + envelope({ text: '/session new feature --worktree' }), + ); + vi.mocked(bridge.resetWorktreeSession!).mockRejectedValueOnce( + daemonError('session_not_found'), + ); + + await ch.handleInbound(envelope({ text: '/clear' })); + + expect(ch.sent.at(-1)?.text).toBe('Could not reset task "feature".'); + expect(ch.sent.at(-1)?.text).not.toContain('s-1'); + await ch.handleInbound(envelope({ text: '/session current' })); + expect(ch.sent.at(-1)?.text).toContain('Current task: feature'); } finally { rmSync(stateDir, { recursive: true, force: true }); } @@ -5011,6 +5276,171 @@ describe('ChannelBase', () => { } }); + it('dispatches a collected turn onto the session its reload healed to', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + let finishFirst!: (response: string) => void; + vi.mocked(bridge.prompt).mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve; + }), + ); + // While the second message waits in the collect buffer, an external + // client resets the task: the daemon supersedes s-1 and its restore + // reports the replacement that now owns the same checkout. + const healedBridge = createBridge(); + vi.mocked(healedBridge.loadSession).mockImplementation( + async (sessionId: string) => { + if (sessionId === 's-1') { + throw daemonError('worktree_session_superseded', { + replacementSessionId: 's-2', + }); + } + return sessionId; + }, + ); + vi.mocked(healedBridge.listSessions!).mockReturnValue([ + { + sessionId: 's-2', + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { slug: 's-1', path: '/worktrees/s-1', branch: 's-1' }, + worktreeState: 'persisted-v1', + }, + ]); + const ch = createChannel( + { multiSession: true, dispatchMode: 'collect' }, + { stateDir }, + ); + const namedSessions = ( + ch as unknown as { + namedSessions: { + resumeReserved: ( + input: unknown, + sessionId: string, + ) => Promise; + }; + } + ).namedSessions; + try { + await ch.handleInbound( + envelope({ text: '/session new feature --worktree' }), + ); + + const first = ch.handleInbound(envelope({ text: 'build feature' })); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + // Buffered behind the running prompt, so the turn is bound to s-1 + // before the transfer becomes visible to this worker. + await ch.handleInbound(envelope({ text: 'update feature' })); + + ch.setBridge(healedBridge); + const resumeSpy = vi.spyOn(namedSessions, 'resumeReserved'); + finishFirst('done'); + await first; + + await vi.waitFor(() => + expect(healedBridge.prompt).toHaveBeenCalledTimes(1), + ); + // The drained turn reloads the session it was bound to and must then + // dispatch on the healed replacement. + expect(resumeSpy).toHaveBeenCalledWith(expect.anything(), 's-1'); + expect(healedBridge.prompt).toHaveBeenCalledWith( + 's-2', + expect.stringContaining('update feature'), + expect.anything(), + ); + expect(ch.sent.map((message) => message.text)).not.toContain( + 'Could not identify the selected task. Use /sessions, select it again, and retry.', + ); + // The queue reservation moved with the binding: the superseded id is + // not left busy forever. + await vi.waitFor(() => + expect( + (ch as unknown as { queuedTurns: Map }).queuedTurns + .size, + ).toBe(0), + ); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it('dispatches a collected turn whose id another turn already healed', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); + let finishFirst!: (response: string) => void; + vi.mocked(bridge.prompt).mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve; + }), + ); + const healedBridge = createBridge(); + vi.mocked(healedBridge.loadSession).mockImplementation( + async (sessionId: string) => { + if (sessionId === 's-1') { + throw daemonError('worktree_session_superseded', { + replacementSessionId: 's-2', + }); + } + return sessionId; + }, + ); + vi.mocked(healedBridge.listSessions!).mockReturnValue([ + { + sessionId: 's-2', + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { slug: 's-1', path: '/worktrees/s-1', branch: 's-1' }, + worktreeState: 'persisted-v1', + }, + ]); + const writeSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const ch = createChannel( + { multiSession: true, dispatchMode: 'collect' }, + { stateDir }, + ); + try { + await ch.handleInbound( + envelope({ text: '/session new feature --worktree' }), + ); + + const first = ch.handleInbound(envelope({ text: 'build feature' })); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + // Buffered behind the running prompt, so this turn is bound to s-1 and + // keeps that binding until it drains. + await ch.handleInbound(envelope({ text: 'update feature' })); + + // A different turn heals the task onto the replacement first, so the + // buffered turn is left holding an id no task names any more. + ch.setBridge(healedBridge); + await ch.handleInbound(envelope({ text: '/session use feature' })); + + finishFirst('done'); + await first; + + await vi.waitFor(() => + expect(healedBridge.prompt).toHaveBeenCalledTimes(1), + ); + expect(healedBridge.prompt).toHaveBeenCalledWith( + 's-2', + expect.stringContaining('update feature'), + expect.anything(), + ); + // The user-visible half: the buffered turn is answered instead of + // vanishing behind an operator-only log line. + expect( + writeSpy.mock.calls.some(([message]) => + String(message).includes('dropped collected turn'), + ), + ).toBe(false); + } finally { + writeSpy.mockRestore(); + rmSync(stateDir, { recursive: true, force: true }); + } + }); + it('cancels the exact named owner when legacy route keys collide', async () => { const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-')); const finishPrompts = new Map void>(); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index ad32b92d550..da8b0957b2c 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -39,9 +39,10 @@ import type { GroupHistoryEntry } from './group-history-store.js'; import { SenderGate } from './SenderGate.js'; import { PairingStore } from './PairingStore.js'; import type { CreatePairingRequestResult } from './PairingStore.js'; -import { SessionRouter } from './SessionRouter.js'; +import { SessionRouter, readDaemonHttpErrorCode } from './SessionRouter.js'; import { NamedSessionManager, + NamedSessionTaskError, type NamedSessionOwnerInput, type NamedSessionSelection, type NamedSessionTaskReference, @@ -3474,6 +3475,25 @@ export abstract class ChannelBase { this.releaseQueuedTurn(binding.sessionId); } + /** + * Follow a task onto the session its reload healed to (a superseded + * redirect). The queue reservation and the staleness generation are keyed + * by session id, so both must move with the binding: leaving them behind + * keeps the stale id "busy" forever and drops the turn on a generation the + * replacement never had. + */ + private moveNamedTurnBinding( + binding: NamedTurnBinding, + sessionId: string, + ): void { + if (binding.sessionId !== null) { + this.releaseQueuedTurn(binding.sessionId); + } + binding.sessionId = sessionId; + binding.generation = this.sessionGenerations.get(sessionId) ?? 0; + this.reserveQueuedTurn(sessionId); + } + private finishNamedTurnBinding(envelope: Envelope): void { const binding = this.namedTurnBindings.get(envelope); if (!binding) return; @@ -3539,6 +3559,74 @@ export abstract class ChannelBase { } } + /** + * The actionable recovery message for a worktree task whose ownership state + * the daemon refused to restore. Both codes come from the load/resume route + * — the reset route resumes an interrupted transfer itself and reports a + * broken marker as invalid state — so they surface on selection and on a + * message, wrapped in the manager's generic load failure. + * + * Clearing always acts on the *selected* task, so pointing the user at a + * clear is only safe when the task that failed is the selected one. Aimed at + * any other task it would run a full ownership transfer against a healthy + * one and destroy that conversation, so the alternative points at the close + * that works on the broken task without loading it. Both name that task. + */ + private async worktreeRecoveryMessage( + envelope: Envelope, + error: unknown, + ): Promise { + const code = readDaemonHttpErrorCode(error); + if ( + code !== 'worktree_reset_interrupted' && + code !== 'worktree_marker_missing' + ) { + return undefined; + } + const interrupted = code === 'worktree_reset_interrupted'; + const failedTaskName = + error instanceof NamedSessionTaskError ? error.taskName : undefined; + // This message bypasses the wrapper's sanitizeDisplayText, so bound the one + // piece of interpolated text it adds. + const safeTaskName = + failedTaskName === undefined + ? undefined + : sanitizeDisplayText(failedTaskName, 32); + const subject = + safeTaskName === undefined ? 'The task' : `Task "${safeTaskName}"`; + const problem = interrupted + ? 'was interrupted while being reset' + : 'cannot verify its worktree because its ownership marker is missing'; + if ( + safeTaskName !== undefined && + failedTaskName === (await this.selectedTaskName(envelope)) + ) { + return `${subject} ${problem}. Its files were not changed. ${ + interrupted + ? 'Clear the task again to finish the reset.' + : 'Clear the task to restart it in the same worktree, or close it.' + }`; + } + const remedy = + safeTaskName === undefined + ? 'select this task first or close it' + : `select this task first or close it with ${this.prefixedCommand(`/session close ${safeTaskName}`)}`; + return `${subject} ${problem}. Its files were not changed. Clearing now would reset the selected task instead, so ${remedy}.`; + } + + private async selectedTaskName( + envelope: Envelope, + ): Promise { + const namedSessions = this.namedSessions; + if (!namedSessions) return undefined; + try { + return (await namedSessions.current(this.namedSessionOwner(envelope))) + ?.name; + } catch { + return undefined; + } + } + private async sendNamedSessionError( envelope: Envelope, error: unknown, @@ -3549,9 +3637,10 @@ export abstract class ChannelBase { ); } const message = - error instanceof Error + (await this.worktreeRecoveryMessage(envelope, error)) ?? + (error instanceof Error ? sanitizeDisplayText(error.message, 500) - : 'Named-session operation failed.'; + : 'Named-session operation failed.'); await this.sendThreadMessage( envelope.chatId, envelope.threadId, @@ -3634,7 +3723,8 @@ export abstract class ChannelBase { : 'shared'; if ( (isolation === 'shared' && parts.length !== 1) || - (isolation === 'worktree' && parts.length !== 2) + (isolation === 'worktree' && parts.length !== 2) || + parts[0]?.startsWith('-') ) { break; } @@ -3741,6 +3831,7 @@ export abstract class ChannelBase { private registerSharedCommands(): void { const doClear = async (envelope: Envelope): Promise => { let resetTaskName: string | undefined; + let resetWorktreeKept = false; let removedIds: string[]; const retiringSessionId = this.namedSessions ? undefined @@ -3757,8 +3848,25 @@ export abstract class ChannelBase { this.namedSessionOwner(envelope), ); resetTaskName = reset?.name; + resetWorktreeKept = reset?.worktreeKept ?? false; removedIds = reset ? [reset.previousSessionId] : []; } catch (error) { + // The reset route's busy signal has its own wording; every other + // typed failure goes through the shared named-session surface. + const code = readDaemonHttpErrorCode(error); + if (code !== undefined) { + process.stderr.write( + `[${sanitizeLogText(this.name, 64)}] worktree reset failed (${sanitizeLogText(code, 64)}): ${this.lifecycleError(error)}\n`, + ); + } + if (code === 'worktree_reset_active') { + await this.sendThreadMessage( + envelope.chatId, + envelope.threadId, + 'Task is busy. Wait for the running prompt to finish (or cancel it), then try again.', + ); + return; + } await this.sendNamedSessionError(envelope, error); return; } @@ -3882,7 +3990,9 @@ export abstract class ChannelBase { envelope.chatId, envelope.threadId, resetTaskName - ? `Task "${resetTaskName}" reset with a fresh conversation.` + ? resetWorktreeKept + ? `Task "${resetTaskName}" reset with a fresh conversation; its worktree and files were kept.` + : `Task "${resetTaskName}" reset with a fresh conversation.` : 'Session cleared. The next message starts a fresh conversation.', ); } else { @@ -6363,11 +6473,13 @@ export abstract class ChannelBase { return; } try { - const resumed = await this.namedSessions.resumeReserved( + sessionId = await this.namedSessions.resumeReserved( this.namedSessionOwner(envelope), namedTurn.sessionId, ); - sessionId = resumed ? namedTurn.sessionId : undefined; + if (sessionId && sessionId !== namedTurn.sessionId) { + this.moveNamedTurnBinding(namedTurn, sessionId); + } } catch (error) { await this.sendNamedSessionError(envelope, error); return; diff --git a/packages/channels/base/src/DaemonChannelBridge.test.ts b/packages/channels/base/src/DaemonChannelBridge.test.ts index 6fa0a97c18f..e19186cfdc2 100644 --- a/packages/channels/base/src/DaemonChannelBridge.test.ts +++ b/packages/channels/base/src/DaemonChannelBridge.test.ts @@ -226,6 +226,76 @@ describe('DaemonChannelBridge', () => { bridge.stop(); }); + it('rejects worktree reset before calling an unsupported daemon factory', async () => { + const factory = vi.fn(); + const bridge = new DaemonChannelBridge({ + cwd: '/repo', + sessionFactory: factory, + sessionWorktreeReset: false, + }); + + await expect( + bridge.resetWorktreeSession('session-1', '/repo'), + ).rejects.toThrow('does not support worktree reset'); + expect(factory).not.toHaveBeenCalled(); + }); + + it('transfers a worktree session through the factory and registers loop tools for the replacement', async () => { + const events = new EventQueue(); + const session = { + ...createFakeSession(events, 'session-2'), + worktree: { slug: 'task', path: '/repo-wt', branch: 'task' }, + worktreeState: 'persisted-v1' as const, + }; + const factory = vi.fn().mockResolvedValue(session); + const host: DaemonChannelLoopMcpHost = { + register: vi.fn().mockResolvedValue(undefined), + unregister: vi.fn().mockResolvedValue(undefined), + }; + const bridge = new DaemonChannelBridge({ + cwd: '/repo', + sessionFactory: factory, + sessionWorktreeReset: true, + channelLoopMcpHost: host, + }); + bridge.registerChannelLoopToolHandler({ + create: vi.fn(async () => ({ text: 'created' })), + list: vi.fn(async () => ({ text: 'listed' })), + cancel: vi.fn(async () => ({ text: 'cancelled' })), + }); + + await bridge.start(); + const replacementId = await bridge.resetWorktreeSession( + 'session-1', + '/repo', + { sourceId: 'feishu-main' }, + ); + + expect(replacementId).toBe('session-2'); + expect(factory).toHaveBeenCalledWith({ + workspaceCwd: '/repo', + modelServiceId: undefined, + sessionScope: 'thread', + sourceId: 'feishu-main', + worktreeReset: { sessionId: 'session-1' }, + }); + expect(bridge.listSessions()[0]).toMatchObject({ + sessionId: 'session-2', + worktree: session.worktree, + worktreeState: 'persisted-v1', + }); + // The replacement must get the same loop-tool registration a loaded + // session gets, or channel loops silently stop working after a reset. + await waitFor(() => + expect(host.register).toHaveBeenCalledWith( + 'session-2', + expect.any(Function), + ), + ); + events.close(); + bridge.stop(); + }); + it('deletes an internal session through its owning workspace', async () => { const events = new EventQueue(); const session = createFakeSession(events); diff --git a/packages/channels/base/src/DaemonChannelBridge.ts b/packages/channels/base/src/DaemonChannelBridge.ts index c22731fa30a..1a4f95cdce7 100644 --- a/packages/channels/base/src/DaemonChannelBridge.ts +++ b/packages/channels/base/src/DaemonChannelBridge.ts @@ -91,6 +91,11 @@ export interface DaemonChannelSessionFactoryRequest { /** Channel instance name stamped as daemon `sourceId`. */ sourceId?: string; worktree?: Record; + /** + * Worktree ownership transfer: replace this session with a fresh session + * that takes over its worktree (daemon `session_worktree_reset_v1`). + */ + worktreeReset?: { sessionId: string }; } export type DaemonChannelSessionFactory = ( @@ -130,6 +135,8 @@ export interface DaemonChannelBridgeOptions { sessionPermissionVote?: boolean; /** Daemon guarantees durable worktree create/restore attestation. */ sessionWorktreePersistence?: boolean; + /** Daemon supports worktree ownership transfer (`session_worktree_reset_v1`). */ + sessionWorktreeReset?: boolean; } export interface DaemonPermissionRequestEvent { @@ -473,6 +480,46 @@ export class DaemonChannelBridge return session.sessionId; } + /** + * Transfer a worktree session's checkout ownership to a fresh replacement + * session (daemon `session_worktree_reset_v1`). The returned id is the + * replacement's; the superseded session's clients stay bound to it (and + * are forgotten by the caller). Gated on the capability flag so a daemon + * without reset support fails before any session is created. + */ + async resetWorktreeSession( + sessionId: string, + cwd: string, + options?: ChannelAgentBridgeSessionOptions, + bindingToken?: object, + ): Promise { + if (!this.options.sessionWorktreeReset) { + throw new Error( + 'The daemon does not support worktree reset for Channel tasks.', + ); + } + const lifecycleGeneration = this.lifecycleGeneration; + const session = await this.options.sessionFactory({ + workspaceCwd: cwd || this.options.cwd, + modelServiceId: this.options.modelServiceId, + sessionScope: this.options.sessionScope ?? 'thread', + ...(options?.approvalMode ? { approvalMode: options.approvalMode } : {}), + ...(options?.sourceId ? { sourceId: options.sourceId } : {}), + worktreeReset: { sessionId }, + }); + if (lifecycleGeneration !== this.lifecycleGeneration) { + await this.rejectStaleSession(session); + } + this.attachSession(session, bindingToken); + if (options?.enableChannelLoops === false) { + this.channelLoopDisabledSessions.add(session.sessionId); + void this.reconcileChannelLoopMcpForSession(session.sessionId); + } else { + await this.reconcileChannelLoopMcpForSession(session.sessionId); + } + return session.sessionId; + } + registerChannelLoopToolHandler(handler: ChannelLoopToolHandler): void { if (!this.channelLoopToolHandlers.includes(handler)) { this.channelLoopToolHandlers.push(handler); diff --git a/packages/channels/base/src/SessionRouter.test.ts b/packages/channels/base/src/SessionRouter.test.ts index 2f09b2725f4..57590138bb8 100644 --- a/packages/channels/base/src/SessionRouter.test.ts +++ b/packages/channels/base/src/SessionRouter.test.ts @@ -10,12 +10,20 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SessionRouter } from './SessionRouter.js'; +import { + readDaemonHttpErrorCode, + readSupersededReplacementId, + SessionRouter, +} from './SessionRouter.js'; import { DaemonChannelBridge, + type DaemonChannelSessionFactoryRequest, type DaemonChannelSessionClient, } from './DaemonChannelBridge.js'; -import type { ChannelAgentBridge } from './ChannelAgentBridge.js'; +import type { + ChannelAgentBridge, + SessionDiedEvent, +} from './ChannelAgentBridge.js'; const mockRenameSync = vi.hoisted(() => vi.fn()); @@ -61,6 +69,17 @@ function writePersistedSession(persistPath: string, key = 'key1'): void { ); } +/** A `DaemonHttpError` by shape — channels/base keeps no SDK dependency. */ +function daemonHttpError( + code: string, + extraBody: Record = {}, +): Error { + const error = new Error(`daemon rejected with ${code}`); + error.name = 'DaemonHttpError'; + Object.assign(error, { status: 409, body: { code, ...extraBody } }); + return error; +} + function invalidationMetadataSize(router: SessionRouter): number { const state = router as unknown as { routeGenerations?: Map; @@ -893,36 +912,822 @@ describe('SessionRouter', () => { router.activateManagedSession(first, firstTarget, '/tmp'); await expect( router.loadManagedSession(second, secondTarget, '/tmp'), - ).resolves.toEqual({ loaded: false }); + ).resolves.toEqual({ loaded: false, sessionId: second }); router.activateManagedSession(second, secondTarget, '/tmp'); await expect( router.loadManagedSession(first, firstTarget, '/tmp'), - ).resolves.toEqual({ loaded: false }); + ).resolves.toEqual({ loaded: false, sessionId: first }); router.activateManagedSession(first, firstTarget, '/tmp'); - expect(bridge.loadSession).not.toHaveBeenCalled(); - expect(router.getTarget(second)).toEqual(secondTarget); - expect(router.getSession('ch', 'alice', 'chat1')).toBe(first); + expect(bridge.loadSession).not.toHaveBeenCalled(); + expect(router.getTarget(second)).toEqual(secondTarget); + expect(router.getSession('ch', 'alice', 'chat1')).toBe(first); + }); + + it('revalidates worktree attestation when rebinding a live task', async () => { + const worktreePath = '/tmp/worktree-task'; + const managedBridge = { + ...mockBridge(), + listSessions: vi.fn().mockReturnValue([ + { + sessionId: 'worktree-session', + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { + slug: 'task', + path: worktreePath, + branch: 'task', + }, + worktreeState: 'persisted-v1' as const, + }, + ]), + newSession: vi.fn().mockResolvedValue('worktree-session'), + } satisfies ChannelAgentBridge; + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + const target = { + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + }; + + await router.createManagedSession(target, '/tmp', 'worktree'); + await expect( + router.loadManagedSession( + 'worktree-session', + target, + '/tmp', + worktreePath, + 'worktree', + ), + ).resolves.toEqual({ loaded: false, sessionId: 'worktree-session' }); + + expect(managedBridge.loadSession).not.toHaveBeenCalled(); + expect(router.getSessionCwd('worktree-session')).toBe(worktreePath); + }); + + it('rejects divergent worktree attestation when rebinding a live task', async () => { + const worktreePath = '/tmp/worktree-task'; + const discardSession = vi.fn().mockResolvedValue(undefined); + const listSessions = vi.fn().mockReturnValue([ + { + sessionId: 'worktree-session', + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { + slug: 'task', + path: worktreePath, + branch: 'task', + }, + worktreeState: 'persisted-v1' as const, + }, + ]); + const managedBridge = { + ...mockBridge(), + discardSession, + listSessions, + newSession: vi.fn().mockResolvedValue('worktree-session'), + } satisfies ChannelAgentBridge; + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + const target = { + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + }; + + await router.createManagedSession(target, '/tmp', 'worktree'); + listSessions.mockReturnValue([ + { + sessionId: 'worktree-session', + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { + slug: 'task', + path: '/tmp/elsewhere', + branch: 'task', + }, + worktreeState: 'persisted-v1' as const, + }, + ]); + + await expect( + router.loadManagedSession( + 'worktree-session', + target, + '/tmp', + worktreePath, + 'worktree', + ), + ).rejects.toThrow('did not attest'); + expect(managedBridge.loadSession).not.toHaveBeenCalled(); + expect(discardSession).not.toHaveBeenCalled(); + expect(router.getSessionCwd('worktree-session')).toBe(worktreePath); + }); + + it('reloads inactive managed tasks after the bridge is replaced', async () => { + const router = new SessionRouter(bridge, '/tmp', 'user', undefined, { + recoveryMode: 'lazy', + }); + const target = { + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + }; + const first = await router.createManagedSession(target, '/tmp'); + const second = await router.createManagedSession(target, '/tmp'); + router.activateManagedSession(first, target, '/tmp'); + const replacementBridge = mockBridge(); + + router.setBridge(replacementBridge); + await expect( + router.loadManagedSession(second, target, '/tmp'), + ).resolves.toEqual({ loaded: true, sessionId: second }); + + expect(replacementBridge.loadSession).toHaveBeenCalledWith( + second, + '/tmp', + { sourceId: 'ch' }, + expect.anything(), + ); + }); + + it('keeps a restored selected task live after bridge recovery', async () => { + const dir = mkdtempSync(join(tmpdir(), 'qwen-router-')); + tempDirs.push(dir); + const persistPath = join(dir, 'routes.json'); + const router = new SessionRouter(bridge, '/tmp', 'user', persistPath, { + recoveryMode: 'lazy', + }); + const target = { + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + }; + const sessionId = await router.createManagedSession(target, '/tmp'); + router.activateManagedSession(sessionId, target, '/tmp'); + const replacementBridge = mockBridge(); + + router.setBridge(replacementBridge); + await expect(router.restoreSessions()).resolves.toEqual({ + restored: 1, + failed: 0, + }); + await expect( + router.loadManagedSession(sessionId, target, '/tmp'), + ).resolves.toEqual({ loaded: false, sessionId }); + + expect(replacementBridge.loadSession).toHaveBeenCalledTimes(1); + }); + + it('detaches closed tasks without deleting daemon session data', async () => { + const discardSession = vi.fn().mockResolvedValue(undefined); + const deleteSessionData = vi.fn().mockResolvedValue(undefined); + const managedBridge = { + ...mockBridge(), + discardSession, + deleteSessionData, + } satisfies ChannelAgentBridge; + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + const sessionId = await router.createManagedSession( + { channelName: 'ch', senderId: 'alice', chatId: 'chat1' }, + '/tmp', + ); + + await router.detachManagedSession(sessionId); + + expect(discardSession).toHaveBeenCalledWith(sessionId); + expect(deleteSessionData).not.toHaveBeenCalled(); + expect(router.getTarget(sessionId)).toBeUndefined(); + }); + + it('forgets managed routing even when runtime detachment fails', async () => { + const managedBridge = { + ...mockBridge(), + discardSession: vi.fn().mockRejectedValue(new Error('detach failed')), + } satisfies ChannelAgentBridge; + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + const target = { + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + }; + const sessionId = await router.createManagedSession(target, '/tmp'); + router.activateManagedSession(sessionId, target, '/tmp'); + + await expect(router.detachManagedSession(sessionId)).rejects.toThrow( + 'detach failed', + ); + expect(router.getSession('ch', 'alice', 'chat1')).toBeUndefined(); + expect(router.getTarget(sessionId)).toBeUndefined(); + }); + }); + + describe('replaceManagedWorktreeSession', () => { + const target = { + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + }; + + function worktreeResetBridge( + overrides: Partial & { + replacementWorktreePath?: string; + } = {}, + ): ChannelAgentBridge { + const { replacementWorktreePath, ...rest } = overrides; + return { + ...mockBridge(), + listSessions: vi.fn().mockReturnValue([ + { + sessionId: 'replacement-session', + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { + slug: 'task', + path: replacementWorktreePath ?? '/tmp/worktree-task', + branch: 'task', + }, + worktreeState: 'persisted-v1' as const, + }, + ]), + resetWorktreeSession: vi.fn().mockResolvedValue('replacement-session'), + ...rest, + }; + } + + /** An attestation for a session owning the task's worktree checkout. */ + function worktreeSessionInfo(sessionId: string) { + return { + sessionId, + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { slug: 'task', path: '/tmp/worktree-task', branch: 'task' }, + worktreeState: 'persisted-v1' as const, + }; + } + + it('fails closed when the bridge does not support worktree reset', async () => { + const router = new SessionRouter(bridge, '/tmp', 'user', undefined, { + recoveryMode: 'lazy', + }); + + await expect( + router.replaceManagedWorktreeSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + ), + ).rejects.toThrow('Worktree reset is not supported by this bridge'); + expect(router.getTarget('old-session')).toBeUndefined(); + }); + + it('routes the validated replacement and forgets the superseded session', async () => { + const managedBridge = worktreeResetBridge(); + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + router.activateManagedSession( + 'old-session', + target, + '/tmp/worktree-task', + ); + + await expect( + router.replaceManagedWorktreeSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + ), + ).resolves.toBe('replacement-session'); + + expect(managedBridge.resetWorktreeSession).toHaveBeenCalledWith( + 'old-session', + '/tmp', + { sourceId: 'ch' }, + expect.anything(), + ); + expect(router.getTarget('replacement-session')).toEqual(target); + expect(router.getSessionCwd('replacement-session')).toBe( + '/tmp/worktree-task', + ); + expect(router.isSessionLive('replacement-session')).toBe(true); + // The superseded session's route and bookkeeping are gone; the manager + // re-activates a route for the replacement itself. + expect(router.getSession('ch', 'alice', 'chat1')).toBeUndefined(); + expect(router.getTarget('old-session')).toBeUndefined(); + }); + + it('discards the replacement and keeps the old session when attestation fails', async () => { + const discardSession = vi.fn().mockResolvedValue(undefined); + const managedBridge = worktreeResetBridge({ + replacementWorktreePath: '/tmp/other-worktree', + discardSession, + }); + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + router.activateManagedSession( + 'old-session', + target, + '/tmp/worktree-task', + ); + + await expect( + router.replaceManagedWorktreeSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + ), + ).rejects.toThrow('did not attest'); + + expect(discardSession).toHaveBeenCalledWith( + 'replacement-session', + expect.anything(), + ); + expect(router.getTarget('replacement-session')).toBeUndefined(); + expect(router.getTarget('old-session')).toEqual(target); + expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session'); + }); + + it('keeps the old session when the daemon reset fails', async () => { + const discardSession = vi.fn().mockResolvedValue(undefined); + const managedBridge = worktreeResetBridge({ + resetWorktreeSession: vi + .fn() + .mockRejectedValue(new Error('daemon unavailable')), + discardSession, + }); + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + router.activateManagedSession( + 'old-session', + target, + '/tmp/worktree-task', + ); + + await expect( + router.replaceManagedWorktreeSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + ), + ).rejects.toThrow('daemon unavailable'); + + expect(discardSession).not.toHaveBeenCalled(); + expect(router.getTarget('old-session')).toEqual(target); + expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session'); + }); + + it('still releases the old session on detach after a failed reset', async () => { + const discardSession = vi.fn().mockResolvedValue(undefined); + const managedBridge = worktreeResetBridge({ + listSessions: vi + .fn() + .mockReturnValue([ + worktreeSessionInfo('old-session'), + worktreeSessionInfo('replacement-session'), + ]), + resetWorktreeSession: vi + .fn() + .mockRejectedValue(new Error('daemon unavailable')), + discardSession, + }); + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + await expect( + router.loadManagedSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + 'worktree', + ), + ).resolves.toEqual({ loaded: true, sessionId: 'old-session' }); + + await expect( + router.replaceManagedWorktreeSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + ), + ).rejects.toThrow('daemon unavailable'); + expect(discardSession).not.toHaveBeenCalled(); + + // The failed reset dropped the live flag so the next load consults the + // daemon again, but the bridge still holds the client and its event + // pump: closing the task must release it rather than leak it until the + // daemon's idle reaper runs. + await router.detachManagedSession('old-session'); + + expect(discardSession).toHaveBeenCalledWith('old-session'); + expect(router.getTarget('old-session')).toBeUndefined(); + }); + + it('discards a replacement that dies before the reset completes', async () => { + const state: { router?: SessionRouter } = {}; + const discardSession = vi.fn().mockResolvedValue(undefined); + const managedBridge = worktreeResetBridge({ + resetWorktreeSession: vi.fn(async () => { + state.router?.removeSessionId('replacement-session'); + return 'replacement-session'; + }), + discardSession, + }); + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + state.router = router; + + await expect( + router.replaceManagedWorktreeSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + ), + ).rejects.toThrow('died before reset completed'); + + expect(discardSession).toHaveBeenCalledWith( + 'replacement-session', + expect.anything(), + ); + expect(router.getTarget('replacement-session')).toBeUndefined(); + }); + + it('consults the daemon for the old session after a failed reset', async () => { + // The daemon commits the flip and the client side fails afterwards, so + // only the next load of the old id reports the superseded redirect. + let flipCommitted = false; + const loadSession = vi.fn(async (sessionId: string) => { + if (flipCommitted && sessionId === 'old-session') { + throw daemonHttpError('worktree_session_superseded', { + replacementSessionId: 'replacement-session', + }); + } + return sessionId; + }); + const managedBridge = worktreeResetBridge({ + loadSession, + listSessions: vi + .fn() + .mockReturnValue([ + worktreeSessionInfo('old-session'), + worktreeSessionInfo('replacement-session'), + ]), + resetWorktreeSession: vi.fn(async () => { + flipCommitted = true; + throw new Error('timed out'); + }), + }); + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + router.activateManagedSession( + 'old-session', + target, + '/tmp/worktree-task', + ); + await router.loadManagedSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + 'worktree', + ); + expect(router.isSessionLive('old-session')).toBe(true); + vi.mocked(loadSession).mockClear(); + + await expect( + router.replaceManagedWorktreeSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + ), + ).rejects.toThrow('timed out'); + + // The failed reset drops only the live flag: the route survives and the + // old id is no longer served from memory. + expect(router.isSessionLive('old-session')).toBe(false); + expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session'); + + await expect( + router.loadManagedSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + 'worktree', + ), + ).resolves.toEqual({ + loaded: true, + sessionId: 'replacement-session', + redirectedFrom: 'old-session', + }); + expect(loadSession.mock.calls.map((call) => call[0])).toEqual([ + 'old-session', + 'replacement-session', + ]); + expect(router.getSession('ch', 'alice', 'chat1')).toBe( + 'replacement-session', + ); + }); + + it('re-loads the same session when a failed reset never committed', async () => { + const detaches: Array> = []; + const sessionFactory = vi.fn( + async ( + request: DaemonChannelSessionFactoryRequest, + ): Promise => { + if (request.worktreeReset) { + throw new Error('daemon unavailable'); + } + const detach = vi.fn().mockResolvedValue(undefined); + detaches.push(detach); + return { + ...daemonSession(request.sessionId ?? '', detach), + worktree: { + slug: 'task', + path: '/tmp/worktree-task', + branch: 'task', + }, + worktreeState: 'persisted-v1', + }; + }, + ); + const daemonBridge = new DaemonChannelBridge({ + cwd: '/tmp', + sessionFactory, + sessionWorktreeReset: true, + }); + await daemonBridge.start(); + const router = new SessionRouter( + daemonBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + const sessionDied = vi.fn(); + daemonBridge.on('sessionDied', (event: SessionDiedEvent) => { + sessionDied(event); + router.handleSessionDied(event.sessionId); + }); + router.activateManagedSession( + 'old-session', + target, + '/tmp/worktree-task', + ); + await router.loadManagedSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + 'worktree', + ); + expect(daemonBridge.listSessions()).toHaveLength(1); + + await expect( + router.replaceManagedWorktreeSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + ), + ).rejects.toThrow('daemon unavailable'); + + // A pre-flip failure keeps the same session as the worktree owner: only + // the live flag goes, and the bridge keeps holding its client. + expect(router.isSessionLive('old-session')).toBe(false); + expect(daemonBridge.listSessions()).toHaveLength(1); + + await expect( + router.loadManagedSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + 'worktree', + ), + ).resolves.toEqual({ loaded: true, sessionId: 'old-session' }); + + expect(sessionFactory).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'old-session', sourceId: 'ch' }), + ); + // Attaching over the surviving binding reports the session as replaced, + // and that notification would abort the very load recovering it. + expect(sessionDied).not.toHaveBeenCalled(); + expect(detaches).toHaveLength(2); + expect(detaches[0]).toHaveBeenCalledOnce(); + expect(detaches[1]).not.toHaveBeenCalled(); + expect(daemonBridge.listSessions()).toEqual([ + { + sessionId: 'old-session', + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { + slug: 'task', + path: '/tmp/worktree-task', + branch: 'task', + }, + worktreeState: 'persisted-v1', + }, + ]); + expect(router.getSessionCwd('old-session')).toBe('/tmp/worktree-task'); + expect(router.isSessionLive('old-session')).toBe(true); + }); + }); + + describe('superseded redirects', () => { + const target = { + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + }; + + function supersededError(replacementSessionId: string): Error { + return daemonHttpError('worktree_session_superseded', { + replacementSessionId, + }); + } + + it('redirects a superseded load to the replacement and heals the route', async () => { + const loadSession = vi.fn(async (sessionId: string) => { + if (sessionId === 'old-session') { + throw supersededError('replacement-session'); + } + return sessionId; + }); + const managedBridge = { + ...mockBridge(), + loadSession, + } satisfies ChannelAgentBridge; + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + router.activateManagedSession('old-session', target, '/tmp'); + + await expect( + router.loadManagedSession('old-session', target, '/tmp'), + ).resolves.toEqual({ + loaded: true, + sessionId: 'replacement-session', + redirectedFrom: 'old-session', + }); + + expect(loadSession.mock.calls.map((call) => call[0])).toEqual([ + 'old-session', + 'replacement-session', + ]); + expect(router.getSession('ch', 'alice', 'chat1')).toBe( + 'replacement-session', + ); + expect(router.getTarget('old-session')).toBeUndefined(); + expect(router.getTarget('replacement-session')).toEqual(target); + expect(router.isSessionLive('replacement-session')).toBe(true); + }); + + it('revalidates the replacement attestation for a worktree redirect', async () => { + const managedBridge = { + ...mockBridge(), + listSessions: vi.fn().mockReturnValue([ + { + sessionId: 'replacement-session', + workspaceCwd: '/tmp', + hasActivePrompt: false, + worktree: { + slug: 'task', + path: '/tmp/worktree-task', + branch: 'task', + }, + worktreeState: 'persisted-v1' as const, + }, + ]), + loadSession: vi.fn(async (sessionId: string) => { + if (sessionId === 'old-session') { + throw supersededError('replacement-session'); + } + return sessionId; + }), + } satisfies ChannelAgentBridge; + const router = new SessionRouter( + managedBridge, + '/tmp', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + router.activateManagedSession( + 'old-session', + target, + '/tmp/worktree-task', + ); + + await expect( + router.loadManagedSession( + 'old-session', + target, + '/tmp', + '/tmp/worktree-task', + 'worktree', + ), + ).resolves.toEqual({ + loaded: true, + sessionId: 'replacement-session', + redirectedFrom: 'old-session', + }); + + expect(router.getSessionCwd('replacement-session')).toBe( + '/tmp/worktree-task', + ); + expect(router.getSession('ch', 'alice', 'chat1')).toBe( + 'replacement-session', + ); + expect(router.getTarget('old-session')).toBeUndefined(); }); - it('revalidates worktree attestation when rebinding a live task', async () => { - const worktreePath = '/tmp/worktree-task'; + it('discards a redirected replacement whose attestation does not match', async () => { + const discardSession = vi.fn().mockResolvedValue(undefined); const managedBridge = { ...mockBridge(), listSessions: vi.fn().mockReturnValue([ { - sessionId: 'worktree-session', + sessionId: 'replacement-session', workspaceCwd: '/tmp', hasActivePrompt: false, worktree: { slug: 'task', - path: worktreePath, + path: '/tmp/other-worktree', branch: 'task', }, worktreeState: 'persisted-v1' as const, }, ]), - newSession: vi.fn().mockResolvedValue('worktree-session'), + loadSession: vi.fn(async (sessionId: string) => { + if (sessionId === 'old-session') { + throw supersededError('replacement-session'); + } + return sessionId; + }), + discardSession, } satisfies ChannelAgentBridge; const router = new SessionRouter( managedBridge, @@ -931,48 +1736,42 @@ describe('SessionRouter', () => { undefined, { recoveryMode: 'lazy' }, ); - const target = { - channelName: 'ch', - senderId: 'alice', - chatId: 'chat1', - }; + router.activateManagedSession( + 'old-session', + target, + '/tmp/worktree-task', + ); - await router.createManagedSession(target, '/tmp', 'worktree'); await expect( router.loadManagedSession( - 'worktree-session', + 'old-session', target, '/tmp', - worktreePath, + '/tmp/worktree-task', 'worktree', ), - ).resolves.toEqual({ loaded: false }); + ).rejects.toThrow('did not attest'); - expect(managedBridge.loadSession).not.toHaveBeenCalled(); - expect(router.getSessionCwd('worktree-session')).toBe(worktreePath); + expect(discardSession).toHaveBeenCalledWith( + 'replacement-session', + expect.anything(), + ); + // The stale route is kept: no heal happened for a failed redirect. + expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session'); + expect(router.getTarget('replacement-session')).toBeUndefined(); }); - it('rejects divergent worktree attestation when rebinding a live task', async () => { - const worktreePath = '/tmp/worktree-task'; - const discardSession = vi.fn().mockResolvedValue(undefined); - const listSessions = vi.fn().mockReturnValue([ - { - sessionId: 'worktree-session', - workspaceCwd: '/tmp', - hasActivePrompt: false, - worktree: { - slug: 'task', - path: worktreePath, - branch: 'task', - }, - worktreeState: 'persisted-v1' as const, - }, - ]); + it('fails closed when the redirect points at the session itself', async () => { + const selfRedirect = supersededError('old-session'); + const loadSession = vi.fn(async (sessionId: string) => { + if (sessionId === 'old-session') { + throw selfRedirect; + } + return sessionId; + }); const managedBridge = { ...mockBridge(), - discardSession, - listSessions, - newSession: vi.fn().mockResolvedValue('worktree-session'), + loadSession, } satisfies ChannelAgentBridge; const router = new SessionRouter( managedBridge, @@ -981,103 +1780,65 @@ describe('SessionRouter', () => { undefined, { recoveryMode: 'lazy' }, ); - const target = { - channelName: 'ch', - senderId: 'alice', - chatId: 'chat1', - }; - - await router.createManagedSession(target, '/tmp', 'worktree'); - listSessions.mockReturnValue([ - { - sessionId: 'worktree-session', - workspaceCwd: '/tmp', - hasActivePrompt: false, - worktree: { - slug: 'task', - path: '/tmp/elsewhere', - branch: 'task', - }, - worktreeState: 'persisted-v1' as const, - }, - ]); + router.activateManagedSession('old-session', target, '/tmp'); await expect( - router.loadManagedSession( - 'worktree-session', - target, - '/tmp', - worktreePath, - 'worktree', - ), - ).rejects.toThrow('did not attest'); - expect(managedBridge.loadSession).not.toHaveBeenCalled(); - expect(discardSession).not.toHaveBeenCalled(); - expect(router.getSessionCwd('worktree-session')).toBe(worktreePath); + router.loadManagedSession('old-session', target, '/tmp'), + ).rejects.toBe(selfRedirect); + expect(loadSession).toHaveBeenCalledTimes(1); + expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session'); }); - it('reloads inactive managed tasks after the bridge is replaced', async () => { - const router = new SessionRouter(bridge, '/tmp', 'user', undefined, { - recoveryMode: 'lazy', + it('fails closed when the replacement is itself superseded', async () => { + const firstRedirect = supersededError('replacement-1'); + const secondRedirect = supersededError('replacement-2'); + const loadSession = vi.fn(async (sessionId: string) => { + if (sessionId === 'old-session') { + throw firstRedirect; + } + if (sessionId === 'replacement-1') { + throw secondRedirect; + } + return sessionId; }); - const target = { - channelName: 'ch', - senderId: 'alice', - chatId: 'chat1', - }; - const first = await router.createManagedSession(target, '/tmp'); - const second = await router.createManagedSession(target, '/tmp'); - router.activateManagedSession(first, target, '/tmp'); - const replacementBridge = mockBridge(); - - router.setBridge(replacementBridge); - await expect( - router.loadManagedSession(second, target, '/tmp'), - ).resolves.toEqual({ loaded: true }); - - expect(replacementBridge.loadSession).toHaveBeenCalledWith( - second, + const managedBridge = { + ...mockBridge(), + loadSession, + } satisfies ChannelAgentBridge; + const router = new SessionRouter( + managedBridge, '/tmp', - { sourceId: 'ch' }, - expect.anything(), + 'user', + undefined, + { recoveryMode: 'lazy' }, ); - }); - - it('keeps a restored selected task live after bridge recovery', async () => { - const dir = mkdtempSync(join(tmpdir(), 'qwen-router-')); - tempDirs.push(dir); - const persistPath = join(dir, 'routes.json'); - const router = new SessionRouter(bridge, '/tmp', 'user', persistPath, { - recoveryMode: 'lazy', - }); - const target = { - channelName: 'ch', - senderId: 'alice', - chatId: 'chat1', - }; - const sessionId = await router.createManagedSession(target, '/tmp'); - router.activateManagedSession(sessionId, target, '/tmp'); - const replacementBridge = mockBridge(); + router.activateManagedSession('old-session', target, '/tmp'); - router.setBridge(replacementBridge); - await expect(router.restoreSessions()).resolves.toEqual({ - restored: 1, - failed: 0, - }); await expect( - router.loadManagedSession(sessionId, target, '/tmp'), - ).resolves.toEqual({ loaded: false }); + router.loadManagedSession('old-session', target, '/tmp'), + ).rejects.toBe(secondRedirect); - expect(replacementBridge.loadSession).toHaveBeenCalledTimes(1); + // A redirect chain is never followed past the first hop. + expect(loadSession.mock.calls.map((call) => call[0])).toEqual([ + 'old-session', + 'replacement-1', + ]); + expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session'); }); - it('detaches closed tasks without deleting daemon session data', async () => { - const discardSession = vi.fn().mockResolvedValue(undefined); - const deleteSessionData = vi.fn().mockResolvedValue(undefined); + it('reads the superseded signal through a wrapped cause', async () => { + const wrapped = new Error('load failed', { + cause: supersededError('replacement-session'), + }); + const loadSession = vi.fn(async (sessionId: string) => { + if (sessionId === 'old-session') { + throw wrapped; + } + return sessionId; + }); const managedBridge = { ...mockBridge(), - discardSession, - deleteSessionData, + loadSession, } satisfies ChannelAgentBridge; const router = new SessionRouter( managedBridge, @@ -1086,22 +1847,31 @@ describe('SessionRouter', () => { undefined, { recoveryMode: 'lazy' }, ); - const sessionId = await router.createManagedSession( - { channelName: 'ch', senderId: 'alice', chatId: 'chat1' }, - '/tmp', - ); + router.activateManagedSession('old-session', target, '/tmp'); - await router.detachManagedSession(sessionId); - - expect(discardSession).toHaveBeenCalledWith(sessionId); - expect(deleteSessionData).not.toHaveBeenCalled(); - expect(router.getTarget(sessionId)).toBeUndefined(); + await expect( + router.loadManagedSession('old-session', target, '/tmp'), + ).resolves.toEqual({ + loaded: true, + sessionId: 'replacement-session', + redirectedFrom: 'old-session', + }); + expect(router.getSession('ch', 'alice', 'chat1')).toBe( + 'replacement-session', + ); }); - it('forgets managed routing even when runtime detachment fails', async () => { + it('does not redirect other daemon conflicts', async () => { + const conflict = daemonHttpError('worktree_marker_missing'); + const loadSession = vi.fn(async (sessionId: string) => { + if (sessionId === 'old-session') { + throw conflict; + } + return sessionId; + }); const managedBridge = { ...mockBridge(), - discardSession: vi.fn().mockRejectedValue(new Error('detach failed')), + loadSession, } satisfies ChannelAgentBridge; const router = new SessionRouter( managedBridge, @@ -1110,19 +1880,13 @@ describe('SessionRouter', () => { undefined, { recoveryMode: 'lazy' }, ); - const target = { - channelName: 'ch', - senderId: 'alice', - chatId: 'chat1', - }; - const sessionId = await router.createManagedSession(target, '/tmp'); - router.activateManagedSession(sessionId, target, '/tmp'); + router.activateManagedSession('old-session', target, '/tmp'); - await expect(router.detachManagedSession(sessionId)).rejects.toThrow( - 'detach failed', - ); - expect(router.getSession('ch', 'alice', 'chat1')).toBeUndefined(); - expect(router.getTarget(sessionId)).toBeUndefined(); + await expect( + router.loadManagedSession('old-session', target, '/tmp'), + ).rejects.toBe(conflict); + expect(loadSession).toHaveBeenCalledTimes(1); + expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session'); }); }); @@ -2487,3 +3251,116 @@ describe('SessionRouter', () => { }); }); }); + +describe('daemon error helpers', () => { + describe('readDaemonHttpErrorCode', () => { + it('reads the code from a DaemonHttpError-shaped error', () => { + expect( + readDaemonHttpErrorCode(daemonHttpError('worktree_reset_active')), + ).toBe('worktree_reset_active'); + }); + + it('reads the code through a cause chain', () => { + const error = new Error('outer', { + cause: new Error('middle', { + cause: daemonHttpError('worktree_reset_interrupted'), + }), + }); + + expect(readDaemonHttpErrorCode(error)).toBe('worktree_reset_interrupted'); + }); + + it('returns undefined for non-matching shapes', () => { + expect(readDaemonHttpErrorCode(undefined)).toBeUndefined(); + expect(readDaemonHttpErrorCode(null)).toBeUndefined(); + expect(readDaemonHttpErrorCode('worktree_reset_active')).toBeUndefined(); + expect(readDaemonHttpErrorCode(new Error('plain'))).toBeUndefined(); + // Wrong name or status, and non-object or empty codes, do not match. + expect( + readDaemonHttpErrorCode( + Object.assign(new Error('x'), { + status: 409, + body: { code: 'worktree_reset_active' }, + }), + ), + ).toBeUndefined(); + expect( + readDaemonHttpErrorCode( + Object.assign(new Error('x'), { + name: 'DaemonHttpError', + status: 500, + body: { code: 'worktree_reset_active' }, + }), + ), + ).toBeUndefined(); + expect( + readDaemonHttpErrorCode( + Object.assign(new Error('x'), { + name: 'DaemonHttpError', + status: 409, + body: { code: '' }, + }), + ), + ).toBeUndefined(); + expect( + readDaemonHttpErrorCode( + Object.assign(new Error('x'), { + name: 'DaemonHttpError', + status: 409, + body: ['worktree_reset_active'], + }), + ), + ).toBeUndefined(); + }); + + it('bounds the cause-chain walk', () => { + let error: Error = daemonHttpError('worktree_reset_active'); + for (let depth = 0; depth < 8; depth++) { + error = new Error(`wrap-${depth}`, { cause: error }); + } + + expect(readDaemonHttpErrorCode(error)).toBeUndefined(); + }); + }); + + describe('readSupersededReplacementId', () => { + it('reads the replacement id from a superseded conflict', () => { + expect( + readSupersededReplacementId( + daemonHttpError('worktree_session_superseded', { + replacementSessionId: 'replacement-session', + }), + ), + ).toBe('replacement-session'); + }); + + it('reads the replacement id through a cause chain', () => { + const error = new Error('outer', { + cause: daemonHttpError('worktree_session_superseded', { + replacementSessionId: 'replacement-session', + }), + }); + + expect(readSupersededReplacementId(error)).toBe('replacement-session'); + }); + + it('returns undefined for other codes or a missing replacement id', () => { + expect( + readSupersededReplacementId(daemonHttpError('worktree_reset_active')), + ).toBeUndefined(); + expect( + readSupersededReplacementId( + daemonHttpError('worktree_session_superseded'), + ), + ).toBeUndefined(); + expect( + readSupersededReplacementId( + daemonHttpError('worktree_session_superseded', { + replacementSessionId: '', + }), + ), + ).toBeUndefined(); + expect(readSupersededReplacementId(new Error('plain'))).toBeUndefined(); + }); + }); +}); diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts index 1a50faa20a1..b3dcece4ff7 100644 --- a/packages/channels/base/src/SessionRouter.ts +++ b/packages/channels/base/src/SessionRouter.ts @@ -46,6 +46,67 @@ interface ResolveOptions { export type SessionRecoveryMode = 'eager' | 'lazy'; export type ManagedSessionIsolation = 'shared' | 'worktree'; +/** + * Read the daemon's typed error code off a `DaemonHttpError` (matched by + * shape: channels/base keeps no SDK dependency), walking `cause` chains so + * callers that wrap errors (the named-session manager) stay transparent. + */ +export function readDaemonHttpErrorCode(error: unknown): string | undefined { + let current: unknown = error; + for (let depth = 0; depth < 5; depth++) { + if ( + (typeof current !== 'object' && typeof current !== 'function') || + current === null + ) { + return undefined; + } + const record = current as Record; + if (record['name'] === 'DaemonHttpError' && record['status'] === 409) { + const body = record['body']; + if (typeof body === 'object' && body !== null && !Array.isArray(body)) { + const code = (body as Record)['code']; + if (typeof code === 'string' && code.length > 0) return code; + } + } + current = record['cause']; + } + return undefined; +} + +/** + * The replacement session id from a `409 worktree_session_superseded` + * daemon response, when the error carries one. + */ +export function readSupersededReplacementId( + error: unknown, +): string | undefined { + let current: unknown = error; + for (let depth = 0; depth < 5; depth++) { + if ( + (typeof current !== 'object' && typeof current !== 'function') || + current === null + ) { + return undefined; + } + const record = current as Record; + if (record['name'] === 'DaemonHttpError' && record['status'] === 409) { + const body = record['body']; + if (typeof body === 'object' && body !== null && !Array.isArray(body)) { + const typedBody = body as Record; + if (typedBody['code'] !== 'worktree_session_superseded') { + return undefined; + } + const replacement = typedBody['replacementSessionId']; + return typeof replacement === 'string' && replacement.length > 0 + ? replacement + : undefined; + } + } + current = record['cause']; + } + return undefined; +} + export interface SessionRouterOptions { recoveryMode?: SessionRecoveryMode; } @@ -57,6 +118,7 @@ export class SessionRouter { private creatingSessions: Map = new Map(); private sessionLoadWindows: Set = new Set(); private readonly liveSessionIds = new Set(); + private readonly staleBridgeBindings = new Set(); private readonly routeTokens = new Map(); private lifecycleGeneration = 0; @@ -87,6 +149,7 @@ export class SessionRouter { setBridge(bridge: ChannelAgentBridge): void { this.bridge = bridge; this.liveSessionIds.clear(); + this.staleBridgeBindings.clear(); } /** Set scope override for a specific channel. */ @@ -501,7 +564,12 @@ export class SessionRouter { workspaceCwd: string, expectedCwd: string = workspaceCwd, isolation: ManagedSessionIsolation = 'shared', - ): Promise<{ loaded: boolean }> { + allowSupersededRedirect = true, + ): Promise<{ + loaded: boolean; + sessionId: string; + redirectedFrom?: string; + }> { if (this.liveSessionIds.has(sessionId)) { const actualCwd = this.validateManagedSessionIdentity( this.bridge, @@ -512,7 +580,7 @@ export class SessionRouter { ); this.toTarget.set(sessionId, target); this.toCwd.set(sessionId, actualCwd); - return { loaded: false }; + return { loaded: false, sessionId }; } const loadWindow = this.beginSessionLoad(); @@ -521,6 +589,13 @@ export class SessionRouter { const bindingToken = {}; let loadedSessionId: string | undefined; try { + if (this.staleBridgeBindings.delete(sessionId)) { + // A failed worktree reset left the bridge holding a client for this id. + // Attaching over that binding reports the session as replaced, which + // aborts this very load, so release it first. Tokenless: the surviving + // binding carries the previous load's token. + await bridge.discardSession?.(sessionId).catch(() => undefined); + } loadedSessionId = await bridge.loadSession( sessionId, workspaceCwd, @@ -558,13 +633,145 @@ export class SessionRouter { this.toTarget.set(sessionId, target); this.toCwd.set(sessionId, actualCwd); this.liveSessionIds.add(sessionId); - return { loaded: true }; + return { loaded: true, sessionId }; } catch (error) { if (loadedSessionId) { await bridge .discardSession?.(loadedSessionId, bindingToken) .catch(() => undefined); + throw error; + } + // Superseded redirect: a worktree reset moved this session's checkout + // ownership to a replacement. Redirect the load once, then heal the + // route mappings so the stale id is never consulted again. A redirect + // chain (or a self-redirect) fails closed. + const replacementId = readSupersededReplacementId(error); + if ( + allowSupersededRedirect && + replacementId !== undefined && + replacementId !== sessionId + ) { + const redirected = await this.loadManagedSession( + replacementId, + target, + workspaceCwd, + expectedCwd, + isolation, + false, + ); + this.healSupersededRoute(sessionId, redirected.sessionId); + return { + loaded: redirected.loaded, + sessionId: redirected.sessionId, + redirectedFrom: sessionId, + }; + } + throw error; + } finally { + this.endSessionLoad(loadWindow); + } + } + + /** + * Remap every route that still points at a superseded session to its + * replacement, and drop the superseded id's bookkeeping. The manager's + * own task registry heals separately (it owns the task-name mapping). + */ + private healSupersededRoute( + oldSessionId: string, + newSessionId: string, + ): void { + let changed = false; + for (const [key, mappedSessionId] of this.toSession) { + if (mappedSessionId !== oldSessionId) continue; + this.invalidateRouteOperation(key); + this.toSession.set(key, newSessionId); + changed = true; + } + changed = this.toTarget.delete(oldSessionId) || changed; + changed = this.toCwd.delete(oldSessionId) || changed; + this.liveSessionIds.delete(oldSessionId); + this.staleBridgeBindings.delete(oldSessionId); + if (changed) this.persist(); + } + + /** + * Worktree reset: transfer the task session's checkout ownership to a + * fresh replacement session on the daemon, validate the replacement's + * attestation, and route it. The task registry itself is swapped by the + * named-session manager (it owns the task-name mapping); this call only + * wires the router's session bookkeeping. + * + * A failure past the daemon's marker flip rolls nothing back: the + * registry keeps pointing at the old id, and the next selection's load + * heals it through the superseded redirect — the failed reset drops the + * old id from the live set so that load consults the daemon again. + */ + async replaceManagedWorktreeSession( + sessionId: string, + target: SessionTarget, + workspaceCwd: string, + expectedCwd: string, + ): Promise { + const bridge = this.bridge; + if (!bridge.resetWorktreeSession) { + throw new Error('Worktree reset is not supported by this bridge'); + } + const loadWindow = this.beginSessionLoad(); + const lifecycleGeneration = this.lifecycleGeneration; + const bindingToken = {}; + let replacementId: string | undefined; + try { + replacementId = await bridge.resetWorktreeSession( + sessionId, + workspaceCwd, + this.sessionOptions(target.channelName), + bindingToken, + ); + if ( + lifecycleGeneration !== this.lifecycleGeneration || + bridge !== this.bridge + ) { + this.scheduleManagedDiscard(bridge, replacementId, bindingToken); + throw new Error('Managed session reset was invalidated'); + } + if (typeof replacementId !== 'string' || replacementId.length === 0) { + throw new Error('Invalid session ID from bridge'); + } + if (loadWindow.delete(replacementId)) { + await bridge.discardSession?.(replacementId, bindingToken); + throw new Error( + `Managed session ${replacementId} died before reset completed`, + ); + } + const actualCwd = this.validateManagedSessionIdentity( + bridge, + replacementId, + workspaceCwd, + expectedCwd, + 'worktree', + ); + this.toTarget.set(replacementId, target); + this.toCwd.set(replacementId, actualCwd); + this.liveSessionIds.add(replacementId); + this.forgetManagedSession(sessionId); + return replacementId; + } catch (error) { + if (replacementId !== undefined) { + await bridge + .discardSession?.(replacementId, bindingToken) + .catch(() => undefined); } + // A failure past the daemon's marker flip leaves this session superseded + // server-side with no eviction event to tell us. While the id stays live + // the next load short-circuits and never consults the daemon, so the + // superseded redirect can never heal it. Drop only the live flag: the + // route mappings stay, so a pre-flip failure re-loads this id. The bridge + // keeps holding its client either way, so record the id — the re-load has + // to release that binding before attaching, and attaching over it would + // report the session as replaced and abort the load that recovers it. + this.staleBridgeBindings.add(sessionId); + this.liveSessionIds.delete(sessionId); throw error; } finally { this.endSessionLoad(loadWindow); @@ -636,16 +843,20 @@ export class SessionRouter { changed = this.toTarget.delete(sessionId) || changed; changed = this.toCwd.delete(sessionId) || changed; changed = this.liveSessionIds.delete(sessionId) || changed; + this.staleBridgeBindings.delete(sessionId); if (changed) this.persist(); } async detachManagedSession(sessionId: string): Promise { try { - if (this.liveSessionIds.has(sessionId)) { - if (!this.bridge.discardSession) { - throw new Error('Managed session detach is not supported'); - } + if (this.bridge.discardSession) { + // Release regardless of the live flag: a failed worktree reset drops it + // so the superseded redirect can heal the id, while the bridge keeps + // holding the client and its event pump. Discarding an id the bridge + // has no binding for is a no-op. await this.bridge.discardSession(sessionId); + } else if (this.liveSessionIds.has(sessionId)) { + throw new Error('Managed session detach is not supported'); } } finally { this.forgetManagedSession(sessionId); @@ -910,6 +1121,7 @@ export class SessionRouter { this.creatingSessions.clear(); this.sessionLoadWindows.clear(); this.liveSessionIds.clear(); + this.staleBridgeBindings.clear(); this.routeTokens.clear(); } diff --git a/packages/channels/base/src/named-session-manager.test.ts b/packages/channels/base/src/named-session-manager.test.ts index 868dfde43aa..abecab8a6cd 100644 --- a/packages/channels/base/src/named-session-manager.test.ts +++ b/packages/channels/base/src/named-session-manager.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChannelAgentBridge } from './ChannelAgentBridge.js'; import { NamedSessionManager } from './named-session-manager.js'; import { canonicalizeWorkspacePath } from './paths.js'; -import { SessionRouter } from './SessionRouter.js'; +import { readDaemonHttpErrorCode, SessionRouter } from './SessionRouter.js'; function createBridge(): ChannelAgentBridge { let nextId = 0; @@ -54,6 +54,25 @@ function createBridge(): ChannelAgentBridge { ); return sessionId; }), + resetWorktreeSession: vi.fn( + async (sessionId: string, workspaceCwd: string) => { + // The daemon transfers the checkout: the replacement session owns the + // SAME worktree path, and the superseded session leaves the live set. + const previous = known.get(sessionId); + const replacementId = `session-${++nextId}`; + const info = previous?.worktree + ? { + workspaceCwd, + worktree: { ...previous.worktree }, + worktreeState: 'persisted-v1' as const, + } + : { workspaceCwd }; + live.set(replacementId, info); + known.set(replacementId, info); + live.delete(sessionId); + return replacementId; + }, + ), prompt: vi.fn().mockResolvedValue(''), cancelSession: vi.fn().mockResolvedValue(undefined), discardSession: vi.fn(async (sessionId: string) => { @@ -69,6 +88,17 @@ function createBridge(): ChannelAgentBridge { }; } +/** A daemon conflict error by shape (channels/base keeps no SDK dependency). */ +function daemonError( + code: string, + extraBody: Record = {}, +): Error { + const error = new Error(`daemon rejected with ${code}`); + error.name = 'DaemonHttpError'; + Object.assign(error, { status: 409, body: { code, ...extraBody } }); + return error; +} + const alice = { senderId: 'alice', chatId: 'group-1', @@ -104,6 +134,58 @@ describe('NamedSessionManager', () => { }); } + /** + * Restart harness for superseded-redirect tests: a fresh bridge whose daemon + * "moved" the checkout of `oldSessionId` to session-2 (loading the old id + * fails with the typed conflict), plus a fresh router and manager on the + * same registry file so nothing is live yet. + */ + function supersededRestart(filePath: string, oldSessionId: string) { + const restartedBridge = createBridge(); + vi.mocked(restartedBridge.loadSession).mockImplementation( + async (sessionId: string) => { + if (sessionId === oldSessionId) { + throw daemonError('worktree_session_superseded', { + replacementSessionId: 'session-2', + }); + } + return sessionId; + }, + ); + vi.mocked(restartedBridge.listSessions).mockReturnValue([ + { + sessionId: 'session-2', + workspaceCwd: '/workspace', + hasActivePrompt: false, + worktree: { + slug: 'feature', + path: `/worktrees/${oldSessionId}`, + branch: 'feature', + }, + worktreeState: 'persisted-v1', + }, + ]); + const restartedRouter = new SessionRouter( + restartedBridge, + '/workspace', + 'user', + undefined, + { recoveryMode: 'lazy' }, + ); + const restartedManager = new NamedSessionManager({ + channelName: 'channel-a', + cwd: '/workspace', + filePath, + router: restartedRouter, + isBusy: () => false, + }); + return { + bridge: restartedBridge, + router: restartedRouter, + manager: restartedManager, + }; + } + it('adopts the existing route as default without changing its session', async () => { const legacyId = await router.resolve( 'channel-a', @@ -377,7 +459,7 @@ describe('NamedSessionManager', () => { vi.mocked(bridge.loadSession).mockClear(); await expect(named.resumeReserved(alice, feature.sessionId)).resolves.toBe( - true, + feature.sessionId, ); await expect(named.current(alice)).resolves.toEqual( @@ -398,11 +480,11 @@ describe('NamedSessionManager', () => { await expect( named.resumeReserved({ ...alice, senderId: 'bob' }, review.sessionId), - ).resolves.toBe(false); + ).resolves.toBeUndefined(); await expect(named.close(alice, 'review')).resolves.toBeDefined(); - await expect(named.resumeReserved(alice, review.sessionId)).resolves.toBe( - false, - ); + await expect( + named.resumeReserved(alice, review.sessionId), + ).resolves.toBeUndefined(); expect(bridge.loadSession).not.toHaveBeenCalled(); }); @@ -743,15 +825,302 @@ describe('NamedSessionManager', () => { }); }); - it('rejects resetting a worktree task before creating a replacement', async () => { + it('resets a worktree task onto a fresh session that keeps the worktree', async () => { const named = manager(); - await named.create(alice, 'feature', 'worktree'); + const created = await named.create(alice, 'feature', 'worktree'); + const worktreeCwd = canonicalizeWorkspacePath( + `/worktrees/${created.sessionId}`, + ); + const before = JSON.parse( + readFileSync(join(dir, 'named-sessions.json'), 'utf8'), + ) as { owners: Array<{ tasks: Array<{ createdAt: number }> }> }; + const createdAt = before.owners[0]?.tasks[0]?.createdAt; + vi.mocked(bridge.newSession).mockClear(); + + const reset = await named.reset(alice); + + expect(reset).toEqual({ + name: 'feature', + previousSessionId: created.sessionId, + sessionId: 'session-2', + worktreeKept: true, + }); + expect(bridge.resetWorktreeSession).toHaveBeenCalledWith( + created.sessionId, + '/workspace', + { sourceId: 'channel-a' }, + expect.anything(), + ); + // The reset is an ownership transfer: no fresh conversation session is + // created and nothing is discarded. + expect(bridge.newSession).not.toHaveBeenCalled(); + expect(bridge.discardSession).not.toHaveBeenCalled(); + // The replacement keeps the same worktree checkout and takes over routing. + expect(router.getSessionCwd('session-2')).toBe(worktreeCwd); + expect(router.getSession('channel-a', 'alice', 'group-1')).toBe( + 'session-2', + ); + expect(named.presentation(created.sessionId)).toBeUndefined(); + expect(named.presentation('session-2')).toEqual( + expect.objectContaining({ taskName: 'feature', status: 'open' }), + ); + await expect(named.current(alice)).resolves.toEqual( + expect.objectContaining({ name: 'feature', sessionId: 'session-2' }), + ); + + // The registry swap preserves the task's identity and creation timestamp. + const persisted = JSON.parse( + readFileSync(join(dir, 'named-sessions.json'), 'utf8'), + ) as { + owners: Array<{ + tasks: Array<{ sessionId: string; cwd: string; createdAt: number }>; + }>; + }; + expect(persisted.owners[0]?.tasks[0]).toMatchObject({ + sessionId: 'session-2', + cwd: worktreeCwd, + createdAt, + }); + }); + + it('rejects resetting a busy worktree task before any daemon call', async () => { + const named = manager(vi.fn().mockReturnValue(true)); + const created = await named.create(alice, 'feature', 'worktree'); vi.mocked(bridge.newSession).mockClear(); await expect(named.reset(alice)).rejects.toThrow( - 'cannot be cleared or reset yet', + 'Task "feature" is busy. Wait for the running prompt to finish (or cancel it), then try again.', ); + + expect(bridge.resetWorktreeSession).not.toHaveBeenCalled(); expect(bridge.newSession).not.toHaveBeenCalled(); + await expect(named.current(alice)).resolves.toEqual( + expect.objectContaining({ + name: 'feature', + sessionId: created.sessionId, + }), + ); + }); + + it('attempts a worktree reset once and keeps the daemon cause', async () => { + const named = manager(); + const created = await named.create(alice, 'feature', 'worktree'); + vi.mocked(bridge.resetWorktreeSession).mockRejectedValue( + daemonError('worktree_reset_invalid_state'), + ); + + const error: unknown = await named + .reset(alice) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Could not reset task "feature".'); + expect(readDaemonHttpErrorCode(error)).toBe('worktree_reset_invalid_state'); + // The daemon resumes an interrupted transfer itself, so the manager never + // retries a rejected reset. + expect(bridge.resetWorktreeSession).toHaveBeenCalledTimes(1); + await expect(named.current(alice)).resolves.toEqual( + expect.objectContaining({ sessionId: created.sessionId }), + ); + }); + + it('keeps the typed daemon code on a load failure for the channel', async () => { + const named = manager(); + const created = await named.create(alice, 'feature', 'worktree'); + router.handleSessionDied(created.sessionId); + vi.mocked(bridge.loadSession).mockRejectedValue( + daemonError('worktree_reset_interrupted'), + ); + + const error: unknown = await named + .use(alice, 'feature') + .catch((caught: unknown) => caught); + + expect((error as Error).message).toBe( + 'Could not load task "feature". The current task was not changed.', + ); + expect(readDaemonHttpErrorCode(error)).toBe('worktree_reset_interrupted'); + await expect(named.current(alice)).resolves.toEqual( + expect.objectContaining({ sessionId: created.sessionId }), + ); + }); + + it('detaches the replacement when the registry commit fails after a worktree reset', async () => { + const registryDir = join(dir, 'registry'); + const named = new NamedSessionManager({ + channelName: 'channel-a', + cwd: '/workspace', + filePath: join(registryDir, 'named-sessions.json'), + router, + isBusy: () => false, + now: () => 1_000, + }); + const created = await named.create(alice, 'feature', 'worktree'); + rmSync(registryDir, { recursive: true, force: true }); + writeFileSync(registryDir, 'block'); + + await expect(named.reset(alice)).rejects.toThrow('Failed to persist'); + + // The freshly created replacement is released again; the registry keeps + // pointing at the superseded id, which the next load heals via the + // superseded redirect. + expect(bridge.discardSession).toHaveBeenCalledWith('session-2'); + expect(router.getTarget('session-2')).toBeUndefined(); + expect(router.getTarget(created.sessionId)).toBeUndefined(); + expect(router.getSession('channel-a', 'alice', 'group-1')).toBeUndefined(); + await expect(named.current(alice)).resolves.toEqual( + expect.objectContaining({ sessionId: created.sessionId }), + ); + }); + + it('heals the registry when the daemon reports the task session superseded', async () => { + const filePath = join(dir, 'named-sessions.json'); + const firstManager = manager(); + const created = await firstManager.create(alice, 'feature', 'worktree'); + const worktreeCwd = canonicalizeWorkspacePath( + `/worktrees/${created.sessionId}`, + ); + const restarted = supersededRestart(filePath, created.sessionId); + + const selected = await restarted.manager.use(alice, 'feature'); + + // The selection, the registry, and the router all name the replacement. + expect(selected.sessionId).toBe('session-2'); + await expect(restarted.manager.current(alice)).resolves.toEqual( + expect.objectContaining({ name: 'feature', sessionId: 'session-2' }), + ); + expect(restarted.manager.presentation(created.sessionId)).toBeUndefined(); + expect(restarted.manager.presentation('session-2')).toEqual( + expect.objectContaining({ taskName: 'feature', status: 'open' }), + ); + expect(restarted.router.getSession('channel-a', 'alice', 'group-1')).toBe( + 'session-2', + ); + expect(restarted.router.getSessionCwd('session-2')).toBe(worktreeCwd); + const persisted = JSON.parse(readFileSync(filePath, 'utf8')) as { + owners: Array<{ tasks: Array<{ sessionId: string }> }>; + }; + expect(persisted.owners[0]?.tasks[0]?.sessionId).toBe('session-2'); + }); + + it('moves the resolve reservation to the replacement session', async () => { + const filePath = join(dir, 'named-sessions.json'); + const firstManager = manager(); + const created = await firstManager.create(alice, 'feature', 'worktree'); + const restarted = supersededRestart(filePath, created.sessionId); + const reserved: string[] = []; + const released: string[] = []; + + const sessionId = await restarted.manager.resolve(alice, (id) => { + reserved.push(id); + return () => { + released.push(id); + }; + }); + + expect(sessionId).toBe('session-2'); + expect(reserved).toEqual([created.sessionId, 'session-2']); + expect(released).toEqual([created.sessionId]); + }); + + it('reports the healed replacement session from a reserved reload', async () => { + const filePath = join(dir, 'named-sessions.json'); + const firstManager = manager(); + const created = await firstManager.create(alice, 'feature', 'worktree'); + const restarted = supersededRestart(filePath, created.sessionId); + + // A queued turn is bound to the superseded id; the reload must hand back + // the id the caller dispatches on, not just a success flag. + await expect( + restarted.manager.resumeReserved(alice, created.sessionId), + ).resolves.toBe('session-2'); + // The heal rewrote the registry, but the *other* queued turns of this chat + // are still bound to the superseded id. They must follow the task onto the + // replacement instead of resolving to nothing and being dropped in + // silence. + await expect( + restarted.manager.resumeReserved(alice, created.sessionId), + ).resolves.toBe('session-2'); + await expect(restarted.manager.current(alice)).resolves.toEqual( + expect.objectContaining({ name: 'feature', sessionId: 'session-2' }), + ); + }); + + it('keeps a healed load when the registry cannot persist the heal', async () => { + const registryDir = join(dir, 'registry'); + const filePath = join(registryDir, 'named-sessions.json'); + const firstManager = new NamedSessionManager({ + channelName: 'channel-a', + cwd: '/workspace', + filePath, + router, + isBusy: () => false, + now: () => 1_000, + }); + const created = await firstManager.create(alice, 'feature', 'worktree'); + const restarted = supersededRestart(filePath, created.sessionId); + rmSync(registryDir, { recursive: true, force: true }); + writeFileSync(registryDir, 'block'); + const reserved: string[] = []; + const released: string[] = []; + + // The redirect loaded, validated and routed the replacement, so an + // unwritable registry must not be reported as a load that created none. + await expect( + restarted.manager.resolve(alice, (id) => { + reserved.push(id); + return () => { + released.push(id); + }; + }), + ).resolves.toBe('session-2'); + expect(reserved).toEqual([created.sessionId, 'session-2']); + expect(released).toEqual([created.sessionId]); + // The replacement keeps its route: detaching it would drop a live session + // the caller is about to dispatch on. + expect(restarted.bridge.discardSession).not.toHaveBeenCalledWith( + 'session-2', + ); + expect(restarted.router.getSession('channel-a', 'alice', 'group-1')).toBe( + 'session-2', + ); + + // The registry still names the superseded id — the state the router + // tolerates — and the next load heals it once writing succeeds again. + rmSync(registryDir, { force: true }); + mkdirSync(registryDir, { recursive: true }); + await expect(restarted.manager.resolve(alice)).resolves.toBe('session-2'); + const persisted = JSON.parse(readFileSync(filePath, 'utf8')) as { + owners: Array<{ tasks: Array<{ sessionId: string }> }>; + }; + expect(persisted.owners[0]?.tasks[0]?.sessionId).toBe('session-2'); + }); + + it('still detaches the replacement when a selection cannot persist a healed load', async () => { + const registryDir = join(dir, 'registry'); + const filePath = join(registryDir, 'named-sessions.json'); + const firstManager = new NamedSessionManager({ + channelName: 'channel-a', + cwd: '/workspace', + filePath, + router, + isBusy: () => false, + now: () => 1_000, + }); + const created = await firstManager.create(alice, 'feature', 'worktree'); + const restarted = supersededRestart(filePath, created.sessionId); + rmSync(registryDir, { recursive: true, force: true }); + writeFileSync(registryDir, 'block'); + + // A selection change commits itself, so it still fails closed and releases + // the replacement it loaded; the best-effort heal must not swallow that. + await expect(restarted.manager.use(alice, 'feature')).rejects.toThrow( + 'Failed to persist', + ); + expect(restarted.bridge.discardSession).toHaveBeenCalledWith('session-2'); + await expect(restarted.manager.current(alice)).resolves.toEqual( + expect.objectContaining({ sessionId: created.sessionId }), + ); }); it('loads a legacy shared-only v1 registry and writes its workspace root next', async () => { @@ -795,6 +1164,83 @@ describe('NamedSessionManager', () => { ); }); + it('keeps a fallback session healed mid-close when detachment rolls back', async () => { + const filePath = join(dir, 'named-sessions.json'); + const named = manager(); + const fallback = await named.create(alice, 'fallback', 'worktree'); + const closing = await named.create(alice, 'feature', 'worktree'); + const healedId = 'session-1b'; + // The fallback's session was superseded out of band, so loading it during + // the close heals the registry onto the replacement before the detach of + // the closing task fails and the snapshot is restored. + router.handleSessionDied(fallback.sessionId); + vi.mocked(bridge.loadSession).mockImplementation( + async (sessionId: string) => { + if (sessionId === fallback.sessionId) { + throw daemonError('worktree_session_superseded', { + replacementSessionId: healedId, + }); + } + return sessionId; + }, + ); + vi.mocked(bridge.listSessions).mockReturnValue([ + { + sessionId: healedId, + workspaceCwd: '/workspace', + hasActivePrompt: false, + worktree: { + slug: 'fallback', + path: `/worktrees/${fallback.sessionId}`, + branch: 'fallback', + }, + worktreeState: 'persisted-v1', + }, + { + sessionId: closing.sessionId, + workspaceCwd: '/workspace', + hasActivePrompt: false, + worktree: { + slug: 'feature', + path: `/worktrees/${closing.sessionId}`, + branch: 'feature', + }, + worktreeState: 'persisted-v1', + }, + ]); + vi.mocked(bridge.discardSession).mockRejectedValueOnce( + new Error('detach failed'), + ); + + await expect(named.close(alice, 'feature')).rejects.toThrow( + 'Failed to close', + ); + + const persisted = JSON.parse(readFileSync(filePath, 'utf8')) as { + owners: Array<{ + activeTaskName: string | null; + tasks: Array<{ name: string; sessionId: string; status: string }>; + }>; + }; + const tasks = persisted.owners[0]?.tasks ?? []; + // The committed heal survives the rollback ... + expect(tasks.find((task) => task.name === 'fallback')?.sessionId).toBe( + healedId, + ); + // ... and the task that failed closed is restored under its original id. + expect(tasks.find((task) => task.name === 'feature')).toMatchObject({ + sessionId: closing.sessionId, + status: 'open', + }); + expect(persisted.owners[0]?.activeTaskName).toBe('feature'); + expect(named.presentation(closing.sessionId)).toEqual( + expect.objectContaining({ taskName: 'feature', status: 'open' }), + ); + expect(named.presentation(healedId)).toEqual( + expect.objectContaining({ taskName: 'fallback', status: 'open' }), + ); + }); + it('replaces the indexed session ID when resetting a task', async () => { const named = manager(); const review = await named.create(alice, 'review'); @@ -802,6 +1248,7 @@ describe('NamedSessionManager', () => { const reset = await named.reset(alice); expect(reset?.previousSessionId).toBe(review.sessionId); + expect(reset?.worktreeKept).toBe(false); expect(named.presentation(review.sessionId)).toBeUndefined(); expect(named.presentation(reset!.sessionId)).toEqual( expect.objectContaining({ taskName: 'review', status: 'open' }), diff --git a/packages/channels/base/src/named-session-manager.ts b/packages/channels/base/src/named-session-manager.ts index 63714c8654e..522daeb155d 100644 --- a/packages/channels/base/src/named-session-manager.ts +++ b/packages/channels/base/src/named-session-manager.ts @@ -79,6 +79,24 @@ interface StoredRegistry { owners: StoredOwner[]; } +/** + * A named-session failure that carries the task it happened to. The recovery + * advice the channel derives from it depends on which task broke, and that is + * not recoverable from the message text. + */ +export class NamedSessionTaskError extends Error { + readonly taskName: string; + + constructor( + message: string, + taskName: string, + options?: { cause?: unknown }, + ) { + super(message, options); + this.taskName = taskName; + } +} + export class NamedSessionManager { private readonly channelName: string; private readonly cwd: string; @@ -90,6 +108,12 @@ export class NamedSessionManager { private registry: StoredRegistry; private taskBySessionId: Map; private readonly ownerOperations = new Map>(); + /** + * Superseded session id → the replacement its task moved onto. A queued turn + * stays bound to the id it reserved, so the reservation lookup has to follow + * the task or every turn after the first one is stranded on the old id. + */ + private readonly supersededSessionIds = new Map(); constructor(options: NamedSessionManagerOptions) { this.channelName = options.channelName; @@ -233,22 +257,32 @@ export class NamedSessionManager { }); } + /** + * Reload a task a queued turn is already bound to. Resolves to the session + * id the caller must dispatch on — the healed replacement when a superseded + * redirect fired — or undefined when the task is no longer reservable. + */ resumeReserved( input: NamedSessionOwnerInput, sessionId: string, - ): Promise { + ): Promise { return this.withOwnerLock(input, async () => { const owner = await this.ensureOwner(input, false); + // The registry may already have healed this task onto its replacement; + // the caller's binding still names the superseded id. + const reservedSessionId = + this.supersededSessionIds.get(sessionId) ?? sessionId; const task = owner?.tasks.find( (candidate) => - candidate.sessionId === sessionId && candidate.status === 'open', + candidate.sessionId === reservedSessionId && + candidate.status === 'open', ); - if (!task) return false; - await this.loadTask( + if (!task) return undefined; + const result = await this.loadTask( task, `Could not reload reserved task "${task.name}".`, ); - return true; + return result.sessionId; }); } @@ -332,12 +366,13 @@ export class NamedSessionManager { } const timestamp = this.nextTimestamp(owner); - const loaded = await this.loadTask( + const loadResult = await this.loadTask( task, `Could not load task "${task.name}". The current task was not changed.`, ); const updatedTask: StoredTask = { ...task, + sessionId: loadResult.sessionId, status: 'open', updatedAt: timestamp, lastSelectedAt: timestamp, @@ -346,14 +381,18 @@ export class NamedSessionManager { try { this.commitOwner(nextOwner); } catch (error) { - if (loaded) { + if (loadResult.loaded) { await this.router - .detachManagedSession(task.sessionId) + .detachManagedSession(loadResult.sessionId) .catch(() => undefined); } throw error; } - this.router.activateManagedSession(task.sessionId, task.target, task.cwd); + this.router.activateManagedSession( + loadResult.sessionId, + updatedTask.target, + updatedTask.cwd, + ); return this.selection(nextOwner, updatedTask); }); } @@ -388,11 +427,14 @@ export class NamedSessionManager { .sort((a, b) => b.lastSelectedAt - a.lastSelectedAt)[0] : undefined; let replacementLoaded = false; + let replacementSessionId: string | undefined; if (replacement) { - replacementLoaded = await this.loadTask( + const replacementLoad = await this.loadTask( replacement, `Could not load fallback task "${replacement.name}". Task "${task.name}" was not closed.`, ); + replacementLoaded = replacementLoad.loaded; + replacementSessionId = replacementLoad.sessionId; } const closedTask: StoredTask = { @@ -408,6 +450,7 @@ export class NamedSessionManager { const selectedReplacement = replacement ? { ...replacement, + sessionId: replacementSessionId ?? replacement.sessionId, updatedAt: timestamp, lastSelectedAt: timestamp, } @@ -424,7 +467,7 @@ export class NamedSessionManager { } catch (error) { if (replacement && replacementLoaded) { await this.router - .detachManagedSession(replacement.sessionId) + .detachManagedSession(replacementSessionId ?? replacement.sessionId) .catch(() => undefined); } throw error; @@ -432,7 +475,7 @@ export class NamedSessionManager { if (replacement) { this.router.activateManagedSession( - replacement.sessionId, + replacementSessionId ?? replacement.sessionId, replacement.target, replacement.cwd, ); @@ -440,15 +483,27 @@ export class NamedSessionManager { try { await this.router.detachManagedSession(task.sessionId); } catch (error) { - this.commitOwner(owner); + // The fallback's load may have healed its own superseded redirect + // mid-close; restoring the raw snapshot would undo that heal. + const healedReplacement = + replacement !== undefined && + replacementSessionId !== undefined && + replacementSessionId !== replacement.sessionId + ? { ...replacement, sessionId: replacementSessionId } + : undefined; + this.commitOwner( + healedReplacement + ? this.replaceTask(owner, healedReplacement, owner.activeTaskName) + : owner, + ); this.router.forgetManagedSession(task.sessionId); - await this.loadTask( + const restored = await this.loadTask( task, `Task "${task.name}" could not be restored after close failed.`, ); if (wasActive) { this.router.activateManagedSession( - task.sessionId, + restored.sessionId, task.target, task.cwd, ); @@ -467,22 +522,63 @@ export class NamedSessionManager { }); } - reset( - input: NamedSessionOwnerInput, - ): Promise< - { name: string; previousSessionId: string; sessionId: string } | undefined + reset(input: NamedSessionOwnerInput): Promise< + | { + name: string; + previousSessionId: string; + sessionId: string; + worktreeKept: boolean; + } + | undefined > { return this.withOwnerLock(input, async () => { const owner = await this.ensureOwner(input, false); if (!owner?.activeTaskName) return undefined; const task = this.findTask(owner, owner.activeTaskName); if (!task || task.status !== 'open') return undefined; + const timestamp = this.nextTimestamp(owner); + if (task.isolation === 'worktree') { - throw new Error( - `Task "${task.name}" uses a worktree and cannot be cleared or reset yet. Continue using the task or close it. Its files were not changed.`, + // Worktree reset is an ownership transfer, not a new conversation: + // the daemon moves the checkout to a fresh session and supersedes + // this one. Busy tasks fail before any daemon call, and the daemon + // resumes an interrupted transfer itself, so one attempt is enough. + if (this.isBusy(task.sessionId)) { + throw new Error( + `Task "${task.name}" is busy. Wait for the running prompt to finish (or cancel it), then try again.`, + ); + } + const sessionId = await this.resetWorktreeSession(task); + const updatedTask: StoredTask = { + ...task, + sessionId, + updatedAt: timestamp, + lastSelectedAt: timestamp, + }; + const nextOwner = this.replaceTask(owner, updatedTask, task.name); + try { + this.commitOwner(nextOwner); + } catch (error) { + await this.router + .detachManagedSession(sessionId) + .catch(() => undefined); + throw error; + } + this.router.activateManagedSession( + sessionId, + updatedTask.target, + updatedTask.cwd, ); + this.router.forgetManagedSession(task.sessionId); + this.repointSupersededSessionIds(task.sessionId, sessionId); + this.supersededSessionIds.delete(task.sessionId); + return { + name: task.name, + previousSessionId: task.sessionId, + sessionId, + worktreeKept: true, + }; } - const timestamp = this.nextTimestamp(owner); const sessionId = await this.createSession( task.target, @@ -510,10 +606,13 @@ export class NamedSessionManager { updatedTask.cwd, ); this.router.forgetManagedSession(task.sessionId); + this.repointSupersededSessionIds(task.sessionId, sessionId); + this.supersededSessionIds.delete(task.sessionId); return { name: task.name, previousSessionId: task.sessionId, sessionId, + worktreeKept: false, }; }); } @@ -615,15 +714,26 @@ export class NamedSessionManager { throw new Error('The selected Channel task is unavailable.'); } const release = reserve?.(task.sessionId); + let activeRelease = release; try { - await this.loadTask( + const result = await this.loadTask( task, `Could not load task "${task.name}". No replacement session was created.`, ); - this.router.activateManagedSession(task.sessionId, task.target, task.cwd); - return task.sessionId; + if (result.sessionId !== task.sessionId && reserve) { + // The task moved to its replacement session; the reservation must + // follow the id the caller will dispatch on. + release?.(); + activeRelease = reserve(result.sessionId); + } + this.router.activateManagedSession( + result.sessionId, + task.target, + task.cwd, + ); + return result.sessionId; } catch (error) { - release?.(); + activeRelease?.(); throw error; } } @@ -665,19 +775,93 @@ export class NamedSessionManager { private async loadTask( task: StoredTask, failureMessage: string, - ): Promise { + ): Promise<{ loaded: boolean; sessionId: string }> { try { - return ( - await this.router.loadManagedSession( - task.sessionId, - task.target, - this.cwd, - task.cwd, - task.isolation, - ) - ).loaded; + const result = await this.router.loadManagedSession( + task.sessionId, + task.target, + this.cwd, + task.cwd, + task.isolation, + ); + if ( + result.redirectedFrom !== undefined && + result.sessionId !== task.sessionId + ) { + try { + this.healSupersededTask(task, result.sessionId); + } catch { + // The load succeeded and the router already routes the replacement; + // a registry still naming the superseded id is the state a failed + // reset leaves behind, so the next load re-heals it. Callers that + // commit a selection still fail closed on their own write. + } + } + // Callers must use the returned id for everything after this point: + // when a superseded redirect fired, the caller's `task` snapshot still + // names the old session, and building registry updates from it would + // overwrite the heal. + return result; } catch (error) { - throw new Error(failureMessage, { cause: error }); + throw new NamedSessionTaskError(failureMessage, task.name, { + cause: error, + }); + } + } + + /** + * Point a task at its replacement session after a superseded redirect + * healed the route. No-op when the registry entry already moved on (a + * concurrent reset under the same owner lock cannot interleave, but a + * stale in-memory snapshot could). + */ + private healSupersededTask(task: StoredTask, sessionId: string): void { + const owner = this.getOwner(task.target); + if (!owner) return; + const current = this.findTask(owner, task.name); + if (!current || current.sessionId !== task.sessionId) return; + const timestamp = this.nextTimestamp(owner); + this.commitOwner( + this.replaceTask( + owner, + { ...current, sessionId, updatedAt: timestamp }, + owner.activeTaskName, + ), + ); + this.repointSupersededSessionIds(task.sessionId, sessionId); + this.supersededSessionIds.set(task.sessionId, sessionId); + } + + /** + * Follow every id that still resolves to `oldSessionId` onto its + * replacement, so a task superseded more than once stays reachable from the + * oldest id a turn is bound to and the map does not grow once per reset. + */ + private repointSupersededSessionIds( + oldSessionId: string, + newSessionId: string, + ): void { + for (const boundSessionId of [...this.supersededSessionIds.keys()]) { + if (this.supersededSessionIds.get(boundSessionId) === oldSessionId) { + this.supersededSessionIds.set(boundSessionId, newSessionId); + } + } + } + + private async resetWorktreeSession(task: StoredTask): Promise { + try { + return await this.router.replaceManagedWorktreeSession( + task.sessionId, + task.target, + this.cwd, + task.cwd, + ); + } catch (error) { + throw new NamedSessionTaskError( + `Could not reset task "${task.name}".`, + task.name, + { cause: error }, + ); } } diff --git a/packages/cli/src/commands/channel/daemon-worker.test.ts b/packages/cli/src/commands/channel/daemon-worker.test.ts index 32ed2731778..f500dedf98d 100644 --- a/packages/cli/src/commands/channel/daemon-worker.test.ts +++ b/packages/cli/src/commands/channel/daemon-worker.test.ts @@ -124,6 +124,7 @@ const mockDefaultDaemonClient = vi.hoisted(() => const mockDefaultDaemonSessionClient = vi.hoisted(() => ({ createOrAttach: vi.fn(), resume: vi.fn(), + resetWorktree: vi.fn(), })); const mockBridgeStart = vi.hoisted(() => vi.fn()); @@ -370,6 +371,15 @@ function createSdk() { setModel: vi.fn(), respondToPermission: vi.fn(), }), + resetWorktree: vi.fn().mockResolvedValue({ + sessionId: 'reset-session', + workspaceCwd: '/workspace', + prompt: vi.fn(), + events: vi.fn(), + cancel: vi.fn(), + setModel: vi.fn(), + respondToPermission: vi.fn(), + }), }; return { client, DaemonClient, DaemonSessionClient, deleteSessionsData }; } @@ -581,6 +591,34 @@ describe('createDaemonSessionFactory', () => { 'qwen-channel-worker', ); }); + + it('routes worktree resets through resetWorktree', async () => { + const sdk = createSdk(); + const factory = createDaemonSessionFactory({ + client: sdk.client, + DaemonSessionClient: sdk.DaemonSessionClient, + clientId: 'qwen-channel-worker', + }); + + await factory({ + workspaceCwd: '/workspace', + sourceId: 'dingtalk-main', + worktreeReset: { sessionId: 'old-session' }, + }); + + expect(sdk.DaemonSessionClient.resetWorktree).toHaveBeenCalledWith( + sdk.client, + 'old-session', + { + workspaceCwd: '/workspace', + sessionScope: 'thread', + sourceType: 'channel', + sourceId: 'dingtalk-main', + }, + ); + expect(sdk.DaemonSessionClient.createOrAttach).not.toHaveBeenCalled(); + expect(sdk.DaemonSessionClient.resume).not.toHaveBeenCalled(); + }); }); describe('createDaemonChannelBridgeFacade', () => { diff --git a/packages/cli/src/commands/channel/daemon-worker.ts b/packages/cli/src/commands/channel/daemon-worker.ts index 5e9d5329f83..b9705ce4032 100644 --- a/packages/cli/src/commands/channel/daemon-worker.ts +++ b/packages/cli/src/commands/channel/daemon-worker.ts @@ -113,6 +113,8 @@ const SESSION_BTW_FEATURE: ServeFeature = 'session_btw'; const SESSION_PERMISSION_VOTE_FEATURE: ServeFeature = 'session_permission_vote'; const SESSION_WORKTREE_PERSISTENCE_FEATURE: ServeFeature = 'session_worktree_persistence_v1'; +const SESSION_WORKTREE_RESET_FEATURE: ServeFeature = + 'session_worktree_reset_v1'; const MAX_ACTIVE_WEBHOOK_TASKS = 16; const WORKER_CHANNEL_DISCONNECT_DRAIN_MS = CHANNEL_WORKER_STOP_GRACE_MS - CHANNEL_WORKER_KILL_GRACE_MS; @@ -185,6 +187,20 @@ interface DaemonSessionClientStaticLike { }, clientId?: string, ): Promise; + // The reset route registers no client for the caller, so unlike `create` + // and `resume` this takes no client id. + resetWorktree( + client: DaemonClientLike, + sessionId: string, + req: { + workspaceCwd: string; + modelServiceId?: string; + sessionScope: 'thread'; + approvalMode?: string; + sourceType?: string; + sourceId?: string; + }, + ): Promise; } interface DaemonSdkLike { @@ -251,6 +267,13 @@ export function createDaemonSessionFactory({ // (dingtalk/feishu) is derivable from the name via the channel config. ...(req.sourceId ? { sourceId: req.sourceId } : {}), }; + if (req.worktreeReset) { + return await DaemonSessionClient.resetWorktree( + client, + req.worktreeReset.sessionId, + daemonReq, + ); + } if (req.sessionId) { return await DaemonSessionClient.resume( client, @@ -314,6 +337,10 @@ export function createDaemonChannelBridgeFacade( facade.listSessions = bridge.listSessions.bind(bridge); } + if (bridge.resetWorktreeSession) { + facade.resetWorktreeSession = bridge.resetWorktreeSession.bind(bridge); + } + if (bridge.registerChannelLoopToolHandler) { facade.registerChannelLoopToolHandler = bridge.registerChannelLoopToolHandler.bind(bridge); @@ -573,6 +600,9 @@ export async function runChannelDaemonWorker( sessionWorktreePersistence: capabilities.features.includes( SESSION_WORKTREE_PERSISTENCE_FEATURE, ), + sessionWorktreeReset: capabilities.features.includes( + SESSION_WORKTREE_RESET_FEATURE, + ), ...(opts.promptAuthorization ? { promptAuthorization: opts.promptAuthorization } : {}), diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index 3277a244be7..f0ddd672ab1 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -139,6 +139,7 @@ export { McpServerNotFoundError, McpServerRestartFailedError, SessionBusyError, + SessionResetPendingError, WorkspaceDrainingError, BridgeChannelQuarantinedError, InvalidRewindTargetError, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 65826872f15..a2c882f81da 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -451,6 +451,11 @@ export const SERVE_CAPABILITY_REGISTRY = { // Worktree-backed session create/load responses are durably persisted and // carry per-response `persisted-v1` attestation. session_worktree_persistence_v1: { since: 'v1' }, + // Worktree ownership transfer: `POST /session/:id/worktree-reset` moves a + // session's checkout ownership to a fresh replacement session, and the + // restore surface reports the superseded / interrupted / missing-marker + // classifications as typed 409s. + session_worktree_reset_v1: { since: 'v1' }, // Workspace-qualified ACP transport (issue #6378 Phase 4): // `/workspaces/:workspace/acp` mounts a per-runtime ACP dispatcher (HTTP + // WebSocket) for each registered workspace, with per-runtime device-flow and diff --git a/packages/cli/src/serve/routes/session-worktree-reset.test.ts b/packages/cli/src/serve/routes/session-worktree-reset.test.ts new file mode 100644 index 00000000000..814eb79c0b7 --- /dev/null +++ b/packages/cli/src/serve/routes/session-worktree-reset.test.ts @@ -0,0 +1,1589 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import express, { type Response } from 'express'; +import request from 'supertest'; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + SessionService, + WORKTREE_SESSION_FILE, + createWorktreeSessionMarkerExclusive, + readWorktreeSessionMarkerStrict, + readWorktreeSessionStrict, + writeWorktreeSession, + WorktreeMarkerCommittedError, + type ChatRecord, + type WorktreeSession, +} from '@qwen-code/qwen-code-core'; +import { + SessionNotFoundError, + type AcpSessionBridge, +} from '../acp-session-bridge.js'; +import { sendBridgeError } from '../server/error-response.js'; +import { + createWorkspaceRegistry, + type WorkspaceRuntime, +} from '../workspace-registry.js'; + +const archiveMocks = vi.hoisted(() => ({ + deleteDaemonSessionIfOrphan: vi.fn(), +})); + +vi.mock('../server/session-archive.js', async (importOriginal) => ({ + ...(await importOriginal()), + deleteDaemonSessionIfOrphan: archiveMocks.deleteDaemonSessionIfOrphan, +})); + +// The transfer's crash windows sit between durable writes, so the cases that +// pin them observe the core primitives directly. Each spy delegates to the +// real implementation and is re-installed in `beforeEach`; individual cases +// override one call to park or fail the transfer at a chosen step. +type CoreTransferFns = Pick< + typeof import('@qwen-code/qwen-code-core'), + | 'writeWorktreeSession' + | 'readWorktreeSessionStrict' + | 'transferWorktreeSessionMarkerOwner' +>; +const coreMocks = vi.hoisted(() => ({ + real: {} as CoreTransferFns, + writeWorktreeSession: vi.fn(), + readWorktreeSessionStrict: vi.fn(), + transferWorktreeSessionMarkerOwner: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + coreMocks.real = { + writeWorktreeSession: actual.writeWorktreeSession, + readWorktreeSessionStrict: actual.readWorktreeSessionStrict, + transferWorktreeSessionMarkerOwner: + actual.transferWorktreeSessionMarkerOwner, + }; + return { + ...actual, + writeWorktreeSession: coreMocks.writeWorktreeSession, + readWorktreeSessionStrict: coreMocks.readWorktreeSessionStrict, + transferWorktreeSessionMarkerOwner: + coreMocks.transferWorktreeSessionMarkerOwner, + }; +}); + +import { registerSessionRoutes } from './session.js'; + +const tmpRoot = mkdtempSync(path.join(tmpdir(), 'session-worktree-reset-')); +afterAll(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +type CreationMetadata = Awaited< + ReturnType +>; +const CHANNEL_METADATA: CreationMetadata = { + sourceType: 'channel', + sourceId: 'telegram', +}; + +beforeEach(() => { + vi.clearAllMocks(); + archiveMocks.deleteDaemonSessionIfOrphan.mockResolvedValue(true); + coreMocks.writeWorktreeSession.mockImplementation( + coreMocks.real.writeWorktreeSession, + ); + coreMocks.readWorktreeSessionStrict.mockImplementation( + coreMocks.real.readWorktreeSessionStrict, + ); + coreMocks.transferWorktreeSessionMarkerOwner.mockImplementation( + coreMocks.real.transferWorktreeSessionMarkerOwner, + ); + vi.spyOn(SessionService.prototype, 'readCreationMetadata').mockResolvedValue( + CHANNEL_METADATA, + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface SessionSummaryStub { + hasActivePrompt: boolean; + pendingInteractionCount?: number; +} + +interface FakeBridge { + bridge: AcpSessionBridge; + spawnedIds: string[]; + getSessionSummary: ReturnType; + spawnOrAttach: ReturnType; + changeSessionCwd: ReturnType; + setSessionWorktree: ReturnType; + clearSessionWorktree: ReturnType; + severSessionClients: ReturnType; + setSessionResetPending: ReturnType; + clearSessionResetPending: ReturnType; + loadSession: ReturnType; + killSession: ReturnType; + detachClient: ReturnType; + setSummary(sessionId: string, summary: SessionSummaryStub): void; +} + +function makeBridge(options: { resetSupport?: boolean } = {}): FakeBridge { + const summaries = new Map(); + const spawnedIds: string[] = []; + const getSessionSummary = vi.fn((sessionId: string) => { + const summary = summaries.get(sessionId); + if (!summary) throw new SessionNotFoundError(sessionId); + return summary; + }); + const spawnOrAttach = vi.fn(async () => { + const sessionId = randomUUID(); + spawnedIds.push(sessionId); + return { sessionId, attached: false }; + }); + const changeSessionCwd = vi.fn( + async (sessionId: string, req: { path: string }) => ({ + sessionId, + previousCwd: '', + newCwd: realpathSync(req.path), + warnings: [], + }), + ); + const setSessionWorktree = vi.fn(); + const clearSessionWorktree = vi.fn(); + // The bridge reports whether the superseded entry is gone after its drain + // loop; a child holding background work refuses the idle close and it + // survives. Tests override this per case with mockResolvedValueOnce(false). + const severSessionClients = vi.fn(async () => true); + const setSessionResetPending = vi.fn(() => true); + const clearSessionResetPending = vi.fn(); + const loadSession = vi.fn( + async (req: { sessionId: string; workspaceCwd: string }) => ({ + sessionId: req.sessionId, + workspaceCwd: req.workspaceCwd, + currentCwd: req.workspaceCwd, + attached: false, + clientId: undefined, + hasActivePrompt: false, + }), + ); + const killSession = vi.fn(async () => true); + const detachClient = vi.fn(async () => {}); + const bridge = { + getSessionSummary, + spawnOrAttach, + changeSessionCwd, + setSessionWorktree, + loadSession, + resumeSession: loadSession, + killSession, + detachClient, + markSessionCatalogChanged: vi.fn(), + deleteSessionAttachments: vi.fn(async () => {}), + ...(options.resetSupport === false + ? {} + : { + clearSessionWorktree, + severSessionClients, + setSessionResetPending, + clearSessionResetPending, + }), + } as unknown as AcpSessionBridge; + return { + bridge, + spawnedIds, + getSessionSummary, + spawnOrAttach, + changeSessionCwd, + setSessionWorktree, + clearSessionWorktree, + severSessionClients, + setSessionResetPending, + clearSessionResetPending, + loadSession, + killSession, + detachClient, + setSummary: (sessionId, summary) => { + summaries.set(sessionId, summary); + }, + }; +} + +function makeFixture( + options: { + resetSupport?: boolean; + onResponse?: (res: Response) => void; + } = {}, +) { + const workspaceDir = path.join(tmpRoot, randomUUID()); + const slug = 'task'; + const worktreeDir = path.join(workspaceDir, '.qwen', 'worktrees', slug); + mkdirSync(worktreeDir, { recursive: true }); + const runtimeBaseDir = path.join(tmpRoot, randomUUID()); + const sessionService = new SessionService(workspaceDir, { runtimeBaseDir }); + const fake = makeBridge(options); + const runtime = { + workspaceId: randomUUID(), + workspaceCwd: workspaceDir, + sessionRuntimeBaseDir: runtimeBaseDir, + primary: true, + trusted: true, + bridge: fake.bridge, + } as WorkspaceRuntime; + const app = express(); + app.use(express.json()); + // Registered before the routes so a case can hold the live response and + // destroy its socket mid-transfer, the way an aborted caller does. + if (options.onResponse) { + const onResponse = options.onResponse; + app.use((_req, res, next) => { + onResponse(res); + next(); + }); + } + const registry = createWorkspaceRegistry([runtime]); + registerSessionRoutes(app, { + boundWorkspace: workspaceDir, + bridge: fake.bridge, + workspaceRegistry: registry, + archiveCoordinator: { + runSharedMany: async (_sessionIds, fn) => await fn(), + } as Parameters[1]['archiveCoordinator'], + mutate: () => (_req, _res, next) => next(), + sendBridgeError, + sessionShellCommandEnabled: true, + languageCodes: ['en'], + }); + const worktreeBranch = `worktree-${slug}`; + const realTarget = realpathSync(worktreeDir); + const candidateRoots = [ + path.join(realpathSync(workspaceDir), '.qwen', 'worktrees'), + ]; + return { + workspaceDir, + slug, + worktreeDir, + worktreeBranch, + realTarget, + candidateRoots, + sessionService, + fake, + app, + async writeSidecar( + sessionId: string, + overrides: Partial = {}, + ): Promise { + await writeWorktreeSession( + sessionService.getWorktreeSessionPath(sessionId), + { + slug, + worktreePath: worktreeDir, + worktreeBranch, + originalCwd: workspaceDir, + workspaceCwd: workspaceDir, + originalBranch: 'main', + originalHeadCommit: 'abc123', + ...overrides, + }, + ); + }, + // The reset route resolves the session record through the transcript + // store (resolveSessionIdForRestore): one head record whose cwd belongs + // to the fixture workspace is enough to make the id resolvable. + writeTranscript(sessionId: string): void { + const record: ChatRecord = { + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: new Date(Date.UTC(2026, 0, 1)).toISOString(), + type: 'user', + provenance: 'real_user', + cwd: workspaceDir, + version: '1.0.0', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }; + const transcriptPath = sessionService.getSessionTranscriptPath(sessionId); + mkdirSync(path.dirname(transcriptPath), { recursive: true }); + writeFileSync(transcriptPath, `${JSON.stringify(record)}\n`, 'utf8'); + }, + readSidecar(sessionId: string) { + return readWorktreeSessionStrict( + sessionService.getWorktreeSessionPath(sessionId), + ); + }, + readMarker() { + return readWorktreeSessionMarkerStrict(worktreeDir); + }, + createMarker(sessionId: string) { + return createWorktreeSessionMarkerExclusive(worktreeDir, sessionId); + }, + // The marker is git-excluded, so a task-local `git clean -xdf` removes it + // from a checkout whose transfer already committed. + removeMarker(): void { + rmSync(path.join(worktreeDir, WORKTREE_SESSION_FILE), { force: true }); + }, + }; +} + +type Fixture = ReturnType; + +async function expectSidecar( + fixture: Fixture, + sessionId: string, +): Promise { + const read = await fixture.readSidecar(sessionId); + expect(read.state).not.toBe('invalid'); + return read.state === 'valid' ? read.session : undefined; +} + +async function expectMarkerOwner( + fixture: Fixture, + sessionId: string, +): Promise { + const marker = await fixture.readMarker(); + expect(marker.state).toBe('valid'); + if (marker.state === 'valid') { + expect(marker.sessionId).toBe(sessionId); + } +} + +describe('POST /session/:id/worktree-reset', () => { + it('transfers worktree ownership to a fresh replacement session', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(200); + const newId = res.body.sessionId; + expect(typeof newId).toBe('string'); + expect(newId).not.toBe(oldId); + expect(res.body.currentCwd).toBe(fixture.realTarget); + expect(res.body.worktree).toEqual({ + slug: fixture.slug, + path: fixture.realTarget, + branch: fixture.worktreeBranch, + }); + expect(res.body.worktreeState).toBe('persisted-v1'); + + expect(fixture.fake.spawnOrAttach).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceCwd: fixture.workspaceDir, + sessionScope: 'thread', + }), + ); + expect(fixture.fake.changeSessionCwd).toHaveBeenCalledWith( + newId, + expect.objectContaining({ path: fixture.realTarget }), + ); + expect(fixture.fake.setSessionWorktree).toHaveBeenCalledWith(newId, { + slug: fixture.slug, + path: fixture.realTarget, + branch: fixture.worktreeBranch, + }); + expect(fixture.fake.clearSessionWorktree).toHaveBeenCalledWith(oldId); + expect(fixture.fake.severSessionClients).toHaveBeenCalledWith(oldId); + expect(fixture.fake.setSessionResetPending).toHaveBeenCalledWith(oldId); + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(newId); + const newSidecar = await expectSidecar(fixture, newId); + expect(newSidecar?.supersedes).toBe(oldId); + expect(newSidecar?.supersededBy).toBeUndefined(); + await expectMarkerOwner(fixture, newId); + expect(archiveMocks.deleteDaemonSessionIfOrphan).not.toHaveBeenCalled(); + }); + + it('rejects with 409 when the session has no worktree sidecar', async () => { + const fixture = makeFixture(); + const sessionId = randomUUID(); + fixture.writeTranscript(sessionId); + + const res = await request(fixture.app) + .post(`/session/${sessionId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_unsupported'); + expect(res.body.sessionId).toBe(sessionId); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + }); + + it('rejects with 409 for a legacy sidecar without workspaceCwd', async () => { + const fixture = makeFixture(); + const sessionId = randomUUID(); + fixture.writeTranscript(sessionId); + await fixture.writeSidecar(sessionId, { workspaceCwd: undefined }); + await fixture.createMarker(sessionId); + + const res = await request(fixture.app) + .post(`/session/${sessionId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_unsupported'); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + }); + + it('rejects with 409 while the old session has an active prompt', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + fixture.fake.setSummary(oldId, { + hasActivePrompt: true, + pendingInteractionCount: 0, + }); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_active'); + expect(res.body.sessionId).toBe(oldId); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBeUndefined(); + await expectMarkerOwner(fixture, oldId); + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); + + it('releases the ownership lock after a refused transfer so the checkout stays usable', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + // A refusal from inside the lock: the busy check runs after the transfer + // acquired it, so this is the path where a leaked key would wedge every + // later operation on the same checkout. + fixture.fake.setSummary(oldId, { + hasActivePrompt: true, + pendingInteractionCount: 0, + }); + + const refused = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(refused.status).toBe(409); + expect(refused.body.code).toBe('worktree_reset_active'); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + + fixture.fake.setSummary(oldId, { + hasActivePrompt: false, + pendingInteractionCount: 0, + }); + // Both take the same worktree-keyed lock. Awaited directly on purpose: a + // refusal that leaked the key would hang here until the test times out. + const load = await request(fixture.app) + .post(`/session/${oldId}/load`) + .send({}); + + expect(load.status).toBe(200); + expect(load.body.worktreeState).toBe('persisted-v1'); + + const retry = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(retry.status).toBe(200); + expect(retry.body.sessionId).not.toBe(oldId); + await expectMarkerOwner(fixture, retry.body.sessionId); + }); + + it('rejects with 404 when the session record is missing', async () => { + const fixture = makeFixture(); + const sessionId = randomUUID(); + // A valid worktree sidecar and marker must not rescue the request: + // without a persisted transcript the resolver reports the id missing. + await fixture.writeSidecar(sessionId); + await fixture.createMarker(sessionId); + + const res = await request(fixture.app) + .post(`/session/${sessionId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.code).toBe('session_not_found'); + expect(res.body.sessionId).toBe(sessionId); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + }); + + it('fails with 500 when the bridge lacks worktree reset support', async () => { + const fixture = makeFixture({ resetSupport: false }); + const sessionId = randomUUID(); + fixture.writeTranscript(sessionId); + + const res = await request(fixture.app) + .post(`/session/${sessionId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('Bridge does not support worktree reset'); + }); + + it('rolls back an interrupted pre-commit transfer and runs a fresh one', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const crashedId = randomUUID(); + fixture.writeTranscript(oldId); + fixture.writeTranscript(crashedId); + await fixture.writeSidecar(oldId, { supersededBy: crashedId }); + await fixture.writeSidecar(crashedId, { supersedes: oldId }); + await fixture.createMarker(oldId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(200); + const newId = res.body.sessionId; + expect(newId).not.toBe(crashedId); + expect(newId).toBe(fixture.fake.spawnedIds[0]); + expect(res.body.worktreeState).toBe('persisted-v1'); + + expect(archiveMocks.deleteDaemonSessionIfOrphan).toHaveBeenCalledTimes(1); + expect(archiveMocks.deleteDaemonSessionIfOrphan).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: crashedId }), + ); + expect(await fixture.readSidecar(crashedId)).toEqual({ + state: 'missing', + }); + + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(newId); + const newSidecar = await expectSidecar(fixture, newId); + expect(newSidecar?.supersedes).toBe(oldId); + await expectMarkerOwner(fixture, newId); + }); + + it('resumes a committed transfer idempotently without respawning', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const replacementId = randomUUID(); + fixture.writeTranscript(oldId); + fixture.writeTranscript(replacementId); + await fixture.writeSidecar(oldId, { supersededBy: replacementId }); + await fixture.writeSidecar(replacementId, { supersedes: oldId }); + await fixture.createMarker(replacementId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.sessionId).toBe(replacementId); + expect(res.body.currentCwd).toBe(fixture.realTarget); + expect(res.body.worktree).toEqual({ + slug: fixture.slug, + path: fixture.realTarget, + branch: fixture.worktreeBranch, + }); + expect(res.body.worktreeState).toBe('persisted-v1'); + expect(res.body.attached).toBe(false); + + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + expect(fixture.fake.clearSessionWorktree).toHaveBeenCalledWith(oldId); + expect(fixture.fake.severSessionClients).toHaveBeenCalledWith(oldId); + expect(archiveMocks.deleteDaemonSessionIfOrphan).not.toHaveBeenCalled(); + + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(replacementId); + await expectMarkerOwner(fixture, replacementId); + }); + + it('rolls back an interrupted pre-commit transfer whose replacement has no transcript record', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const crashedId = randomUUID(); + fixture.writeTranscript(oldId); + // The crash left the sidecar pair linked before the replacement ever + // persisted a record: the marker, not the record, decides the rollback. + await fixture.writeSidecar(oldId, { supersededBy: crashedId }); + await fixture.writeSidecar(crashedId, { supersedes: oldId }); + await fixture.createMarker(oldId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(200); + const newId = res.body.sessionId; + expect(newId).not.toBe(crashedId); + expect(res.body.worktreeState).toBe('persisted-v1'); + + expect(archiveMocks.deleteDaemonSessionIfOrphan).toHaveBeenCalledTimes(1); + expect(archiveMocks.deleteDaemonSessionIfOrphan).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: crashedId }), + ); + expect(await fixture.readSidecar(crashedId)).toEqual({ state: 'missing' }); + await expectMarkerOwner(fixture, newId); + }); + + it('refuses a pre-commit rollback whose interrupted replacement survives', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const crashedId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId, { supersededBy: crashedId }); + await fixture.writeSidecar(crashedId, { supersedes: oldId }); + await fixture.createMarker(oldId); + // `false` is a documented outcome, not an error: a client attached to the + // interrupted replacement, so `requireZeroAttaches` bailed and it is still + // live inside the checkout. + archiveMocks.deleteDaemonSessionIfOrphan.mockResolvedValueOnce(false); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_invalid_state'); + // Fail closed before the destructive writes: the survivor keeps both links + // that name it, so nothing is dismantled and no second writer is spawned + // into the same checkout beside it. + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(crashedId); + const crashedSidecar = await expectSidecar(fixture, crashedId); + expect(crashedSidecar?.supersedes).toBe(oldId); + await expectMarkerOwner(fixture, oldId); + expect(fixture.fake.clearSessionWorktree).not.toHaveBeenCalled(); + expect(fixture.fake.severSessionClients).not.toHaveBeenCalled(); + // Nothing committed, so releasing the barrier is still this request's. + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); + + it('leaves the backward link alone when the pre-commit rollback fails halfway', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const crashedId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId, { supersededBy: crashedId }); + await fixture.writeSidecar(crashedId, { supersedes: oldId }); + await fixture.createMarker(oldId); + // The rollback's second write is the one that drops the backward link, so + // an ENOSPC or a crash between the two leaves the residue this pins. + coreMocks.writeWorktreeSession.mockImplementationOnce(async () => { + throw Object.assign(new Error('no space left on device'), { + code: 'ENOSPC', + }); + }); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(500); + // The forward link went first, so the residue is the backward link alone: + // the replacement's sidecar is gone while `S_old` still names it. + expect(await fixture.readSidecar(crashedId)).toEqual({ state: 'missing' }); + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(crashedId); + await expectMarkerOwner(fixture, oldId); + + // A retry refuses that disagreeing pair for repair instead of taking the + // fresh path and spawning a second replacement beside the first. + const retry = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(retry.status).toBe(409); + expect(retry.body.code).toBe('worktree_reset_invalid_state'); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + }); + + it('resumes a committed transfer idempotently when the replacement has no transcript record', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const replacementId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId, { supersededBy: replacementId }); + await fixture.writeSidecar(replacementId, { supersedes: oldId }); + await fixture.createMarker(replacementId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.sessionId).toBe(replacementId); + expect(res.body.worktreeState).toBe('persisted-v1'); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + expect(fixture.fake.clearSessionWorktree).toHaveBeenCalledWith(oldId); + expect(fixture.fake.severSessionClients).toHaveBeenCalledWith(oldId); + expect(archiveMocks.deleteDaemonSessionIfOrphan).not.toHaveBeenCalled(); + await expectMarkerOwner(fixture, replacementId); + }); + + it('refuses a marker-cleaned committed transfer while the replacement is live', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + + const committed = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + expect(committed.status).toBe(200); + const replacementId = committed.body.sessionId; + fixture.writeTranscript(replacementId); + // The committed transfer cleared and severed the old session itself; + // only the retry's bridge calls are under test from here on. + vi.clearAllMocks(); + // A `git clean -xdf` in the checkout removes the excluded marker, and the + // committed replacement is still live on this daemon: the marker-missing + // shape now reads exactly like the pre-commit crash shape. + fixture.removeMarker(); + expect((await fixture.readMarker()).state).toBe('missing'); + fixture.fake.setSummary(replacementId, { + hasActivePrompt: false, + pendingInteractionCount: 0, + }); + + const retry = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(retry.status).toBe(409); + expect(retry.body.code).toBe('worktree_reset_invalid_state'); + // Nothing destructive ran and no second writer was spawned. + expect(archiveMocks.deleteDaemonSessionIfOrphan).not.toHaveBeenCalled(); + expect(fixture.fake.clearSessionWorktree).not.toHaveBeenCalled(); + expect(fixture.fake.severSessionClients).not.toHaveBeenCalled(); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + const replacementSidecar = await expectSidecar(fixture, replacementId); + expect(replacementSidecar?.supersedes).toBe(oldId); + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(replacementId); + expect( + existsSync( + fixture.sessionService.getSessionTranscriptPath(replacementId), + ), + ).toBe(true); + expect((await fixture.readMarker()).state).toBe('missing'); + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); + + it('refuses a pre-commit resume whose replacement sidecar is missing', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const replacementId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId, { supersededBy: replacementId }); + // The crash left the backward link but never persisted the forward one, + // so the pair disagrees and the rollback the marker would authorize has + // nothing to agree with. The transfer writes the backward link first + // precisely so this is the shape such a crash leaves — a visible refusal + // rather than a forward link no retry ever looks for. + await fixture.createMarker(oldId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_invalid_state'); + expect(res.body.sessionId).toBe(oldId); + // Fail-closed: the interrupted state survives untouched for repair. + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + expect(archiveMocks.deleteDaemonSessionIfOrphan).not.toHaveBeenCalled(); + expect(fixture.fake.clearSessionWorktree).not.toHaveBeenCalled(); + expect(fixture.fake.severSessionClients).not.toHaveBeenCalled(); + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(replacementId); + expect(await fixture.readSidecar(replacementId)).toEqual({ + state: 'missing', + }); + await expectMarkerOwner(fixture, oldId); + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); + + it('writes the backward supersede link before the forward one', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + const writesBefore = coreMocks.writeWorktreeSession.mock.calls.length; + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(200); + const newId = res.body.sessionId; + const oldSidecarPath = fixture.sessionService.getWorktreeSessionPath(oldId); + const newSidecarPath = fixture.sessionService.getWorktreeSessionPath(newId); + const transferWrites = + coreMocks.writeWorktreeSession.mock.calls.slice(writesBefore); + // The backward link is the only door into the resume classification, so + // it has to be durable first: a crash between the two writes then leaves + // the shape the case above refuses for repair, instead of a dangling + // forward link that no retry ever sees (it would spawn a second + // replacement and strand the first under an untyped 500 forever). + expect(transferWrites.map((call) => call[0])).toEqual([ + oldSidecarPath, + newSidecarPath, + ]); + expect(transferWrites[0]?.[1]).toMatchObject({ supersededBy: newId }); + expect(transferWrites[1]?.[1]).toMatchObject({ supersedes: oldId }); + }); + + it('refuses a committed resume whose replacement links to another session', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const replacementId = randomUUID(); + const unrelatedId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId, { supersededBy: replacementId }); + await fixture.writeSidecar(replacementId, { supersedes: unrelatedId }); + await fixture.createMarker(replacementId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_invalid_state'); + expect(res.body.sessionId).toBe(oldId); + // The marker committed ownership, so the idempotent finish must not + // sever the old session against a replacement that disowns it. + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + expect(fixture.fake.clearSessionWorktree).not.toHaveBeenCalled(); + expect(fixture.fake.severSessionClients).not.toHaveBeenCalled(); + expect(archiveMocks.deleteDaemonSessionIfOrphan).not.toHaveBeenCalled(); + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(replacementId); + const replacementSidecar = await expectSidecar(fixture, replacementId); + expect(replacementSidecar?.supersedes).toBe(unrelatedId); + await expectMarkerOwner(fixture, replacementId); + // The marker committed ownership, so this refusal is post-commit: the + // superseded entry stays fenced for operator repair instead of being + // re-admitted as a writer into a checkout the marker has handed over. + expect(fixture.fake.clearSessionResetPending).not.toHaveBeenCalled(); + }); + + it('keeps the barrier armed when a committed resume finds the replacement busy', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const replacementId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId, { supersededBy: replacementId }); + await fixture.writeSidecar(replacementId, { supersedes: oldId }); + await fixture.createMarker(replacementId); + // The replacement is a session in use — reached through the superseded + // redirect and prompted by another client — so the idempotent finish + // refuses instead of severing underneath it. + fixture.fake.setSummary(replacementId, { + hasActivePrompt: true, + pendingInteractionCount: 0, + }); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_active'); + expect(res.body.sessionId).toBe(replacementId); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + expect(fixture.fake.severSessionClients).not.toHaveBeenCalled(); + // This refusal is post-commit too — the marker named the replacement + // before the request started — so the superseded entry stays fenced + // rather than being re-admitted into a checkout it no longer owns. + expect(fixture.fake.clearSessionResetPending).not.toHaveBeenCalled(); + + // The protocol's answer to `worktree_reset_active` is "retry once it + // settles", so the fence must not be stranded: the settled retry finishes + // the severance and drops it. + fixture.fake.setSummary(replacementId, { + hasActivePrompt: false, + pendingInteractionCount: 0, + }); + const retry = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(retry.status).toBe(200); + expect(retry.body.sessionId).toBe(replacementId); + expect(fixture.fake.spawnOrAttach).not.toHaveBeenCalled(); + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); + + it('rolls back sidecars and the orphan replacement when the marker flip fails', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const thirdPartyId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(thirdPartyId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_invalid_state'); + + expect(fixture.fake.spawnOrAttach).toHaveBeenCalledTimes(1); + const spawnedId = fixture.fake.spawnedIds[0]!; + expect(archiveMocks.deleteDaemonSessionIfOrphan).toHaveBeenCalledTimes(1); + expect(archiveMocks.deleteDaemonSessionIfOrphan).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: spawnedId }), + ); + expect(await fixture.readSidecar(spawnedId)).toEqual({ + state: 'missing', + }); + + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBeUndefined(); + await expectMarkerOwner(fixture, thirdPartyId); + expect(fixture.fake.clearSessionWorktree).not.toHaveBeenCalled(); + expect(fixture.fake.severSessionClients).not.toHaveBeenCalled(); + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); + + it('keeps the linked pair when the fresh rollback cannot reap its spawn', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const thirdPartyId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(thirdPartyId); + // The flip fails and the spawned replacement cannot be removed: a client + // attached to it in the meantime, so it is still live in the checkout. + archiveMocks.deleteDaemonSessionIfOrphan.mockResolvedValueOnce(false); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_invalid_state'); + const spawnedId = fixture.fake.spawnedIds[0]!; + expect(archiveMocks.deleteDaemonSessionIfOrphan).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: spawnedId }), + ); + // The compensation stops before unwriting the links, so the survivor stays + // named on disk and a retry classifies it there instead of spawning a + // second writer into the same checkout. + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(spawnedId); + const spawnedSidecar = await expectSidecar(fixture, spawnedId); + expect(spawnedSidecar?.supersedes).toBe(oldId); + await expectMarkerOwner(fixture, thirdPartyId); + expect(fixture.fake.severSessionClients).not.toHaveBeenCalled(); + + const retry = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(retry.status).toBe(409); + expect(retry.body.code).toBe('worktree_reset_invalid_state'); + expect(fixture.fake.spawnOrAttach).toHaveBeenCalledTimes(1); + }); + + it('reaps the spawned replacement when the caller disconnects before the flip', async () => { + let liveResponse: Response | undefined; + const fixture = makeFixture({ + onResponse: (res) => { + liveResponse = res; + }, + }); + const oldId = randomUUID(); + const spawnedId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + const oldSidecarPath = fixture.sessionService.getWorktreeSessionPath(oldId); + + // Park the transfer at the spawn so the socket can go away while the + // handover is still running, before the marker flip commits it. + let releaseSpawn!: () => void; + const spawnGate = new Promise((resolve) => { + releaseSpawn = resolve; + }); + let resetAtSpawn!: () => void; + const resetSpawned = new Promise((resolve) => { + resetAtSpawn = resolve; + }); + fixture.fake.spawnOrAttach.mockImplementationOnce(async () => { + resetAtSpawn(); + await spawnGate; + return { sessionId: spawnedId, attached: false }; + }); + // The dead socket settles the caller's promise at once while the route + // runs on, so the rollback signals its own progress: the reap is its first + // act, and its last is restoring the old sidecar — whose payload has no + // `supersededBy`, unlike the transfer's own write to that same path. + let reaped!: () => void; + const reapCalled = new Promise((resolve) => { + reaped = resolve; + }); + archiveMocks.deleteDaemonSessionIfOrphan.mockImplementationOnce( + async () => { + reaped(); + return true; + }, + ); + let rollbackDone!: () => void; + const rollbackFinished = new Promise((resolve) => { + rollbackDone = resolve; + }); + const realWrite = coreMocks.real.writeWorktreeSession; + coreMocks.writeWorktreeSession.mockImplementation( + async (...args: Parameters) => { + const written = await realWrite(...args); + if (args[0] === oldSidecarPath && args[1].supersededBy === undefined) { + rollbackDone(); + } + return written; + }, + ); + + const inFlight = request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}) + .then( + (response) => response.status, + () => undefined, + ); + await resetSpawned; + // The caller's budget expired: `fetchWithTimeout` aborts and the socket + // goes away mid-transfer. + liveResponse?.destroy(); + expect(liveResponse?.destroyed).toBe(true); + releaseSpawn(); + // Without the disconnect guard neither signal ever arrives and this hangs + // until the test times out. + await reapCalled; + await rollbackFinished; + await inFlight; + + // The handover never committed and the replacement this request minted is + // reaped instead of being left live in the checkout, holding a + // registration whose id no caller ever received. + expect(archiveMocks.deleteDaemonSessionIfOrphan).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: spawnedId }), + ); + await expectMarkerOwner(fixture, oldId); + expect(await fixture.readSidecar(spawnedId)).toEqual({ state: 'missing' }); + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBeUndefined(); + expect(fixture.fake.severSessionClients).not.toHaveBeenCalled(); + expect(fixture.fake.clearSessionWorktree).not.toHaveBeenCalled(); + }); + + it('never compensates backwards when a step after the marker flip fails', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + // Severing the superseded session's attaches is the last awaited step of + // the transfer and runs after the flip already committed ownership. + fixture.fake.severSessionClients.mockRejectedValueOnce( + new Error('client teardown failed'), + ); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('client teardown failed'); + expect(fixture.fake.spawnOrAttach).toHaveBeenCalledTimes(1); + const newId = fixture.fake.spawnedIds[0]!; + // The failure is genuinely post-flip: the replacement was attested first. + expect(fixture.fake.setSessionWorktree).toHaveBeenCalledWith(newId, { + slug: fixture.slug, + path: fixture.realTarget, + branch: fixture.worktreeBranch, + }); + // The checkout belongs to the replacement now, so the sidecar links stay + // and the replacement is neither orphan-reaped nor replaced again. + await expectMarkerOwner(fixture, newId); + const oldSidecarAfter = await expectSidecar(fixture, oldId); + expect(oldSidecarAfter?.supersededBy).toBe(newId); + const newSidecar = await expectSidecar(fixture, newId); + expect(newSidecar?.supersedes).toBe(oldId); + expect(archiveMocks.deleteDaemonSessionIfOrphan).not.toHaveBeenCalled(); + // The failure is post-commit, so releasing the barrier is not this + // request's to do: the superseded entry stays fenced instead of being + // re-admitted as a writer into the checkout the marker just handed over. + expect(fixture.fake.clearSessionResetPending).not.toHaveBeenCalled(); + + // The fence is not stranded: the retry resumes the committed transfer, + // the severance completes, and the barrier is dropped there. + const retry = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(retry.status).toBe(200); + expect(retry.body.sessionId).toBe(newId); + expect(fixture.fake.spawnOrAttach).toHaveBeenCalledTimes(1); + expect(fixture.fake.severSessionClients).toHaveBeenCalledTimes(2); + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); + + it('never rolls back a marker flip whose post-commit tail failed', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + const realTransfer = coreMocks.real.transferWorktreeSessionMarkerOwner; + // Commit the flip for real, then fail the way the primitive's own + // post-commit tail does: the marker handle's close runs after the marker + // is durable and propagates with the marker intact. + coreMocks.transferWorktreeSessionMarkerOwner.mockImplementationOnce( + async (...args: Parameters) => { + await realTransfer(...args); + throw new WorktreeMarkerCommittedError(args[2], { + cause: Object.assign(new Error('i/o error, close'), { + code: 'EIO', + }), + }); + }, + ); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + const newId = fixture.fake.spawnedIds[0]!; + expect(archiveMocks.deleteDaemonSessionIfOrphan).not.toHaveBeenCalled(); + // Not the invalid-state 409, whose contract is that this request made no + // destructive change: the marker already names the replacement. + expect(res.status).toBe(500); + expect(res.body.code).toBeUndefined(); + expect(res.body.error).not.toMatch(/ownership state is invalid/); + // The pre-flip rollback did not run: both sidecars and the replacement's + // record survive, and the marker stays on the committed owner. + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBe(newId); + const newSidecar = await expectSidecar(fixture, newId); + expect(newSidecar?.supersedes).toBe(oldId); + await expectMarkerOwner(fixture, newId); + // The primitive's tail failed with ownership already moved, so the barrier + // stays armed on the superseded entry rather than being released back into + // a checkout the marker has handed to the replacement. + expect(fixture.fake.clearSessionResetPending).not.toHaveBeenCalled(); + + // The response tells the caller to retry; the retry resumes the committed + // transfer instead of spawning a second replacement. + const retry = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(retry.status).toBe(200); + expect(retry.body.sessionId).toBe(newId); + expect(fixture.fake.spawnOrAttach).toHaveBeenCalledTimes(1); + // The resumed severance completes, and that is what drops the fence. + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); + + it('keeps the barrier armed and reports a superseded session that survives sever', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + // The child holds background work, so the last detach's idle close is + // refused and the superseded entry survives inside the checkout whose + // ownership just moved. + fixture.fake.severSessionClients.mockResolvedValueOnce(false); + + const res = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(res.status).toBe(200); + const newId = res.body.sessionId; + await expectMarkerOwner(fixture, newId); + // The survivor stays fenced: the barrier is armed and never cleared. + expect(fixture.fake.setSessionResetPending).toHaveBeenCalledWith(oldId); + expect(fixture.fake.clearSessionResetPending).not.toHaveBeenCalled(); + // Never escalate: killSession turns a close error into a channel kill, + // and the replacement was spawned onto that same workspace-bound bridge. + expect(fixture.fake.killSession).not.toHaveBeenCalled(); + // And the 200 reports the hold instead of certifying a severance that did + // not happen. + expect(res.body.supersededSessionLive).toBe(true); + + // A retry while the child still holds takes the committed resume path and + // must report the same hold rather than clear the barrier. + fixture.fake.severSessionClients.mockResolvedValueOnce(false); + const retry = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(retry.status).toBe(200); + expect(retry.body.sessionId).toBe(newId); + expect(retry.body.supersededSessionLive).toBe(true); + expect(fixture.fake.spawnOrAttach).toHaveBeenCalledTimes(1); + expect(fixture.fake.clearSessionResetPending).not.toHaveBeenCalled(); + + // Once the child releases its holds the resumed severance completes and + // the barrier is finally dropped. + const settled = await request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}); + + expect(settled.status).toBe(200); + expect(settled.body.sessionId).toBe(newId); + expect(settled.body.supersededSessionLive).toBeUndefined(); + expect(fixture.fake.spawnOrAttach).toHaveBeenCalledTimes(1); + expect(fixture.fake.clearSessionResetPending).toHaveBeenCalledWith(oldId); + }); +}); + +describe('POST /session/:id/load worktree classifications', () => { + it('redirects a superseded session restore to the replacement', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const replacementId = randomUUID(); + await fixture.writeSidecar(oldId, { supersededBy: replacementId }); + await fixture.createMarker(replacementId); + + const res = await request(fixture.app) + .post(`/session/${oldId}/load`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_session_superseded'); + expect(res.body.sessionId).toBe(oldId); + expect(res.body.replacementSessionId).toBe(replacementId); + expect(fixture.fake.loadSession).not.toHaveBeenCalled(); + }); + + it('decides the superseded redirect under the ownership lock, not before it', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + const oldSidecarPath = fixture.sessionService.getWorktreeSessionPath(oldId); + + // Park the transfer at the marker flip: both sidecar links are durably on + // disk and the reset holds the worktree-keyed ownership lock. + let reachedFlip!: () => void; + const atFlip = new Promise((resolve) => { + reachedFlip = resolve; + }); + let releaseFlip!: () => void; + const flipGate = new Promise((resolve) => { + releaseFlip = resolve; + }); + coreMocks.transferWorktreeSessionMarkerOwner.mockImplementationOnce( + async () => { + reachedFlip(); + await flipGate; + // Fail the flip, so the pre-commit rollback clears the very link the + // contending load is about to observe. + throw new Error('Worktree marker owner does not match the expectation'); + }, + ); + // Signal once the load has read the old sidecar, so the rollback cannot + // race ahead of the observation this case is about. + let loadIssued = false; + let preReadDone!: () => void; + const loadPreRead = new Promise((resolve) => { + preReadDone = resolve; + }); + const realRead = coreMocks.real.readWorktreeSessionStrict; + coreMocks.readWorktreeSessionStrict.mockImplementation( + async (...args: Parameters) => { + const read = await realRead(...args); + if (loadIssued && args[0] === oldSidecarPath) preReadDone(); + return read; + }, + ); + + // Supertest dispatches on the first `then`, so both requests are chained. + const resetInFlight = request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}) + .then((response) => response); + await atFlip; + // The transient shape a pre-lock decision would act on: the backward link + // names a replacement this daemon is about to delete. + const midTransfer = await fixture.readSidecar(oldId); + expect(midTransfer.state).toBe('valid'); + if (midTransfer.state === 'valid') { + expect(midTransfer.session.supersededBy).toBe(fixture.fake.spawnedIds[0]); + } + + loadIssued = true; + const loadInFlight = request(fixture.app) + .post(`/session/${oldId}/load`) + .send({}) + .then((response) => response); + await loadPreRead; + + releaseFlip(); + const [resetResponse, loadResponse] = await Promise.all([ + resetInFlight, + loadInFlight, + ]); + + expect(resetResponse.status).toBe(409); + expect(resetResponse.body.code).toBe('worktree_reset_invalid_state'); + // The rollback cleared the link while the load waited for the lock, so the + // locked re-read restores the session that still owns the checkout + // instead of redirecting to a replacement that no longer exists. + expect(loadResponse.status).toBe(200); + expect(loadResponse.body.code).toBeUndefined(); + expect(loadResponse.body.replacementSessionId).toBeUndefined(); + expect(loadResponse.body.worktreeState).toBe('persisted-v1'); + const oldSidecar = await expectSidecar(fixture, oldId); + expect(oldSidecar?.supersededBy).toBeUndefined(); + await expectMarkerOwner(fixture, oldId); + }); + + it('rejects a Part4A restore when the ownership marker is missing', async () => { + const fixture = makeFixture(); + const sessionId = randomUUID(); + await fixture.writeSidecar(sessionId); + + const res = await request(fixture.app) + .post(`/session/${sessionId}/load`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_marker_missing'); + expect(res.body.sessionId).toBe(sessionId); + expect(fixture.fake.loadSession).toHaveBeenCalledTimes(1); + expect(fixture.fake.killSession).toHaveBeenCalledWith(sessionId, { + requireZeroAttaches: true, + }); + }); + + it('rejects a replacement restore whose marker still names the old session', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const replacementId = randomUUID(); + await fixture.writeSidecar(replacementId, { supersedes: oldId }); + await fixture.writeSidecar(oldId, { supersededBy: replacementId }); + await fixture.createMarker(oldId); + + const res = await request(fixture.app) + .post(`/session/${replacementId}/load`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_reset_interrupted'); + expect(res.body.sessionId).toBe(replacementId); + // The interrupted handoff's other side is the SUPERSEDED session, so it + // must not ride the wire under the key that tells a caller which id to + // load instead: the restore target already is the replacement. + expect(res.body.replacementSessionId).toBeUndefined(); + expect(fixture.fake.killSession).toHaveBeenCalledWith(replacementId, { + requireZeroAttaches: true, + }); + }); + + it('serializes a source-less restore behind an in-flight reset', async () => { + const fixture = makeFixture(); + const oldId = randomUUID(); + const replacementId = randomUUID(); + fixture.writeTranscript(oldId); + await fixture.writeSidecar(oldId); + await fixture.createMarker(oldId); + + // Hold the transfer mid-flight: the reset owns the worktree-keyed lock + // for its whole duration, including while the replacement spawns. + let releaseSpawn!: () => void; + const spawnGate = new Promise((resolve) => { + releaseSpawn = resolve; + }); + let resetHoldsLock!: () => void; + const resetAtSpawn = new Promise((resolve) => { + resetHoldsLock = resolve; + }); + fixture.fake.spawnOrAttach.mockImplementationOnce(async () => { + // Reached only after the ownership lock was acquired. + resetHoldsLock(); + await spawnGate; + return { sessionId: replacementId, attached: false }; + }); + // The contending restore is source-less: no source in the request and no + // persisted source on the session, so it is not a Channel restore. + let metadataRead!: () => void; + const metadataGate = new Promise((resolve) => { + metadataRead = resolve; + }); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadata', + ).mockImplementation(async () => { + metadataRead(); + return {}; + }); + + // Supertest dispatches on the first `then`, so both requests are chained + // here: the reset must already be in flight before the restore starts. + const resetInFlight = request(fixture.app) + .post(`/session/${oldId}/worktree-reset`) + .send({}) + .then((response) => response); + await resetAtSpawn; + + let restoreSettled = false; + const restoreInFlight = request(fixture.app) + .post(`/session/${oldId}/load`) + .send({}) + .then((response) => { + restoreSettled = true; + return response; + }); + // The restore reaches the ownership gate right after its metadata read; + // from there it waits instead of attesting ownership mid-transfer. Poll + // so an ungated restore is caught however slowly the box runs. + await metadataGate; + const deadline = Date.now() + 500; + while (!restoreSettled && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(restoreSettled).toBe(false); + expect(fixture.fake.loadSession).not.toHaveBeenCalled(); + + releaseSpawn(); + const [resetResponse, restoreResponse] = await Promise.all([ + resetInFlight, + restoreInFlight, + ]); + + expect(resetResponse.status).toBe(200); + expect(resetResponse.body.sessionId).toBe(replacementId); + // Released state: the restore observes the completed transfer and + // redirects to the replacement instead of attesting the superseded + // session it asked for. + expect(restoreResponse.status).toBe(409); + expect(restoreResponse.body.code).toBe('worktree_session_superseded'); + expect(restoreResponse.body.sessionId).toBe(oldId); + expect(restoreResponse.body.replacementSessionId).toBe(replacementId); + expect(fixture.fake.setSessionWorktree).toHaveBeenCalledTimes(1); + expect(fixture.fake.setSessionWorktree).toHaveBeenCalledWith( + replacementId, + expect.objectContaining({ path: fixture.realTarget }), + ); + expect(fixture.fake.changeSessionCwd).not.toHaveBeenCalledWith( + oldId, + expect.anything(), + ); + }); + + it('keeps a legacy sidecar restore lock-free while a reset holds the worktree', async () => { + const fixture = makeFixture(); + const ownerId = randomUUID(); + const legacyId = randomUUID(); + fixture.writeTranscript(ownerId); + fixture.writeTranscript(legacyId); + await fixture.writeSidecar(ownerId); + // A legacy sidecar records no workspace: its restore validates on the + // legacy path and must never contend on the worktree-keyed lock. + await fixture.writeSidecar(legacyId, { workspaceCwd: undefined }); + await fixture.createMarker(ownerId); + + let releaseSpawn!: () => void; + const spawnGate = new Promise((resolve) => { + releaseSpawn = resolve; + }); + let resetHoldsLock!: () => void; + const resetAtSpawn = new Promise((resolve) => { + resetHoldsLock = resolve; + }); + fixture.fake.spawnOrAttach.mockImplementationOnce(async () => { + resetHoldsLock(); + await spawnGate; + return { sessionId: randomUUID(), attached: false }; + }); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadata', + ).mockResolvedValue({}); + + const resetInFlight = request(fixture.app) + .post(`/session/${ownerId}/worktree-reset`) + .send({}) + .then((response) => response); + await resetAtSpawn; + + // Awaited directly on purpose: a legacy restore that wrongly took the + // lock would hang here until the reset was released, failing the test. + const legacyResponse = await request(fixture.app) + .post(`/session/${legacyId}/load`) + .send({}); + + expect(legacyResponse.status).toBe(200); + expect(legacyResponse.body.worktree).toEqual( + expect.objectContaining({ path: fixture.realTarget }), + ); + + releaseSpawn(); + expect((await resetInFlight).status).toBe(200); + }); + + it('attests persisted-v1 when the marker proves ownership', async () => { + const fixture = makeFixture(); + const sessionId = randomUUID(); + await fixture.writeSidecar(sessionId); + await fixture.createMarker(sessionId); + + const res = await request(fixture.app) + .post(`/session/${sessionId}/load`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.worktree).toEqual({ + slug: fixture.slug, + path: fixture.realTarget, + branch: fixture.worktreeBranch, + }); + expect(res.body.worktreeState).toBe('persisted-v1'); + expect(res.body.currentCwd).toBe(fixture.realTarget); + expect(fixture.fake.changeSessionCwd).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ path: fixture.realTarget }), + ); + expect(fixture.fake.setSessionWorktree).toHaveBeenCalledWith(sessionId, { + slug: fixture.slug, + path: fixture.realTarget, + branch: fixture.worktreeBranch, + }); + expect(fixture.fake.killSession).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index a5f205b0dc0..396c0cd0559 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -30,6 +30,9 @@ import { createWorktreeSessionMarkerExclusive, readWorktreeSessionMarkerStrict, readWorktreeSessionStrict, + transferWorktreeSessionMarkerOwner, + WorktreeMarkerCommittedError, + clearWorktreeSession, writeWorktreeSession, readSessionPrs, toSessionPrInfo, @@ -39,6 +42,7 @@ import { type SessionGroupColor, type SessionGroupPresetColor, type SessionArchiveState, + type WorktreeSession, parseGoalControlRequest, } from '@qwen-code/qwen-code-core'; import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts'; @@ -97,6 +101,14 @@ import { } from '../acp-session-bridge.js'; import type { DaemonLogger } from '../daemon-logger.js'; import type { SendBridgeError } from '../server/error-response.js'; +import { + WorktreeMarkerMissingError, + WorktreeResetActiveError, + WorktreeResetInterruptedError, + WorktreeResetInvalidStateError, + WorktreeResetUnsupportedError, + WorktreeSessionSupersededError, +} from '../server/worktree-reset-errors.js'; import { resolvePromptDeadlineMs } from '../server/prompt-deadline.js'; import { parseClientIdHeader, @@ -850,23 +862,27 @@ export function registerSessionRoutes( string, SessionTranscriptCursorCodec >(); - // A Channel restore keeps its recovered question deferred until this route - // validates and relocates the worktree. Serialize that integrity window so - // a second restore cannot enter the bridge and queue cwd behind the prompt. - const worktreeRestoreTails = new WeakMap< + // Worktree-ownership operations (Part 4A restores, Part 4B resets) share + // one bridge: a restore must not pass ownership validation while a marker + // transfer for the same checkout is mid-flight, and two transfers for one + // checkout must not race the flip. Serialize them on a per-bridge promise + // chain keyed by the canonical worktree path. The bridge WeakMap scopes + // cleanup to bridge lifetime; the key map only grows while an operation + // is in flight (the release callback deletes the entry). + const worktreeOwnershipOpTails = new WeakMap< AcpSessionBridge, Map> >(); - const acquireWorktreeRestore = async ( + const acquireWorktreeOwnershipOp = async ( bridge: AcpSessionBridge, - sessionId: string, + worktreeKey: string, ): Promise<() => void> => { - let tails = worktreeRestoreTails.get(bridge); + let tails = worktreeOwnershipOpTails.get(bridge); if (!tails) { tails = new Map(); - worktreeRestoreTails.set(bridge, tails); + worktreeOwnershipOpTails.set(bridge, tails); } - const key = normalizeSessionIdForLookup(sessionId); + const key = worktreeKey; const previous = tails.get(key); let releaseGate!: () => void; const gate = new Promise((resolve) => { @@ -3283,7 +3299,7 @@ export function registerSessionRoutes( } catch (error) { if (!session.attached) { try { - const removed = await runWithWorkspaceRuntimeStorage(runtime, () => + await runWithWorkspaceRuntimeStorage(runtime, () => deleteDaemonSessionIfOrphan({ sessionId: session.sessionId, service: createWorkspaceRuntimeSessionService(runtime), @@ -3291,13 +3307,6 @@ export function registerSessionRoutes( coordinator: archiveCoordinator, }), ); - if (removed) { - if (worktreeMeta) { - await new GitWorktreeService(workspaceCwd) - .removeUserWorktree(worktreeMeta.slug, { deleteBranch: true }) - .catch(() => {}); - } - } } catch { // Runtime disposal remains responsible for final containment. } @@ -3306,6 +3315,35 @@ export function registerSessionRoutes( .detachClient(session.sessionId, session.clientId) .catch(() => {}); } + // Relocation is only attempted after this check, so no session has + // entered the worktree and nothing can own the checkout regardless of + // how session cleanup turned out. This deliberately differs from the + // relocation and persistence failure handlers below, which may still + // have a live session inside the checkout and so remove it only after + // a definitive session delete; leaving the checkout here would orphan + // the directory, its git registration, and its branch permanently. + if (worktreeMeta) { + const rollbackSlug = worktreeMeta.slug; + await new GitWorktreeService(workspaceCwd) + .removeUserWorktree(rollbackSlug, { deleteBranch: true }) + .catch((removalError) => { + // A failed removal here recreates the exact orphan this block + // exists to prevent, and the caller only sees the generation + // 503 — name the worktree in the daemon log so the leak is + // diagnosable. + daemonLog?.warn( + 'worktree removal failed during generation-closed rollback', + { + sessionId: session.sessionId, + slug: rollbackSlug, + error: + removalError instanceof Error + ? removalError.message + : String(removalError), + }, + ); + }); + } throw error; } // Client may have disconnected during the 1–3s spawn window. If @@ -3877,20 +3915,47 @@ export function registerSessionRoutes( const isChannelRestore = restoreRequestMetadata.sourceType === 'channel'; let isPart4AWorktreeRestore = false; - if ( - isChannelRestore && - runtime.provenance !== 'live-conversation' - ) { + let part4AWorktreeKey: string | undefined; + if (runtime.provenance !== 'live-conversation') { const sidecarBeforeRestore = await readWorktreeSessionStrict( sessionService.getWorktreeSessionPath(restoredStorageSessionId), ); + if (isChannelRestore) { + // The superseded decision is deliberately NOT taken here: + // this pre-read only supplies the ownership lock's key, and a + // transfer that rolls back between the two would make this + // route redirect the caller to a replacement the same daemon + // is about to delete. It is re-read under the lock below. + suppressWorktreeContextRestore = !( + sidecarBeforeRestore.state === 'valid' && + sidecarBeforeRestore.session.workspaceCwd === undefined + ); + } + // The ownership validation below runs for every non-live + // restore, and the reset route is public, so the lock cannot be + // Channel-only: a source-less restore would otherwise attest + // exclusive ownership while a transfer moves it. Legacy + // sidecars (no recorded workspace) validate on the legacy path + // and stay lock-free. isPart4AWorktreeRestore = sidecarBeforeRestore.state === 'valid' && sidecarBeforeRestore.session.workspaceCwd !== undefined; - suppressWorktreeContextRestore = !( - sidecarBeforeRestore.state === 'valid' && - sidecarBeforeRestore.session.workspaceCwd === undefined - ); + if ( + isPart4AWorktreeRestore && + sidecarBeforeRestore.state === 'valid' + ) { + // Best-effort canonical key for the ownership lock: the + // reset route keys by the fully validated realpath, which + // agrees whenever the checkout still exists. A missing + // checkout falls back to the raw sidecar path and fails + // closed later in the restore's own validation. + const recordedPath = sidecarBeforeRestore.session.worktreePath; + try { + part4AWorktreeKey = fs.realpathSync(recordedPath); + } catch { + part4AWorktreeKey = recordedPath; + } + } } else { suppressWorktreeContextRestore = isChannelRestore; } @@ -3900,11 +3965,33 @@ export function registerSessionRoutes( undefined && runtime.bridge.discardDeferredRestoreAskUserQuestionPrompt !== undefined; - if (deferRestoreAskUserQuestionPrompt && isPart4AWorktreeRestore) { - releaseWorktreeRestore = await acquireWorktreeRestore( + if (isPart4AWorktreeRestore && part4AWorktreeKey !== undefined) { + releaseWorktreeRestore = await acquireWorktreeOwnershipOp( runtime.bridge, - sessionId, + part4AWorktreeKey, ); + if (isChannelRestore) { + // A reset moved this session's worktree ownership to a + // replacement: never restore the superseded session — tell + // the caller where the ownership went so it can redirect (and + // self-heal its registry). Decided on the locked read, so a + // transfer that rolled back while this request waited cannot + // make it redirect to a replacement that no longer exists. + const sidecarUnderLock = await readWorktreeSessionStrict( + sessionService.getWorktreeSessionPath( + restoredStorageSessionId, + ), + ); + if ( + sidecarUnderLock.state === 'valid' && + sidecarUnderLock.session.supersededBy !== undefined + ) { + throw new WorktreeSessionSupersededError( + restoredStorageSessionId, + sidecarUnderLock.session.supersededBy, + ); + } + } } assertRuntimeGenerationOpen?.(); if (isInternalWorkspaceRuntime(runtime)) { @@ -4187,6 +4274,14 @@ export function registerSessionRoutes( throw new Error('Worktree sidecar is missing or invalid'); } const sidecar = sidecarResult.session; + // Defense in depth alongside the pre-load check: a superseded + // session is never restored, whatever path raced us here. + if (sidecar.supersededBy !== undefined) { + throw new WorktreeSessionSupersededError( + restoredStorageSessionId, + sidecar.supersededBy, + ); + } const realWorkspace = fs.realpathSync(workspaceCwd); if (sidecar.workspaceCwd !== undefined) { let realSidecarWorkspace: string; @@ -4258,6 +4353,48 @@ export function registerSessionRoutes( marker.state !== 'valid' || marker.sessionId !== restoredStorageSessionId ) { + // Interrupted-transfer classification: this session's sidecar + // names the session it supersedes, the old session's sidecar + // agrees, and the marker never moved (or was cleaned). The + // transfer crashed between the sidecar rewrite and the + // flip; the repair is retrying the reset. + if ( + sidecar.supersedes !== undefined && + (marker.state === 'missing' || + (marker.state === 'valid' && + marker.sessionId === sidecar.supersedes)) + ) { + const oldSidecar = await readWorktreeSessionStrict( + createWorkspaceRuntimeSessionService( + runtime, + ).getWorktreeSessionPath(sidecar.supersedes), + ); + if ( + oldSidecar.state === 'valid' && + oldSidecar.session.supersededBy === restoredStorageSessionId + ) { + // The other side of this handoff is the SUPERSEDED + // session, so it must not ride the wire as + // `replacementSessionId`: that key tells a caller to load + // the id it carries, and the session being restored here + // already is the replacement. The old id is diagnostic. + daemonLog?.warn( + 'worktree restore found an interrupted reset', + { + sessionId: restoredStorageSessionId, + supersedesSessionId: sidecar.supersedes, + }, + ); + throw new WorktreeResetInterruptedError( + restoredStorageSessionId, + ); + } + } + if (marker.state === 'missing') { + throw new WorktreeMarkerMissingError( + restoredStorageSessionId, + ); + } throw new Error('Worktree marker ownership is invalid'); } const worktree = { @@ -4265,31 +4402,48 @@ export function registerSessionRoutes( path: realTarget, branch: sidecar.worktreeBranch, }; + // A parked deferred restore prompt reads inactive here by + // design (the bridge's restore response excludes it), so the + // deferred shape still reaches the relocating branch and earns + // its attestation. A genuinely active session refuses the + // relocation instead of being moved under its prompt. + // + // A restore that never asked for the deferral still reports an + // active prompt when `restoreAskUserQuestion` is on: the bridge + // fires the re-hung question during the cold restore, and that + // response carries no `currentCwd`. Relocating it is impossible + // (`changeSessionCwd` chains onto the prompt queue and refuses + // while a prompt is live), and failing closed would kill the + // session the caller just recovered — inside a checkout a + // non-suppressed restore lets the child place itself in. Keep + // the pre-4B outcome for that one shape: worktree metadata + // without the attestation the route cannot earn. const hasUnlocatedRestoredPrompt = session.hasActivePrompt && !session.attached && - session.currentCwd === undefined; + session.currentCwd === undefined && + !deferRestoreAskUserQuestionPrompt; if (hasUnlocatedRestoredPrompt) { runtime.bridge.setSessionWorktree(sessionId, worktree); session.worktree = worktree; - } else if (session.hasActivePrompt) { - if (session.currentCwd !== realTarget) { - throw new Error('Active session is outside its worktree'); - } } else { - const changed = await runtime.bridge.changeSessionCwd( - sessionId, - { - path: realTarget, - allowedRoots: candidateRoots, - }, - ); - if (changed.newCwd !== realTarget) { - throw new Error('Worktree relocation was rejected'); + if (session.hasActivePrompt) { + if (session.currentCwd !== realTarget) { + throw new Error('Active session is outside its worktree'); + } + } else { + const changed = await runtime.bridge.changeSessionCwd( + sessionId, + { + path: realTarget, + allowedRoots: candidateRoots, + }, + ); + if (changed.newCwd !== realTarget) { + throw new Error('Worktree relocation was rejected'); + } + session.currentCwd = changed.newCwd; } - session.currentCwd = changed.newCwd; - } - if (!hasUnlocatedRestoredPrompt) { runtime.bridge.setSessionWorktree(sessionId, worktree); assertRuntimeGenerationOpen?.(); session.worktree = worktree; @@ -4386,6 +4540,670 @@ export function registerSessionRoutes( app.post('/session/:id/load', mutate(), restoreSessionHandler('load')); app.post('/session/:id/resume', mutate(), restoreSessionHandler('resume')); + // ── Worktree reset (ownership transfer) ────────────────────────── + // POST /session/:id/worktree-reset moves a worktree session's checkout + // ownership to a fresh replacement session: spawn in the root workspace, + // relocate into the checkout, link the sidecar pair (supersedes / + // supersededBy), flip the marker, then attest persisted-v1. The transfer + // holds the worktree-keyed ownership lock for its whole duration, so a + // restore can never pass its ownership validation mid-transfer, and two + // resets for one checkout serialize. See + // docs/design/channel-named-sessions-part4b.md for the full protocol. + const resolveWorktreeOwnershipTarget = async ( + runtime: WorkspaceRuntime, + workspaceCwd: string, + sidecar: WorktreeSession, + sessionId: string, + ): Promise<{ realTarget: string; candidateRoots: string[] }> => { + // Every refusal here is the typed invalid-state 409: the specific + // reason goes to the daemon log, never to the wire. Returns the error + // so call sites `throw invalidState(...)` explicitly (definite- + // assignment analysis does not follow never-returning calls in catch). + const invalidState = (reason: string): WorktreeResetInvalidStateError => { + daemonLog?.warn('worktree reset rejected sidecar state', { + sessionId, + reason, + }); + return new WorktreeResetInvalidStateError(sessionId); + }; + const realWorkspace = fs.realpathSync(workspaceCwd); + if (sidecar.workspaceCwd !== undefined) { + let realSidecarWorkspace: string; + try { + realSidecarWorkspace = fs.realpathSync(sidecar.workspaceCwd); + } catch { + throw invalidState('sidecar workspace is missing or inaccessible'); + } + if (realSidecarWorkspace !== realWorkspace) { + throw invalidState('sidecar belongs to another workspace'); + } + } + const workspaceRoots = [realWorkspace]; + let repoTop: string | null = null; + try { + repoTop = await new GitWorktreeService(realWorkspace).getRepoTopLevel(); + } catch { + // A missing repository makes containment validation fail below. + } + if (repoTop && repoTop !== realWorkspace) { + workspaceRoots.push(fs.realpathSync(repoTop)); + } + let realOriginalCwd: string; + try { + realOriginalCwd = fs.realpathSync(sidecar.originalCwd); + } catch { + throw invalidState('sidecar original cwd is missing or inaccessible'); + } + if (!workspaceRoots.includes(realOriginalCwd)) { + throw invalidState('sidecar original cwd is outside the accepted roots'); + } + const candidateRoots = workspaceRoots.map((root) => + path.join(root, '.qwen', 'worktrees'), + ); + let realTarget: string; + try { + realTarget = fs.realpathSync(sidecar.worktreePath); + } catch { + throw invalidState('worktree checkout is missing or inaccessible'); + } + const contained = candidateRoots.some((root) => { + try { + const realRoot = fs.realpathSync(root); + const relative = path.relative(realRoot, realTarget); + return ( + relative.length > 0 && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ); + } catch { + return false; + } + }); + if (!contained) { + throw invalidState('worktree path failed containment'); + } + return { realTarget, candidateRoots }; + }; + + const worktreeResetHandler = async ( + req: Request, + res: Response, + ): Promise => { + const route = 'POST /session/:id/worktree-reset'; + const sessionId = requireSessionId(req, res); + if (!sessionId) return; + if (parseVirtualSubagentSessionId(sessionId)) { + res.status(400).json({ + error: 'Virtual subagent sessions do not support worktree reset', + code: 'unsupported_action', + sessionId, + }); + return; + } + const body = safeBody(req); + let resolvedRuntime: + | { runtime: WorkspaceRuntime; workspaceCwd: string } + | undefined; + try { + resolvedRuntime = await resolveRuntimeForSessionRestore( + body, + res, + route, + sessionId, + ); + } catch (err) { + if (sendConversationRuntimeError(res, err)) return; + sendBridgeError(res, err, { route, sessionId }); + return; + } + if (resolvedRuntime === undefined) return; + const { runtime, workspaceCwd } = resolvedRuntime; + const approvalMode = parseOptionalApprovalMode(body, res); + if (approvalMode === null) return; + const source = parseRequestedSessionSource(body, res); + if (source === null) return; + const modelServiceId = + typeof body['modelServiceId'] === 'string' + ? (body['modelServiceId'] as string) + : undefined; + const assertRuntimeGenerationOpen = + captureRuntimeGenerationAssertion(runtime); + try { + await runOwnerRuntimeActivity(runtime, () => + archiveCoordinator.runSharedMany([sessionId], async () => { + const sessionService = createWorkspaceRuntimeSessionService(runtime); + // The session record must exist (this resolver also normalizes + // legacy case-variant spellings to the stored id). + const storageSessionId = await resolveSessionIdForRestore( + sessionService, + sessionId, + ); + if (storageSessionId === undefined) { + throw new SessionNotFoundError(sessionId); + } + const oldSidecarPath = + sessionService.getWorktreeSessionPath(storageSessionId); + if ( + runtime.bridge.setSessionResetPending === undefined || + runtime.bridge.clearSessionResetPending === undefined || + runtime.bridge.clearSessionWorktree === undefined || + runtime.bridge.severSessionClients === undefined + ) { + throw new Error('Bridge does not support worktree reset'); + } + // Pre-read (unlocked) only to locate the worktree and classify the + // entry; every value that drives a decision is re-read under the + // ownership lock. + const preRead = await readWorktreeSessionStrict(oldSidecarPath); + if ( + preRead.state === 'missing' || + (preRead.state === 'valid' && + preRead.session.workspaceCwd === undefined) + ) { + throw new WorktreeResetUnsupportedError(sessionId); + } + if (preRead.state !== 'valid') { + daemonLog?.warn('worktree reset rejected invalid sidecar', { + sessionId, + reason: preRead.reason, + }); + throw new WorktreeResetInvalidStateError(sessionId); + } + const preTarget = await resolveWorktreeOwnershipTarget( + runtime, + workspaceCwd, + preRead.session, + sessionId, + ); + const releaseOwnership = await acquireWorktreeOwnershipOp( + runtime.bridge, + preTarget.realTarget, + ); + try { + // Re-read and re-validate under the lock; a concurrent edit + // between the pre-read and here must not drive the transfer. + const lockedRead = await readWorktreeSessionStrict(oldSidecarPath); + if ( + lockedRead.state === 'missing' || + (lockedRead.state === 'valid' && + lockedRead.session.workspaceCwd === undefined) + ) { + throw new WorktreeResetUnsupportedError(sessionId); + } + if (lockedRead.state !== 'valid') { + daemonLog?.warn('worktree reset rejected invalid sidecar', { + sessionId, + reason: lockedRead.reason, + }); + throw new WorktreeResetInvalidStateError(sessionId); + } + const oldSidecar = lockedRead.session; + const { realTarget, candidateRoots } = + await resolveWorktreeOwnershipTarget( + runtime, + workspaceCwd, + oldSidecar, + sessionId, + ); + if (realTarget !== preTarget.realTarget) { + daemonLog?.warn('worktree reset rejected: sidecar moved', { + sessionId, + }); + throw new WorktreeResetInvalidStateError(sessionId); + } + const readSummary = ( + id: string, + ): + | ReturnType + | undefined => { + try { + return runtime.bridge.getSessionSummary(id); + } catch (error) { + // A session unknown to the live bridge is dormant, and a + // dormant session is quiescent. + if (error instanceof SessionNotFoundError) return undefined; + throw error; + } + }; + runtime.bridge.setSessionResetPending(sessionId); + let barrierArmed = true; + try { + let effectiveOldSidecar = oldSidecar; + if (oldSidecar.supersededBy !== undefined) { + // ── Resume path ────────────────────────────────────── + // A previous transfer for this session crashed mid-flight. + // Classify by the marker: naming the old session means the + // flip never committed — roll the interrupted transfer back + // and proceed with a fresh replacement in the same request; + // naming the replacement means the flip committed — finish + // the severance and return it. An absent marker only reads + // as pre-commit while the replacement is dormant (below). + const replacementId = oldSidecar.supersededBy; + const newSidecarPath = + sessionService.getWorktreeSessionPath(replacementId); + const newRead = await readWorktreeSessionStrict(newSidecarPath); + // Sidecar-link agreement only. The marker is the authority on + // what committed, so neither branch may consult the + // replacement's transcript record: a crash can leave the + // linked pair with no record yet, and a retry that refuses on + // it never converges. + const linksAgree = + newRead.state === 'valid' && + newRead.session.supersedes === storageSessionId; + const marker = + await readWorktreeSessionMarkerStrict(realTarget); + if (marker.state === 'invalid') { + daemonLog?.warn('worktree reset resume rejected marker', { + sessionId, + reason: marker.reason, + }); + throw new WorktreeResetInvalidStateError(sessionId); + } + if ( + marker.state === 'missing' || + (marker.state === 'valid' && + marker.sessionId === storageSessionId) + ) { + // Pre-commit: a marker naming the old session proves S_new + // non-authoritative, so the destructive rollback is correct + // and the fresh transfer below recreates the marker through + // the hatch. + if (!linksAgree) { + daemonLog?.warn( + 'worktree reset resume found inconsistent links', + { sessionId, replacementSessionId: replacementId }, + ); + throw new WorktreeResetInvalidStateError(sessionId); + } + // An absent marker proves nothing on its own: a committed + // transfer whose git-excluded marker was later cleaned (a + // task-local `git clean -xdf`) reads exactly like the + // supervisor-cleaned pre-commit shape. A replacement still + // live on this daemon is that committed owner, so refuse + // before any destructive write instead of dismantling it + // and spawning a second writer. A dormant replacement is + // the genuine post-crash shape and still rolls back. + if ( + marker.state === 'missing' && + readSummary(replacementId) !== undefined + ) { + daemonLog?.warn( + 'worktree reset resume found a live replacement with no marker', + { sessionId, replacementSessionId: replacementId }, + ); + throw new WorktreeResetInvalidStateError(sessionId); + } + const { supersededBy: _drop, ...restoredOld } = oldSidecar; + // Confirm the removal before the sidecar writes destroy the + // links a retry classifies by. `false` is a documented + // outcome, not an error — a client attached, so + // `requireZeroAttaches` bailed — and it leaves the + // interrupted replacement live inside the checkout, where + // the fresh transfer below would spawn a second writer + // beside it with nothing on disk naming the survivor. + const removed = await runWithWorkspaceRuntimeStorage( + runtime, + () => + deleteDaemonSessionIfOrphan({ + sessionId: replacementId, + service: sessionService, + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ).catch(() => false); + if (!removed) { + daemonLog?.warn( + 'worktree reset could not remove the interrupted replacement', + { sessionId, replacementSessionId: replacementId }, + ); + throw new WorktreeResetInvalidStateError(sessionId); + } + // Undo in the reverse of the transfer's write order so a + // crash mid-rollback leaves the backward link alone — the + // shape a retry refuses for repair — instead of a forward + // link nothing scans for. + await clearWorktreeSession(newSidecarPath); + await writeWorktreeSession(oldSidecarPath, restoredOld); + daemonLog?.warn( + 'worktree reset rolled back an interrupted transfer', + { sessionId, replacementSessionId: replacementId }, + ); + effectiveOldSidecar = restoredOld; + } else if ( + marker.state === 'valid' && + marker.sessionId === replacementId + ) { + // Committed: the replacement is the authoritative owner and + // is never rolled back. Finish the transfer idempotently. + // The marker already moved ownership, so releasing the + // barrier stops being this request's to do: every refusal + // below leaves the superseded entry fenced for the retry + // that finishes the severance, exactly as the survivor + // branch does, instead of re-admitting a writer to a + // checkout the marker has handed to the replacement. + barrierArmed = false; + if (!linksAgree) { + daemonLog?.warn( + 'worktree reset resume found inconsistent links', + { sessionId, replacementSessionId: replacementId }, + ); + throw new WorktreeResetInvalidStateError(sessionId); + } + const newSummary = readSummary(replacementId); + if ( + newSummary && + (newSummary.hasActivePrompt || + (newSummary.pendingInteractionCount ?? 0) > 0) + ) { + throw new WorktreeResetActiveError(replacementId); + } + runtime.bridge.clearSessionWorktree(sessionId); + const severCompleted = + await runtime.bridge.severSessionClients(sessionId); + if (severCompleted) { + runtime.bridge.clearSessionResetPending(sessionId); + } else { + // The same hold the fresh transfer's sever step reports: + // keep the barrier armed on the surviving superseded + // entry instead of certifying a severance that did not + // happen. + daemonLog?.warn( + 'worktree reset left the superseded session live', + { sessionId, replacementSessionId: replacementId }, + ); + } + assertRuntimeGenerationOpen?.(); + daemonLog?.info( + 'worktree session reset resumed after interruption', + { sessionId, replacementSessionId: replacementId }, + ); + res.status(200).json({ + sessionId: replacementId, + workspaceCwd, + currentCwd: realTarget, + worktree: { + slug: oldSidecar.slug, + path: realTarget, + branch: oldSidecar.worktreeBranch, + }, + worktreeState: 'persisted-v1', + attached: false, + ...(severCompleted ? {} : { supersededSessionLive: true }), + }); + return; + } else { + daemonLog?.warn( + 'worktree reset resume found a marker naming another session', + { sessionId }, + ); + throw new WorktreeResetInvalidStateError(sessionId); + } + } + + // ── Fresh transfer (also the fall-through after a pre-commit + // rollback) ───────────────────────────────────────────── + const oldSummary = readSummary(sessionId); + if ( + oldSummary && + (oldSummary.hasActivePrompt || + (oldSummary.pendingInteractionCount ?? 0) > 0) + ) { + throw new WorktreeResetActiveError(sessionId); + } + let spawnedNew: { sessionId: string } | undefined; + let newSidecarWritten = false; + let oldSupersededWritten = false; + let markerTransferred = false; + try { + assertRuntimeGenerationOpen?.(); + // 1. The replacement spawns in the root workspace with the + // same thread-scope and source metadata conventions as a + // fresh worktree creation, minus worktree creation. + const spawned = await runtime.bridge.spawnOrAttach({ + workspaceCwd, + modelServiceId, + sessionScope: 'thread', + ...(approvalMode !== undefined ? { approvalMode } : {}), + ...(source.sourceType !== undefined + ? { sourceType: source.sourceType } + : {}), + ...(source.sourceId !== undefined + ? { sourceId: source.sourceId } + : {}), + }); + spawnedNew = { sessionId: spawned.sessionId }; + // 2. Relocate into the checkout. + const changed = await runtime.bridge.changeSessionCwd( + spawned.sessionId, + { path: realTarget, allowedRoots: candidateRoots }, + ); + if (changed.newCwd !== realTarget) { + throw new Error('Worktree relocation was rejected'); + } + // 3. The old sidecar names its replacement first: that + // backward link is the only door into the resume + // classification above, so a crash between the two writes + // stays observable to a retry — it refuses the disagreeing + // pair for operator repair instead of spawning a second + // replacement and stranding the first under a dangling + // forward link nothing ever scans for. + await writeWorktreeSession(oldSidecarPath, { + ...effectiveOldSidecar, + supersededBy: spawned.sessionId, + }); + oldSupersededWritten = true; + // 4. The replacement's sidecar carries the forward link. + const newSidecarPath = sessionService.getWorktreeSessionPath( + spawned.sessionId, + ); + const { + supersededBy: _dropOld, + supersedes: _dropOldLink, + ...oldSidecarCore + } = effectiveOldSidecar; + await writeWorktreeSession(newSidecarPath, { + ...oldSidecarCore, + supersedes: storageSessionId, + }); + newSidecarWritten = true; + // The caller is gone — an expired `fetchWithTimeout` budget + // aborts the socket mid-transfer. Only the fresh-transfer 200 + // carries the bridge-minted owner registration, so a handover + // nobody receives leaves one no client can detach, and past + // the flip below it could not be undone anyway: refuse here + // and let the pre-commit rollback reap the replacement. Do + // not test `res.writable` — it is an own data property that + // stays `true` after a disconnect, unlike these two. + if (res.destroyed || res.socket?.writable === false) { + throw new Error( + 'Client disconnected before the worktree ownership flip', + ); + } + // 5. A prompt admitted in the check-to-arm window and still + // winding down aborts the transfer; after the flip it would + // write into a checkout whose ownership just moved. + const windingDown = readSummary(sessionId); + if ( + windingDown && + (windingDown.hasActivePrompt || + (windingDown.pendingInteractionCount ?? 0) > 0) + ) { + throw new WorktreeResetActiveError(sessionId); + } + const markerBeforeFlip = + await readWorktreeSessionMarkerStrict(realTarget); + try { + await transferWorktreeSessionMarkerOwner( + realTarget, + markerBeforeFlip.state === 'missing' + ? null + : storageSessionId, + spawned.sessionId, + ); + } catch (markerError) { + // Bounded typed failure: the core primitive's message can + // carry I/O detail; the daemon log gets it, the wire does + // not. The rollback below runs before this propagates. + daemonLog?.warn('worktree marker transfer failed', { + sessionId, + replacementSessionId: spawned.sessionId, + error: + markerError instanceof Error + ? markerError.message + : String(markerError), + }); + if (markerError instanceof WorktreeMarkerCommittedError) { + // The primitive's own post-commit tail failed with the + // marker already naming the replacement, so the flip did + // happen: never compensate backwards, and never report + // the invalid-state 409 whose contract is that this + // request changed nothing. A retry resumes the committed + // transfer idempotently. + markerTransferred = true; + barrierArmed = false; + throw new Error( + 'Worktree ownership moved to the replacement, but the marker commit did not finish cleanly; retry the reset to complete the transfer', + ); + } + throw new WorktreeResetInvalidStateError(sessionId); + } + markerTransferred = true; + // Ownership moved, so this request stops owning the barrier + // release: a completed severance clears it explicitly below, + // while a survivor's fence — or a failure anywhere in this + // post-flip tail — has to outlive the request that left it + // armed rather than re-admit a writer to a checkout the + // marker already handed to the replacement. + barrierArmed = false; + // 6. Attest the replacement, then bring the superseded + // session's runtime view in line with the disk: no worktree + // association, no attaches. + const newWorktree = { + slug: effectiveOldSidecar.slug, + path: realTarget, + branch: effectiveOldSidecar.worktreeBranch, + }; + runtime.bridge.setSessionWorktree( + spawned.sessionId, + newWorktree, + ); + assertRuntimeGenerationOpen?.(); + runtime.bridge.clearSessionWorktree(sessionId); + const severCompleted = + await runtime.bridge.severSessionClients(sessionId); + if (severCompleted) { + runtime.bridge.clearSessionResetPending(sessionId); + } else { + // The child holds background work, so the last detach's + // idle close was refused and the superseded session is + // still live inside the checkout whose ownership just + // moved. Keep the prompt barrier armed — it is the only + // fence left on that entry — and report the hold instead of + // certifying a severance that did not happen. Escalating to + // killSession is not an option: it turns a close error into + // a channel kill, and the replacement was spawned onto that + // same workspace-bound bridge. + daemonLog?.warn( + 'worktree reset left the superseded session live', + { + sessionId, + replacementSessionId: spawned.sessionId, + }, + ); + } + daemonLog?.info('worktree session reset', { + sessionId, + replacementSessionId: spawned.sessionId, + }); + res.status(200).json({ + ...spawned, + currentCwd: realTarget, + worktree: newWorktree, + worktreeState: 'persisted-v1', + ...(severCompleted ? {} : { supersededSessionLive: true }), + }); + } catch (transferError) { + if (markerTransferred) { + // Past the point of no return: the checkout belongs to the + // replacement. The superseded redirect heals the caller's + // registry on its next selection; never compensate + // backwards here. + daemonLog?.warn( + 'worktree reset failed after the ownership flip', + { + sessionId, + replacementSessionId: spawnedNew?.sessionId, + error: + transferError instanceof Error + ? transferError.message + : String(transferError), + }, + ); + throw transferError; + } + // Roll back to the pre-commit shape: the old session keeps + // ownership and a retry starts a fresh transfer. The removal + // goes first and has to be confirmed — `false` means a client + // attached, so the replacement is still live inside the + // checkout. Unwriting the links anyway would leave that + // survivor with nothing on disk naming it and a retry would + // spawn a second writer beside it; keeping the pair makes the + // retry take the resume path above and refuse for repair. + if (spawnedNew) { + const orphanSessionId = spawnedNew.sessionId; + const removed = await runWithWorkspaceRuntimeStorage( + runtime, + () => + deleteDaemonSessionIfOrphan({ + sessionId: orphanSessionId, + service: sessionService, + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ).catch(() => false); + if (!removed) { + daemonLog?.warn( + 'worktree reset could not remove the spawned replacement', + { sessionId, replacementSessionId: orphanSessionId }, + ); + throw transferError; + } + } + // Undo in the reverse of the write order above so a crash + // mid-rollback leaves the backward link alone — the shape a + // retry refuses for repair — instead of a forward link + // nothing scans for. + if (newSidecarWritten && spawnedNew) { + await clearWorktreeSession( + sessionService.getWorktreeSessionPath(spawnedNew.sessionId), + ); + } + if (oldSupersededWritten) { + await writeWorktreeSession( + oldSidecarPath, + effectiveOldSidecar, + ); + } + throw transferError; + } + } finally { + if (barrierArmed) { + runtime.bridge.clearSessionResetPending(sessionId); + } + } + } finally { + releaseOwnership(); + } + }), + ); + } catch (err) { + if (!res.headersSent) { + sendBridgeError(res, err, { route, sessionId }); + } + } + }; + app.post('/session/:id/worktree-reset', mutate(), worktreeResetHandler); + app.get('/session/:id/subagents/:subagentRef', async (req, res) => { const route = 'GET /session/:id/subagents/:subagentRef'; const sessionId = requireSessionId(req, res); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 6a05be9b7f9..aaffac78a50 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -728,6 +728,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'workspace_session_live_state', 'workspace_session_metadata', 'session_worktree_persistence_v1', + 'session_worktree_reset_v1', // Baseline (always advertised) — presence means the `/voice/stream` // endpoint exists; the WS errors if no voice model is configured. 'voice_transcribe', @@ -806,6 +807,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'workspace_session_live_state' && f !== 'workspace_session_metadata' && f !== 'session_worktree_persistence_v1' && + f !== 'session_worktree_reset_v1' && f !== 'voice_transcribe' && f !== 'realtime_voice', ), @@ -872,6 +874,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'workspace_session_live_state', 'workspace_session_metadata', 'session_worktree_persistence_v1', + 'session_worktree_reset_v1', 'workspace_qualified_acp', 'client_mcp_over_ws', 'cdp_tunnel_over_ws', @@ -12538,7 +12541,7 @@ describe('createServeApp', () => { }); it.each(['live', 'probe-error'] as const)( - 'preserves a worktree when post-spawn cleanup is inconclusive (%s)', + 'removes an unowned worktree when post-spawn cleanup is inconclusive (%s)', async (cleanupState) => { const generationGuard = createWorkspaceGenerationGuard(); const removed: string[] = []; @@ -12609,7 +12612,10 @@ describe('createServeApp', () => { opts: { requireZeroAttaches: true }, }, ]); - expect(removed).toEqual([]); + // Relocation never ran, so nothing can own the checkout: the + // worktree is removed even though the session cleanup outcome was + // inconclusive (live summary / probe error). + expect(removed).toEqual(['my-task']); } finally { mockWt.impl = undefined; } @@ -16685,7 +16691,7 @@ describe('createServeApp', () => { } }); - it('keeps an unlocated restored prompt compatible without attesting isolation', async () => { + it('fails closed for an unlocated active restored prompt instead of attesting isolation', async () => { const worktreePath = `${WS_BOUND}/.qwen/worktrees/my-task`; const bridge = fakeBridge({ loadImpl: async (req) => ({ @@ -16725,6 +16731,76 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ cwd: WS_BOUND }); + // An active restored prompt whose cwd the bridge never reported is + // unlocated: the restore must fail closed rather than assigning the + // worktree without relocation or attestation. + expect(res.status).toBe(500); + expect(res.body.error).toBe('Active session is outside its worktree'); + expect(bridge.changeSessionCwdCalls).toHaveLength(0); + expect(bridge.setSessionWorktreeCalls).toHaveLength(0); + expect(bridge.killCalls).toEqual([ + { + sessionId: 'wt-session', + opts: { requireZeroAttaches: true }, + }, + ]); + } finally { + mockWt.readSidecar = undefined; + mockWt.readMarker = undefined; + mockWt.realpath = undefined; + readCreationMetadata.mockRestore(); + } + }); + + it('keeps a fired non-Channel restore prompt unattested instead of killing the session', async () => { + const worktreePath = `${WS_BOUND}/.qwen/worktrees/my-task`; + const bridge = fakeBridge({ + loadImpl: async (req) => ({ + sessionId: req.sessionId, + workspaceCwd: req.workspaceCwd, + attached: false, + clientId: 'restored-client', + state: {}, + hasActivePrompt: true, + }), + }); + // No persisted source and none requested, so this is not a Channel + // restore and the route asks the bridge for no deferral. With + // `restoreAskUserQuestion` enabled the bridge then FIRES the re-hung + // question during the cold restore, and that response reports an + // active prompt while carrying no `currentCwd` at all. + const readCreationMetadata = vi + .spyOn(SessionService.prototype, 'readCreationMetadata') + .mockResolvedValue({}); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.realpath = (p) => p; + mockWt.readMarker = () => + Promise.resolve({ state: 'valid', sessionId: 'wt-session' }); + mockWt.readSidecar = () => + Promise.resolve({ + slug: 'my-task', + worktreePath, + worktreeBranch: 'worktree-my-task', + originalCwd: WS_BOUND, + workspaceCwd: WS_BOUND, + originalBranch: 'main', + originalHeadCommit: 'abc123', + }); + + try { + const res = await request(app) + .post('/session/wt-session/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: WS_BOUND }); + + // The session is inside its checkout — a non-suppressed restore lets + // the child restore its own worktree context — and relocating it is + // impossible while its prompt is live. It keeps the worktree metadata + // without the attestation the route cannot earn, and survives. expect(res.status).toBe(200); expect(res.body.worktree).toEqual({ slug: 'my-task', @@ -16732,11 +16808,12 @@ describe('createServeApp', () => { branch: 'worktree-my-task', }); expect(res.body.worktreeState).toBeUndefined(); - expect(bridge.loadCalls[0]).toMatchObject({ - suppressWorktreeContextRestore: true, - }); + expect(bridge.loadCalls[0]).not.toHaveProperty( + 'suppressWorktreeContextRestore', + ); expect(bridge.changeSessionCwdCalls).toHaveLength(0); expect(bridge.setSessionWorktreeCalls).toHaveLength(1); + expect(bridge.killCalls).toHaveLength(0); } finally { mockWt.readSidecar = undefined; mockWt.readMarker = undefined; @@ -17195,7 +17272,8 @@ describe('createServeApp', () => { second, ]); - expect(firstResponse.status).toBe(500); + expect(firstResponse.status).toBe(409); + expect(firstResponse.body.code).toBe('worktree_marker_missing'); expect(secondResponse.status).toBe(200); expect(bridge.loadCalls).toHaveLength(2); expect(bridge.changeSessionCwdCalls).toHaveLength(1); @@ -17562,8 +17640,16 @@ describe('createServeApp', () => { undefined, { workspaceRegistry: createWorkspaceRegistry([runtime]) }, ); + // The restore pre-reads the sidecar to pick its worktree-ownership lock + // key, so the generation must close on the post-load integrity read: + // the bridge has registered the cold session by then, which is what + // this cleanup path is about. + let sidecarReads = 0; mockWt.readSidecarStrict = async () => { - generationGuard.close(); + sidecarReads++; + if (sidecarReads === 2) { + generationGuard.close(); + } return { state: 'missing' }; }; @@ -17694,7 +17780,8 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ cwd: WS_BOUND }); - expect(res.status).toBe(500); + expect(res.status).toBe(409); + expect(res.body.code).toBe('worktree_marker_missing'); expect(bridge.detachCalls).toEqual([ { sessionId: 'wt-session', clientId: 'attached-client' }, ]); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 195b1abad5f..dae3bcc801f 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -47,6 +47,7 @@ import { SessionLimitExceededError, SessionNotArchivedError, SessionNotFoundError, + SessionResetPendingError, SessionShellClientRequiredError, SessionShellDisabledError, WorkspaceInitConflictError, @@ -67,6 +68,14 @@ import { import { DaemonDrainingError } from './session-archive.js'; import { StandaloneSessionServiceError } from '../conversations/standalone-session-service.js'; import { ConversationRuntimeOwnershipError } from '../conversations/conversation-runtime-errors.js'; +import { + WorktreeMarkerMissingError, + WorktreeResetActiveError, + WorktreeResetInterruptedError, + WorktreeResetInvalidStateError, + WorktreeResetUnsupportedError, + WorktreeSessionSupersededError, +} from './worktree-reset-errors.js'; export type BridgeErrorContext = { route?: string; @@ -570,6 +579,67 @@ export function sendBridgeError( }); return; } + if (err instanceof SessionResetPendingError) { + // The prompt barrier the worktree-reset route arms for the superseded + // session. 409 + the shared reset code: callers that know the reset flow + // (Channel worker) retry the task; non-Channel callers get a bounded + // message with no path or transfer internals. + res.status(409).json({ + error: err.message, + code: 'worktree_reset_active', + sessionId: err.sessionId, + }); + return; + } + if (err instanceof WorktreeSessionSupersededError) { + res.status(409).json({ + error: err.message, + code: 'worktree_session_superseded', + sessionId: err.sessionId, + replacementSessionId: err.replacementSessionId, + }); + return; + } + if (err instanceof WorktreeMarkerMissingError) { + res.status(409).json({ + error: err.message, + code: 'worktree_marker_missing', + sessionId: err.sessionId, + }); + return; + } + if (err instanceof WorktreeResetInterruptedError) { + res.status(409).json({ + error: err.message, + code: 'worktree_reset_interrupted', + sessionId: err.sessionId, + }); + return; + } + if (err instanceof WorktreeResetActiveError) { + res.status(409).json({ + error: err.message, + code: 'worktree_reset_active', + sessionId: err.sessionId, + }); + return; + } + if (err instanceof WorktreeResetUnsupportedError) { + res.status(409).json({ + error: err.message, + code: 'worktree_reset_unsupported', + sessionId: err.sessionId, + }); + return; + } + if (err instanceof WorktreeResetInvalidStateError) { + res.status(409).json({ + error: err.message, + code: 'worktree_reset_invalid_state', + sessionId: err.sessionId, + }); + return; + } if (err instanceof TrustGateError) { // Trust-folder rejection. 403 because the workspace's trust posture // forbids the privileged mode. diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index e41622d3370..c407b6765aa 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(68); + expect(registered).toHaveLength(69); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 9c2c472efdf..b7aabe2e18e 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -1090,17 +1090,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 68 unique routes with the audited 66/2 attribution split', () => { + it('contains 69 unique routes with the audited 67/2 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(68); - expect(new Set(keys).size).toBe(68); + expect(keys).toHaveLength(69); + expect(new Set(keys).size).toBe(69); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(66); + ).toHaveLength(67); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 10a29565e9b..b25dff252fd 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -61,6 +61,12 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'POST /session/:id/resume', }, + { + method: 'POST', + path: '/session/:id/worktree-reset', + attribution: 'handler_resolved', + route: 'POST /session/:id/worktree-reset', + }, { method: 'POST', path: '/session/:id/branch', diff --git a/packages/cli/src/serve/server/worktree-reset-errors.ts b/packages/cli/src/serve/server/worktree-reset-errors.ts new file mode 100644 index 00000000000..c739d26ac26 --- /dev/null +++ b/packages/cli/src/serve/server/worktree-reset-errors.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Typed errors for the Channel worktree ownership-transfer protocol + * (Part 4B): the reset route and the restore route's superseded / marker + * classifications. Each maps to a bounded 409 body in `sendBridgeError` — + * no paths, no stack traces, only the session ids a caller needs to + * redirect or retry. + */ + +/** Restore of a session whose worktree ownership moved to a replacement. */ +export class WorktreeSessionSupersededError extends Error { + readonly sessionId: string; + readonly replacementSessionId: string; + constructor(sessionId: string, replacementSessionId: string) { + super( + `Session ${sessionId} was superseded by a worktree reset; restore the replacement session instead`, + ); + this.name = 'WorktreeSessionSupersededError'; + this.sessionId = sessionId; + this.replacementSessionId = replacementSessionId; + } +} + +/** + * Channel restore reached the ownership check and the marker is absent. + * Distinct from corruption: the checkout may have been cleaned. A restore + * retry cannot repair it — no restore path writes a marker — so the caller + * must reset the task, whose transfer recreates the marker. An absent marker + * whose `supersedes`/`supersededBy` links agree classifies as + * {@link WorktreeResetInterruptedError} instead, before this one. + */ +export class WorktreeMarkerMissingError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super( + `Worktree ownership marker for session ${sessionId} is missing; reset the task to recreate it`, + ); + this.name = 'WorktreeMarkerMissingError'; + this.sessionId = sessionId; + } +} + +/** + * A restore reached an unfinished handoff: the sidecar pair links the two + * sessions but the checkout marker never moved onto the replacement. The + * repair depends on which session the caller restored — retrying the reset + * against the *superseded* session rolls the pre-commit shape back or + * finishes it, while this shape, reported for the replacement, is not + * repairable that way (the reset route resumes a committed transfer as a + * no-op) and needs operator repair. Carries no replacement id: this is + * thrown for the replacement, so the id it would name is already the + * caller's own. + */ +export class WorktreeResetInterruptedError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super( + 'A previous worktree reset was interrupted; retry the reset against the superseded session to finish or roll it back', + ); + this.name = 'WorktreeResetInterruptedError'; + this.sessionId = sessionId; + } +} + +/** Reset refused because a session involved in the transfer is busy. */ +export class WorktreeResetActiveError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super( + `Session ${sessionId} has a prompt in progress; wait for it to finish before resetting`, + ); + this.name = 'WorktreeResetActiveError'; + this.sessionId = sessionId; + } +} + +/** Reset target is not a persisted worktree session. */ +export class WorktreeResetUnsupportedError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super(`Session ${sessionId} is not a worktree session`); + this.name = 'WorktreeResetUnsupportedError'; + this.sessionId = sessionId; + } +} + +/** + * Reset refused on stale, foreign, tampered, containment-failing, or + * ambiguous ownership state. Non-destructive for what this request started: + * the route rolls a partial transfer this request began back before this + * reaches the caller, while the resume path's fail-closed branches — an + * invalid marker, an inconsistent supersede link pair, an ambiguous marker + * owner — leave the pre-existing interrupted state untouched for operator + * repair. The message is deliberately generic — the specific reason goes to + * the daemon log, never to the wire (no paths, no underlying I/O error text). + */ +export class WorktreeResetInvalidStateError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super( + `Session ${sessionId}'s worktree ownership state is invalid; the reset made no destructive change`, + ); + this.name = 'WorktreeResetInvalidStateError'; + this.sessionId = sessionId; + } +} diff --git a/packages/core/src/services/git-worktree-service-marker.test.ts b/packages/core/src/services/git-worktree-service-marker.test.ts index 4c55c1d0eb4..bed59c32d76 100644 --- a/packages/core/src/services/git-worktree-service-marker.test.ts +++ b/packages/core/src/services/git-worktree-service-marker.test.ts @@ -5,6 +5,7 @@ */ import * as fs from 'node:fs/promises'; +import { closeSync } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { execFile } from 'node:child_process'; @@ -13,6 +14,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createWorktreeSessionMarkerExclusive, readWorktreeSessionMarkerStrict, + readWorktreeSessionMarkerStrictSync, + transferWorktreeSessionMarkerOwner, + WorktreeMarkerCommittedError, WORKTREE_SESSION_FILE, } from './gitWorktreeService.js'; @@ -40,7 +44,7 @@ describe('daemon worktree session markers', () => { await createWorktreeSessionMarkerExclusive(dir, 'session-123'); - await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toEqual({ + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ state: 'valid', sessionId: 'session-123', }); @@ -153,10 +157,12 @@ describe('daemon worktree session markers', () => { await probe.close(); try { - await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toEqual({ - state: 'valid', - sessionId: 'owner', - }); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject( + { + state: 'valid', + sessionId: 'owner', + }, + ); expect(readFileSpy).not.toHaveBeenCalled(); expect(readSpy).toHaveBeenCalledWith(expect.any(Buffer), 0, 513, 0); } finally { @@ -191,10 +197,12 @@ describe('daemon worktree session markers', () => { .mockImplementation(shortRead as typeof prototype.read); try { - await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toEqual({ - state: 'valid', - sessionId: 'session-owner', - }); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject( + { + state: 'valid', + sessionId: 'session-owner', + }, + ); expect(readSpy.mock.calls.length).toBeGreaterThan(1); } finally { readSpy.mockRestore(); @@ -212,4 +220,525 @@ describe('daemon worktree session markers', () => { }); } }); + + it('leaves no file behind when the write fails after creation', async () => { + const dir = await tempDir(); + const probe = await fs.open(path.join(dir, 'probe'), 'w'); + const prototype = Object.getPrototypeOf(probe) as typeof probe; + await probe.close(); + const writeSpy = vi + .spyOn(prototype, 'writeFile') + .mockRejectedValue(new Error('injected write failure')); + + try { + await expect( + createWorktreeSessionMarkerExclusive(dir, 'session-123'), + ).rejects.toThrow('injected write failure'); + // The failed create must not wedge the path with an EEXIST-raising + // empty file: the strict reader sees a clean absence. + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toEqual({ + state: 'missing', + }); + const siblings = await fs.readdir(dir); + expect(siblings.filter((name) => name.endsWith('.tmp'))).toEqual([]); + } finally { + writeSpy.mockRestore(); + } + }); + + it('never lets the marker path exist before its owner is fsynced', async () => { + const dir = await tempDir(); + const markerPath = path.join(dir, WORKTREE_SESSION_FILE); + const probe = await fs.open(path.join(dir, 'probe'), 'w'); + const prototype = Object.getPrototypeOf(probe) as Pick< + typeof probe, + 'stat' | 'writeFile' | 'sync' + >; + const originalStat = prototype.stat; + const originalWriteFile = prototype.writeFile; + const originalSync = prototype.sync; + await probe.close(); + + // A crash between the marker path appearing and its owner landing is the + // unrecoverable shape: a 0-byte `.qwen-session` reads as `invalid`, which + // no route repairs and every retried reset refuses. Sample the path at + // every await the create yields on and require each sample to be either + // absent or already holding the whole owner. + const markerSizes: Array = []; + const sample = async (): Promise => { + try { + markerSizes.push((await fs.lstat(markerPath)).size); + } catch { + markerSizes.push('absent'); + } + }; + const statSpy = vi + .spyOn(prototype, 'stat') + .mockImplementation(async function (this: typeof prototype) { + await sample(); + const stats = await originalStat.call(this); + await sample(); + return stats; + }); + const writeSpy = vi + .spyOn(prototype, 'writeFile') + .mockImplementation(async function ( + this: typeof prototype, + ...args: Parameters + ) { + await sample(); + await originalWriteFile.apply(this, args); + await sample(); + }); + const syncSpy = vi + .spyOn(prototype, 'sync') + .mockImplementation(async function (this: typeof prototype) { + await sample(); + await originalSync.call(this); + await sample(); + }); + + try { + await createWorktreeSessionMarkerExclusive(dir, 'session-123'); + } finally { + statSpy.mockRestore(); + writeSpy.mockRestore(); + syncSpy.mockRestore(); + } + + expect(markerSizes.length).toBeGreaterThan(0); + // Either absent or already holding the whole owner: a 0-byte or partially + // written `.qwen-session` reads as `invalid`, which no route repairs. + const ownerBytes = Buffer.byteLength('session-123'); + expect( + markerSizes.filter((size) => size !== 'absent' && size !== ownerBytes), + ).toEqual([]); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ + state: 'valid', + sessionId: 'session-123', + }); + const siblings = await fs.readdir(dir); + expect(siblings.filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('lets exactly one of two concurrent exclusive creates win', async () => { + const dir = await tempDir(); + + const results = await Promise.allSettled([ + createWorktreeSessionMarkerExclusive(dir, 'session-a'), + createWorktreeSessionMarkerExclusive(dir, 'session-b'), + ]); + + // The publishing link is the compare-and-swap: the loser must fail rather + // than overwrite the owner the winner just committed. + const winners = results.filter((result) => result.status === 'fulfilled'); + expect(winners).toHaveLength(1); + const winner = winners[0] === results[0] ? 'session-a' : 'session-b'; + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ + state: 'valid', + sessionId: winner, + }); + const siblings = await fs.readdir(dir); + expect(siblings.filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('does not remove a foreign file swapped in during the write window', async () => { + const dir = await tempDir(); + const probe = await fs.open(path.join(dir, 'probe'), 'w'); + const prototype = Object.getPrototypeOf(probe) as Pick< + typeof probe, + 'writeFile' + >; + const originalWriteFile = prototype.writeFile; + await probe.close(); + // Another writer swaps the staged file while our write is in flight, so + // the identity check fires with a foreign inode now occupying the path. + let stagedPath = ''; + const writeSpy = vi + .spyOn(prototype, 'writeFile') + .mockImplementation(async function (this: typeof prototype) { + const staged = (await fs.readdir(dir)).find((name) => + name.endsWith('.tmp'), + ); + stagedPath = path.join(dir, staged as string); + await fs.unlink(stagedPath); + await fs.writeFile(stagedPath, 'foreign-owner'); + await originalWriteFile.call(this, 'session-123', 'utf8'); + }); + + try { + await expect( + createWorktreeSessionMarkerExclusive(dir, 'session-123'), + ).rejects.toThrow('Worktree session marker identity changed'); + // The cleanup must not delete the file the identity check proved is + // not ours, and a create that never published leaves no marker behind. + await expect(fs.readFile(stagedPath, 'utf8')).resolves.toBe( + 'foreign-owner', + ); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toEqual({ + state: 'missing', + }); + } finally { + writeSpy.mockRestore(); + } + }); + + it('reports a committed marker when the post-commit close rejects', async () => { + const createDir = await tempDir(); + const transferDir = await tempDir(); + const probe = await fs.open(path.join(createDir, 'probe'), 'w'); + const prototype = Object.getPrototypeOf(probe) as typeof probe; + await probe.close(); + const realStat = prototype.stat; + let statCalls = 0; + // A FileHandle's `close` is a per-instance property, so the seam is the + // prototype's `stat`: the marker create stats exactly twice (pre-write + // identity, post-write verification), and closing the raw fd under the + // second one makes production's own post-commit `handle.close()` reject + // with the marker already written, fsync'd and identity-verified. + const statSpy = vi + .spyOn(prototype, 'stat') + .mockImplementation(async function (this: typeof prototype) { + const stats = await realStat.call(this); + statCalls += 1; + if (statCalls % 2 === 0) closeSync(this.fd); + return stats; + }); + + const settle = (run: () => Promise): Promise => + run().then( + () => undefined, + (error: unknown) => error, + ); + let createError: unknown; + let transferError: unknown; + try { + createError = await settle(() => + createWorktreeSessionMarkerExclusive(createDir, 'session-new'), + ); + // The reset route's missing-marker hatch reaches the same tail through + // the transfer primitive, so the classification must propagate. + transferError = await settle(() => + transferWorktreeSessionMarkerOwner(transferDir, null, 'session-new'), + ); + } finally { + statSpy.mockRestore(); + } + + expect(statCalls).toBe(4); + for (const error of [createError, transferError]) { + expect(error).toBeInstanceOf(WorktreeMarkerCommittedError); + expect((error as WorktreeMarkerCommittedError).committedOwner).toBe( + 'session-new', + ); + expect((error as Error).cause).toMatchObject({ code: 'EBADF' }); + } + // The close rejection propagates with the marker intact: no cleanup of a + // valid file, so a caller that compensates on failure would dismantle a + // session the marker already names. + for (const dir of [createDir, transferDir]) { + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject( + { state: 'valid', sessionId: 'session-new' }, + ); + } + }); +}); + +describe('readWorktreeSessionMarkerStrictSync', () => { + const tempDirs: string[] = []; + + async function tempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-marker-')); + tempDirs.push(dir); + return dir; + } + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it('matches the async reader on missing, valid, and invalid markers', async () => { + const dir = await tempDir(); + expect(readWorktreeSessionMarkerStrictSync(dir)).toEqual({ + state: 'missing', + }); + + await createWorktreeSessionMarkerExclusive(dir, 'session-123'); + const syncResult = readWorktreeSessionMarkerStrictSync(dir); + expect(syncResult).toMatchObject({ + state: 'valid', + sessionId: 'session-123', + }); + const asyncResult = await readWorktreeSessionMarkerStrict(dir); + expect(syncResult).toEqual(asyncResult); + + const markerPath = path.join(dir, WORKTREE_SESSION_FILE); + await fs.unlink(markerPath); + const targetPath = path.join(dir, 'target'); + await fs.writeFile(targetPath, 'keep'); + await fs.symlink(targetPath, markerPath); + expect(readWorktreeSessionMarkerStrictSync(dir)).toMatchObject({ + state: 'invalid', + }); + + await fs.unlink(markerPath); + await fs.writeFile(markerPath, ' padded\n'); + expect(readWorktreeSessionMarkerStrictSync(dir)).toMatchObject({ + state: 'invalid', + reason: 'invalid marker owner', + }); + }); +}); + +describe('transferWorktreeSessionMarkerOwner', () => { + const tempDirs: string[] = []; + + async function tempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-marker-')); + tempDirs.push(dir); + return dir; + } + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it('moves an owned marker to the replacement owner', async () => { + const dir = await tempDir(); + await createWorktreeSessionMarkerExclusive(dir, 'session-old'); + + await transferWorktreeSessionMarkerOwner(dir, 'session-old', 'session-new'); + + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ + state: 'valid', + sessionId: 'session-new', + }); + // No transfer temp file lingers next to the marker. + const siblings = await fs.readdir(dir); + expect(siblings.filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('recreates a missing marker exclusively through the hatch', async () => { + const dir = await tempDir(); + + await transferWorktreeSessionMarkerOwner(dir, null, 'session-new'); + + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ + state: 'valid', + sessionId: 'session-new', + }); + }); + + it('refuses the hatch when a marker already exists', async () => { + const dir = await tempDir(); + await createWorktreeSessionMarkerExclusive(dir, 'session-old'); + + await expect( + transferWorktreeSessionMarkerOwner(dir, null, 'session-new'), + ).rejects.toBeDefined(); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ + state: 'valid', + sessionId: 'session-old', + }); + }); + + it('aborts when the opening read does not name the expected owner', async () => { + const dir = await tempDir(); + await createWorktreeSessionMarkerExclusive(dir, 'session-other'); + + await expect( + transferWorktreeSessionMarkerOwner(dir, 'session-old', 'session-new'), + ).rejects.toThrow('does not match'); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ + state: 'valid', + sessionId: 'session-other', + }); + }); + + it('aborts when the marker expected by the transfer is missing', async () => { + const dir = await tempDir(); + + await expect( + transferWorktreeSessionMarkerOwner(dir, 'session-old', 'session-new'), + ).rejects.toThrow('does not match'); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toEqual({ + state: 'missing', + }); + }); + + it('aborts on an invalid marker without touching it', async () => { + const dir = await tempDir(); + await fs.writeFile(path.join(dir, WORKTREE_SESSION_FILE), ' padded\n'); + + await expect( + transferWorktreeSessionMarkerOwner(dir, 'session-old', 'session-new'), + ).rejects.toThrow('Worktree marker is invalid'); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ + state: 'invalid', + }); + }); + + it('requires the new owner to differ from the expected owner', async () => { + const dir = await tempDir(); + await createWorktreeSessionMarkerExclusive(dir, 'session-old'); + + await expect( + transferWorktreeSessionMarkerOwner(dir, 'session-old', 'session-old'), + ).rejects.toThrow('distinct new owner'); + await expect(readWorktreeSessionMarkerStrict(dir)).resolves.toMatchObject({ + state: 'valid', + sessionId: 'session-old', + }); + }); + + it('leaves the marker excluded from git after a transfer', async () => { + const dir = await tempDir(); + const repo = path.join(dir, 'repo'); + const worktree = path.join(dir, 'worktree'); + await fs.mkdir(repo); + await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: repo }); + await execFileAsync('git', ['config', 'user.email', 'test@example.com'], { + cwd: repo, + }); + await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: repo }); + await execFileAsync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: repo, + }); + await fs.writeFile(path.join(repo, 'tracked.txt'), 'tracked'); + await execFileAsync('git', ['add', '.'], { cwd: repo }); + await execFileAsync( + 'git', + ['commit', '-q', '-m', 'initial', '--no-verify'], + { + cwd: repo, + }, + ); + await execFileAsync( + 'git', + ['worktree', 'add', '-q', '-b', 'task', worktree], + { + cwd: repo, + }, + ); + await createWorktreeSessionMarkerExclusive(worktree, 'session-old'); + + await transferWorktreeSessionMarkerOwner( + worktree, + 'session-old', + 'session-new', + ); + + const exclude = await fs.readFile( + path.join(repo, '.git', 'info', 'exclude'), + 'utf8', + ); + const rules = exclude.split(/\r?\n/); + expect(rules).toContain(WORKTREE_SESSION_FILE); + expect(rules).toContain(`${WORKTREE_SESSION_FILE}.*.tmp`); + await execFileAsync('git', ['add', '-A'], { cwd: worktree }); + const { stdout } = await execFileAsync( + 'git', + ['diff', '--cached', '--name-only'], + { cwd: worktree }, + ); + expect(stdout).toBe(''); + }); + + it.skipIf(process.geteuid === undefined)( + 'refuses a marker owned by a different uid without touching it', + async () => { + const dir = await tempDir(); + await createWorktreeSessionMarkerExclusive(dir, 'session-old'); + const markerPath = path.join(dir, WORKTREE_SESSION_FILE); + const before = await fs.lstat(markerPath); + + // Stands in for a marker a different unix account owns — a daemon that + // ran as root wrote it, or the worktree was restored from a backup + // taken under another uid. The transfer must refuse it outright rather + // than ride `atomicWriteFile`'s ownership-preserving in-place write. + // A test cannot chown without privileges, so the foreign uid is + // injected where the transfer observes it: the fstat behind the strict + // reader's `handle.stat()`. + const foreignUid = (process.geteuid?.() ?? 0) + 1000; + const probe = await fs.open(markerPath, 'r'); + const prototype = Object.getPrototypeOf(probe) as Pick< + typeof probe, + 'stat' + >; + const originalStat = prototype.stat; + await probe.close(); + const statSpy = vi + .spyOn(prototype, 'stat') + .mockImplementation(async function (this: typeof probe) { + const stats = await originalStat.call(this); + stats.uid = foreignUid; + return stats; + }); + + try { + await expect( + transferWorktreeSessionMarkerOwner(dir, 'session-old', 'session-new'), + ).rejects.toThrow('Worktree marker is owned by a different uid'); + // The refusal precedes every write step: the marker keeps its bytes + // and its inode, and no transfer temp file is staged beside it. + await expect(fs.readFile(markerPath, 'utf8')).resolves.toBe( + 'session-old', + ); + expect((await fs.lstat(markerPath)).ino).toBe(before.ino); + const siblings = await fs.readdir(dir); + expect(siblings.filter((name) => name.endsWith('.tmp'))).toEqual([]); + } finally { + statSpy.mockRestore(); + } + }, + ); + + it('aborts the rename when the marker is swapped in the commit window', async () => { + const dir = await tempDir(); + await createWorktreeSessionMarkerExclusive(dir, 'session-old'); + const markerPath = path.join(dir, WORKTREE_SESSION_FILE); + + // Stands in for a second writer that takes over the marker path after the + // opening strict read decided the transfer was allowed. `fs.writeFile`'s + // flush is the last step before `atomicWriteFile` commits the rename, so + // hooking it lands the swap inside the window where the staged temp file + // already exists and only the `assertCanCommit` re-check can stop it. + const probe = await fs.open(markerPath, 'r'); + const prototype = Object.getPrototypeOf(probe) as Pick< + typeof probe, + 'sync' + >; + const originalSync = prototype.sync; + await probe.close(); + const syncSpy = vi + .spyOn(prototype, 'sync') + .mockImplementation(async function (this: typeof probe) { + await originalSync.call(this); + await fs.unlink(markerPath); + await fs.writeFile(markerPath, 'session-raced'); + }); + + try { + await expect( + transferWorktreeSessionMarkerOwner(dir, 'session-old', 'session-new'), + ).rejects.toThrow('Worktree marker changed during ownership transfer'); + // The aborted rename leaves the raced marker — never the stale owner + // this call was about to commit — and cleans up the staged temp file. + await expect(fs.readFile(markerPath, 'utf8')).resolves.toBe( + 'session-raced', + ); + const siblings = await fs.readdir(dir); + expect(siblings.filter((name) => name.endsWith('.tmp'))).toEqual([]); + } finally { + syncSpy.mockRestore(); + } + }); }); diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 645abd7fa45..594aa1033e3 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -20,6 +20,7 @@ import { createDebugLogger } from '../utils/debugLogger.js'; import { fileExists, isWithinRoot } from '../utils/fileUtils.js'; import { loadSimpleGit } from '../utils/load-simple-git.js'; import { initRepositoryWithMainBranch } from './gitInit.js'; +import { atomicWriteFile } from '../utils/atomicFileWrite.js'; const debugLogger = createDebugLogger('GIT_WORKTREE_SERVICE'); @@ -35,8 +36,8 @@ export function worktreeBranchForSlug(slug: string): string { * Filename of the in-worktree session marker. Created at worktree * provisioning time and consulted by `exit_worktree` to decide * whether the current session is allowed to drop the worktree. The - * file lives outside the working tree (it is .gitignored as part of - * `.qwen/worktrees/.gitignore`) so it cannot leak into commits. + * file is kept out of commits via the repository's common + * `.git/info/exclude` (see `addWorktreeSessionMarkerExclude`). */ export const WORKTREE_SESSION_FILE = '.qwen-session'; @@ -44,7 +45,20 @@ const WORKTREE_SESSION_MARKER_MAX_BYTES = 512; export type StrictWorktreeSessionMarker = | { state: 'missing' } - | { state: 'valid'; sessionId: string } + | { + state: 'valid'; + sessionId: string; + /** + * Owning uid of the marker inode, or null when the process cannot + * compare uids (no `geteuid`, e.g. Windows). Ownership-transfer + * flows refuse a foreign-uid marker where the identity is available + * instead of riding the ownership-preserving in-place write path. + */ + uid: number | null; + /** Inode identity pinned across the no-follow read. */ + dev: number; + ino: number; + } | { state: 'invalid'; reason: string }; async function addWorktreeSessionMarkerExclude( @@ -64,10 +78,20 @@ async function addWorktreeSessionMarkerExclude( } catch { // File missing — fall through to fresh write. } - const rule = WORKTREE_SESSION_FILE; - if (!existing.split(/\r?\n/).includes(rule)) { - const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'; - await fs.writeFile(excludePath, `${existing}${sep}${rule}\n`, 'utf8'); + // The second rule covers the sibling temp file `atomicWriteFile` stages + // next to the marker during an ownership transfer + // (`/.qwen-session..tmp`). It lands in the main checkout's + // shared exclude too — accepted: the pattern is name-scoped. + const rules = [WORKTREE_SESSION_FILE, `${WORKTREE_SESSION_FILE}.*.tmp`]; + let next = existing; + for (const rule of rules) { + if (!next.split(/\r?\n/).includes(rule)) { + const sep = next.length === 0 || next.endsWith('\n') ? '' : '\n'; + next = `${next}${sep}${rule}\n`; + } + } + if (next !== existing) { + await fs.writeFile(excludePath, next, 'utf8'); } } catch { // The marker remains authoritative when the ignore rule cannot be added. @@ -94,6 +118,26 @@ export async function writeWorktreeSessionMarker( await addWorktreeSessionMarkerExclude(worktreePath); } +/** + * The marker is durably committed — staged, fsync'd, identity-verified and + * linked into place — but this primitive's own post-commit tail failed. The + * commit stands: a caller that compensates on failure must classify this as + * "the flip happened" rather than roll back a transfer the marker already + * records, and must not clean up a valid marker file. `committedOwner` is the + * session id the marker now names. + */ +export class WorktreeMarkerCommittedError extends Error { + readonly committedOwner: string; + constructor(committedOwner: string, options?: { cause?: unknown }) { + super( + `Worktree session marker committed for ${committedOwner}; the post-commit close failed`, + options, + ); + this.name = 'WorktreeMarkerCommittedError'; + this.committedOwner = committedOwner; + } +} + /** * Creates the daemon-owned marker without replacing any existing path. * This is intentionally separate from {@link writeWorktreeSessionMarker}, @@ -103,22 +147,28 @@ export async function createWorktreeSessionMarkerExclusive( worktreePath: string, sessionId: string, ): Promise { - if ( - sessionId.length === 0 || - Buffer.byteLength(sessionId, 'utf8') > WORKTREE_SESSION_MARKER_MAX_BYTES || - sessionId.trim() !== sessionId - ) { - throw new Error('Invalid worktree session marker owner'); - } + assertValidWorktreeSessionMarkerOwner(sessionId); const markerPath = path.join(worktreePath, WORKTREE_SESSION_FILE); + // Staged beside the marker so the publishing link stays on one device, and + // named for the `.qwen-session.*.tmp` git-exclude rule. + const stagedPath = `${markerPath}.${randomBytes(6).toString('hex')}.tmp`; const flags = nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT | nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW ?? 0); - const handle = await fs.open(markerPath, flags, 0o600); + // `fs.open` with O_EXCL throws before anything is created when a path + // already exists, so it stays outside the try: only a failure past this + // point has a file of ours to remove. + const handle = await fs.open(stagedPath, flags, 0o600); + // Captured from the opened handle so the cleanup below can verify the path + // still refers to the file this call created before removing it. + let stagedDev = 0; + let stagedIno = 0; try { const before = await handle.stat(); + stagedDev = before.dev; + stagedIno = before.ino; if (!before.isFile() || before.nlink !== 1 || before.ino === 0) { throw new Error('Worktree session marker has an unsafe identity'); } @@ -126,7 +176,7 @@ export async function createWorktreeSessionMarkerExclusive( await handle.sync(); const [after, pathStats] = await Promise.all([ handle.stat(), - fs.lstat(markerPath), + fs.lstat(stagedPath), ]); if ( !after.isFile() || @@ -139,12 +189,60 @@ export async function createWorktreeSessionMarkerExclusive( ) { throw new Error('Worktree session marker identity changed'); } - } finally { + // Publish with a hard link so the marker path first exists once its inode + // already holds the owner and is fsync'd. Creating the marker path itself + // with O_EXCL and filling it afterwards leaves a crash window holding a + // 0-byte marker, which strict reads report as `invalid` — the one state no + // recovery path repairs. `link` fails EEXIST when the marker path is + // taken, so it is the same compare-and-swap O_EXCL was: a concurrent + // create still loses instead of overwriting. + await fs.link(stagedPath, markerPath); + } catch (error) { + await handle.close().catch(() => {}); + // Remove only the file this call created: on the identity-changed branch + // the path already refers to someone else's inode, and unlinking it would + // turn a fail-closed detection into a destructive action against the + // detected file. Without a captured identity there is nothing to match, + // so the rare residue is left for operator cleanup instead. This catch is + // reachable only before the publish, so the marker path is never unlinked. + if (stagedIno !== 0) { + try { + const current = await fs.lstat(stagedPath); + if (current.dev === stagedDev && current.ino === stagedIno) { + await fs.unlink(stagedPath); + } + } catch { + // Path already gone — nothing of ours to remove. + } + } + throw error; + } + // Post-commit tail: the marker already names `sessionId`, so a failure here + // must propagate with the marker intact rather than trigger cleanup of a + // valid file. Classified so a caller that compensates on failure cannot read + // the committed flip as a pre-flip error and dismantle what the marker now + // names. Dropping the staged name belongs to the commit — until it lands the + // marker carries nlink 2 and strict reads reject it. + try { + await fs.unlink(stagedPath); await handle.close(); + } catch (error) { + await handle.close().catch(() => {}); + throw new WorktreeMarkerCommittedError(sessionId, { cause: error }); } await addWorktreeSessionMarkerExclude(worktreePath); } +function assertValidWorktreeSessionMarkerOwner(sessionId: string): void { + if ( + sessionId.length === 0 || + Buffer.byteLength(sessionId, 'utf8') > WORKTREE_SESSION_MARKER_MAX_BYTES || + sessionId.trim() !== sessionId + ) { + throw new Error('Invalid worktree session marker owner'); + } +} + /** Strict daemon-only marker read that never collapses corruption into absence. */ export async function readWorktreeSessionMarkerStrict( worktreePath: string, @@ -221,7 +319,13 @@ export async function readWorktreeSessionMarkerStrict( ) { return { state: 'invalid', reason: 'invalid marker owner' }; } - return { state: 'valid', sessionId }; + return { + state: 'valid', + sessionId, + uid: process.geteuid === undefined ? null : after.uid, + dev: after.dev, + ino: after.ino, + }; } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { return observedMarker @@ -237,6 +341,186 @@ export async function readWorktreeSessionMarkerStrict( } } +/** + * Synchronous twin of {@link readWorktreeSessionMarkerStrict}. Exists for the + * `atomicWriteFile` assertCanCommit hook, which must re-verify the marker + * synchronously in the same tick as the rename commit. + */ +export function readWorktreeSessionMarkerStrictSync( + worktreePath: string, +): StrictWorktreeSessionMarker { + const markerPath = path.join(worktreePath, WORKTREE_SESSION_FILE); + let fd: number | undefined; + let observedMarker = false; + try { + const flags = + nodeFs.constants.O_RDONLY | + (nodeFs.constants.O_NOFOLLOW ?? 0) | + (nodeFs.constants.O_NONBLOCK ?? 0); + const before = nodeFs.lstatSync(markerPath); + observedMarker = true; + if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) { + return { state: 'invalid', reason: 'unsafe marker file type' }; + } + if (before.ino === 0 || before.size > WORKTREE_SESSION_MARKER_MAX_BYTES) { + return { state: 'invalid', reason: 'unsafe marker size or identity' }; + } + fd = nodeFs.openSync(markerPath, flags); + const opened = nodeFs.fstatSync(fd); + if ( + !opened.isFile() || + opened.nlink !== 1 || + opened.dev !== before.dev || + opened.ino !== before.ino + ) { + return { + state: 'invalid', + reason: 'marker identity changed before read', + }; + } + const markerBuffer = Buffer.alloc(WORKTREE_SESSION_MARKER_MAX_BYTES + 1); + let bytesRead = 0; + while (bytesRead < markerBuffer.length) { + const chunk = nodeFs.readSync( + fd, + markerBuffer, + bytesRead, + markerBuffer.length - bytesRead, + bytesRead, + ); + if (chunk === 0) break; + bytesRead += chunk; + } + if (bytesRead > WORKTREE_SESSION_MARKER_MAX_BYTES) { + return { state: 'invalid', reason: 'unsafe marker size or identity' }; + } + const raw = markerBuffer.subarray(0, bytesRead).toString('utf8'); + const after = nodeFs.fstatSync(fd); + const pathStats = nodeFs.lstatSync(markerPath); + if ( + !after.isFile() || + after.nlink !== 1 || + after.size !== bytesRead || + after.size > WORKTREE_SESSION_MARKER_MAX_BYTES || + after.dev !== opened.dev || + after.ino !== opened.ino || + !pathStats.isFile() || + pathStats.nlink !== 1 || + pathStats.dev !== after.dev || + pathStats.ino !== after.ino + ) { + return { + state: 'invalid', + reason: 'marker identity changed during read', + }; + } + const sessionId = raw.trim(); + if ( + sessionId.length === 0 || + sessionId !== raw || + Buffer.byteLength(sessionId, 'utf8') > WORKTREE_SESSION_MARKER_MAX_BYTES + ) { + return { state: 'invalid', reason: 'invalid marker owner' }; + } + return { + state: 'valid', + sessionId, + uid: process.geteuid === undefined ? null : after.uid, + dev: after.dev, + ino: after.ino, + }; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return observedMarker + ? { state: 'invalid', reason: 'marker disappeared during read' } + : { state: 'missing' }; + } + return { + state: 'invalid', + reason: error instanceof Error ? error.message : String(error), + }; + } finally { + if (fd !== undefined) { + try { + nodeFs.closeSync(fd); + } catch { + // Best-effort close; the read result above stands. + } + } + } +} + +/** + * Transfer the worktree marker's ownership to a replacement session. + * + * `expectedOwner` is the session id the opening strict read must find, or + * `null` for the missing-marker hatch (caller already proved the sidecar + * valid; the marker is re-created by linking a fully staged file into the + * path, which fails when a concurrently created marker already occupies it + * instead of overwriting it). + * + * An owned marker is rewritten through `atomicWriteFile` (sibling temp + + * fsync + rename) with an `assertCanCommit` hook that re-reads the marker + * synchronously and compares owner, dev/ino, and uid against the opening + * read — a marker swapped between the read and the commit aborts the write. + * Where uid identity is available, a foreign-owned marker is refused outright + * rather than rewritten through the ownership-preserving in-place path. + * + * The missing-marker hatch delegates to + * {@link createWorktreeSessionMarkerExclusive}, so a failure after that + * create committed surfaces as {@link WorktreeMarkerCommittedError}: the + * marker already names `newOwner` and must not be compensated backwards. + */ +export async function transferWorktreeSessionMarkerOwner( + worktreePath: string, + expectedOwner: string | null, + newOwner: string, +): Promise { + assertValidWorktreeSessionMarkerOwner(newOwner); + if (expectedOwner !== null) { + assertValidWorktreeSessionMarkerOwner(expectedOwner); + if (expectedOwner === newOwner) { + throw new Error('Worktree marker transfer needs a distinct new owner'); + } + } + const opening = await readWorktreeSessionMarkerStrict(worktreePath); + if (opening.state === 'invalid') { + throw new Error(`Worktree marker is invalid: ${opening.reason}`); + } + if (opening.state === 'missing') { + if (expectedOwner !== null) { + throw new Error('Worktree marker owner does not match the expectation'); + } + await createWorktreeSessionMarkerExclusive(worktreePath, newOwner); + return; + } + if (opening.sessionId !== expectedOwner) { + throw new Error('Worktree marker owner does not match the expectation'); + } + const euid = process.geteuid?.(); + if (euid !== undefined && opening.uid !== null && opening.uid !== euid) { + throw new Error('Worktree marker is owned by a different uid'); + } + const markerPath = path.join(worktreePath, WORKTREE_SESSION_FILE); + await atomicWriteFile(markerPath, newOwner, { + mode: 0o600, + noFollow: true, + assertCanCommit: () => { + const current = readWorktreeSessionMarkerStrictSync(worktreePath); + if ( + current.state !== 'valid' || + current.sessionId !== opening.sessionId || + current.dev !== opening.dev || + current.ino !== opening.ino || + current.uid !== opening.uid + ) { + throw new Error('Worktree marker changed during ownership transfer'); + } + }, + }); + await addWorktreeSessionMarkerExclude(worktreePath); +} + /** * Reads the owning session id stored at worktree provisioning time. * Returns `null` when the marker is missing or unreadable — callers diff --git a/packages/core/src/services/worktreeSessionService.test.ts b/packages/core/src/services/worktreeSessionService.test.ts index 96ed5abe2ac..36b2d0824e9 100644 --- a/packages/core/src/services/worktreeSessionService.test.ts +++ b/packages/core/src/services/worktreeSessionService.test.ts @@ -329,6 +329,42 @@ describe('writeWorktreeSession', () => { await writeWorktreeSession(nestedPath, sample); expect(await readWorktreeSession(nestedPath)).toEqual(sample); }); + + it('round-trips the supersede link fields used by worktree reset', async () => { + const linked: WorktreeSession = { ...sample, supersedes: 'session-old' }; + await writeWorktreeSession(filePath, linked); + await expect(readWorktreeSessionStrict(filePath)).resolves.toEqual({ + state: 'valid', + session: linked, + }); + + const superseded: WorktreeSession = { + ...sample, + supersededBy: 'session-new', + }; + await writeWorktreeSession(filePath, superseded); + await expect(readWorktreeSession(filePath)).resolves.toEqual(superseded); + }); + + it('rejects sidecars with non-string supersede links', async () => { + await fs.writeFile( + filePath, + JSON.stringify({ ...sample, supersededBy: 42 }), + 'utf-8', + ); + await expect(readWorktreeSessionStrict(filePath)).resolves.toMatchObject({ + state: 'invalid', + }); + + await fs.writeFile( + filePath, + JSON.stringify({ ...sample, supersedes: null }), + 'utf-8', + ); + await expect(readWorktreeSessionStrict(filePath)).resolves.toMatchObject({ + state: 'invalid', + }); + }); }); describe('clearWorktreeSession', () => { @@ -436,6 +472,41 @@ describe('restoreWorktreeContext', () => { expect(await readWorktreeSession(filePath)).toEqual(live); }); + it('refuses to restore a live worktree whose sidecar names a replacement session', async () => { + // A worktree reset moves ownership of the checkout to the replacement + // session but leaves the superseded sidecar (and transcript) on disk. + // Resuming the old id must not inject the "continue using this path" + // notice — that would put a second live writer in the replacement's + // worktree. + const supersededCwd = path.join(tmpDir, 'superseded-repo'); + const supersededWorktree = path.join( + supersededCwd, + '.qwen', + 'worktrees', + 'my-feature', + ); + await fs.mkdir(supersededWorktree, { recursive: true }); + const superseded: WorktreeSession = { + ...sample, + originalCwd: supersededCwd, + worktreePath: supersededWorktree, + supersededBy: 'session-replacement', + }; + await writeWorktreeSession(filePath, superseded); + const warnings: unknown[] = []; + + const result = await restoreWorktreeContext(filePath, (e) => + warnings.push(e), + ); + + expect(result.session).toBeNull(); + expect(result.contextMessage).toBeNull(); + // Unlike the stale cases, the sidecar survives: its supersede link is the + // daemon reset route's redirect and interrupted-transfer evidence. + expect(await readWorktreeSession(filePath)).toEqual(superseded); + expect(warnings).toHaveLength(1); + }); + it('rejects and clears a sidecar whose worktreePath escapes the managed subtree', async () => { // A tampered sidecar pointing at /tmp itself (a real dir) but not // under `/.qwen/worktrees/` must be treated as diff --git a/packages/core/src/services/worktreeSessionService.ts b/packages/core/src/services/worktreeSessionService.ts index 5d6409bdbcd..3147fe8db03 100644 --- a/packages/core/src/services/worktreeSessionService.ts +++ b/packages/core/src/services/worktreeSessionService.ts @@ -57,6 +57,19 @@ export interface WorktreeSession { * treat empty as "unknown" and skip the commit-count display. */ originalHeadCommit: string; + /** + * Set on the superseded session's sidecar by a worktree reset: the + * replacement session this session's worktree ownership moved to. Readers + * redirect to it (and self-heal their registry) instead of restoring the + * superseded session. + */ + supersededBy?: string; + /** + * Set on the replacement session's sidecar by a worktree reset: the + * session it took the worktree from. Cross-checked against the old + * session's `supersededBy` to classify an interrupted transfer. + */ + supersedes?: string; } export type StrictWorktreeSession = @@ -82,7 +95,10 @@ function isValidWorktreeSession(value: unknown): value is WorktreeSession { (v['workspaceCwd'] === undefined || typeof v['workspaceCwd'] === 'string') && typeof v['originalBranch'] === 'string' && - typeof v['originalHeadCommit'] === 'string' + typeof v['originalHeadCommit'] === 'string' && + (v['supersededBy'] === undefined || + typeof v['supersededBy'] === 'string') && + (v['supersedes'] === undefined || typeof v['supersedes'] === 'string') ); } @@ -481,6 +497,12 @@ export interface WorktreeRestoreResult { * error (e.g. permission, EIO) — we still attempt cleanup so the next * resume isn't stuck reading the same broken file. * + * A sidecar carrying `supersededBy` is refused the same way but deliberately + * NOT cleared: a worktree reset moved ownership of that checkout to the + * replacement session, and the daemon's reset route still reads this sidecar's + * `supersededBy` to redirect and to classify an interrupted transfer. Restoring + * here would point a live session at a checkout another session owns. + * * Shared by TUI / headless / ACP entry points so all three behave * consistently on `--resume`. Failures are logged via the supplied * `onWarn` callback but never thrown — worktree restore is best-effort, @@ -520,6 +542,21 @@ export async function restoreWorktreeContext( return { contextMessage: null, session: null }; } + if (session.supersededBy !== undefined) { + // A worktree reset moved ownership of this checkout to + // `session.supersededBy`. Refuse the restore — resuming here would direct + // a live session into a worktree another session now owns — but leave the + // sidecar alone: the daemon's reset route still reads this link to + // redirect and to classify an interrupted transfer. + onWarn?.( + new Error( + `worktree session was superseded by ${session.supersededBy}; ` + + `not restoring its worktree context.`, + ), + ); + return { contextMessage: null, session: null }; + } + // Structural sanity check: the worktreePath MUST live under // `/.qwen/worktrees/`. Schema validation (readWorktreeSession) // already ensures the fields are strings, but a manually-edited or diff --git a/packages/core/src/tools/exit-worktree.test.ts b/packages/core/src/tools/exit-worktree.test.ts index 797626e3a6c..38d0afd8d0a 100644 --- a/packages/core/src/tools/exit-worktree.test.ts +++ b/packages/core/src/tools/exit-worktree.test.ts @@ -18,6 +18,13 @@ import { worktreeBranchForSlug, writeWorktreeSessionMarker, } from '../services/gitWorktreeService.js'; +import { + readWorktreeSession, + writeWorktreeSession, +} from '../services/worktreeSessionService.js'; +import { SessionService } from '../services/sessionService.js'; +import { Storage } from '../config/storage.js'; +import { writeRuntimeStatus } from '../utils/runtimeStatus.js'; function makeMockConfig(targetDir = process.cwd()): Config { // Default to cwd because `GitWorktreeService` constructs `simpleGit` @@ -322,4 +329,139 @@ describe('ExitWorktreeTool', () => { expect(re.trim()).toBe('rewritten-id'); }); }); + + // ── execute() integration: superseded sidecar after a worktree reset ── + // The reset transfer retains the superseded session's sidecar (same + // slug, same worktreePath) and only adds `supersededBy`, so the + // stale-marker hatch must not read that sidecar as proof the current + // session owns the checkout. + describe('execute() — superseded sidecar', () => { + let repoRoot: string; + + beforeEach(async () => { + const raw = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-exit-sup-')); + repoRoot = await fs.realpath(raw); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoRoot }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { + cwd: repoRoot, + }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: repoRoot }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: repoRoot, + }); + await fs.writeFile(path.join(repoRoot, 'README.md'), 'hi\n'); + execFileSync('git', ['add', '.'], { cwd: repoRoot }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], { + cwd: repoRoot, + }); + Storage.setRuntimeBaseDir(path.join(repoRoot, '.runtime')); + }); + + afterEach(async () => { + Storage.setRuntimeBaseDir(null); + await fs.rm(repoRoot, { recursive: true, force: true }); + }); + + /** + * A committed transfer: the marker names the replacement, the + * replacement's runtime is dead (so `ownerActive` is false and the + * stale-marker hatch is reachable), and the superseded session's + * sidecar still names the slug and path. + */ + async function provisionTransferredWorktree( + slug: string, + supersededBy?: string, + ): Promise<{ worktreePath: string; sidecarPath: string }> { + const service = new GitWorktreeService(repoRoot); + expect((await service.createUserWorktree(slug)).success).toBe(true); + const worktreePath = service.getUserWorktreePath(slug); + await writeWorktreeSessionMarker(worktreePath, 'session-new'); + await writeRuntimeStatus( + new Storage(worktreePath).getRuntimeStatusPath('session-new'), + { + sessionId: 'session-new', + workDir: worktreePath, + pid: 2147483647, + }, + ); + const sessionService = new SessionService(worktreePath); + const sidecarPath = sessionService.getWorktreeSessionPath('session-old'); + const originalHeadCommit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + await writeWorktreeSession(sidecarPath, { + slug, + worktreePath, + worktreeBranch: worktreeBranchForSlug(slug), + originalCwd: repoRoot, + originalBranch: 'main', + originalHeadCommit, + ...(supersededBy === undefined ? {} : { supersededBy }), + }); + return { worktreePath, sidecarPath }; + } + + function supersededConfig(worktreePath: string): Config { + return { + getTargetDir: () => worktreePath, + getSessionId: () => 'session-old', + getSessionService: () => new SessionService(worktreePath), + } as unknown as Config; + } + + it('refuses remove when the current sidecar is superseded by the marker owner', async () => { + const { worktreePath, sidecarPath } = await provisionTransferredWorktree( + 'transferred', + 'session-new', + ); + + const result = await new ExitWorktreeTool(supersededConfig(worktreePath)) + .build({ + name: 'transferred', + action: 'remove', + discard_changes: true, + }) + .execute(new AbortController().signal); + + expect(result.error?.message ?? 'removal was allowed to proceed').toMatch( + /different session.*owner=session-new/i, + ); + // The replacement's checkout, branch, marker and the redirect link the + // restore route depends on must all survive. + await expect(fs.access(worktreePath)).resolves.toBeUndefined(); + expect( + execFileSync('git', ['branch', '--list'], { + cwd: repoRoot, + encoding: 'utf8', + }), + ).toContain(worktreeBranchForSlug('transferred')); + await expect( + fs.readFile(path.join(worktreePath, WORKTREE_SESSION_FILE), 'utf8'), + ).resolves.toBe('session-new'); + expect(await readWorktreeSession(sidecarPath)).toMatchObject({ + supersededBy: 'session-new', + }); + }); + + it('still recovers a stale marker when the sidecar carries no supersede link', async () => { + // Fix constraint: the new term is a conjunction on + // `currentSessionOwnsPath`, not a replacement of the `ownerActive` + // check — legitimate stale-marker recovery must keep firing. + const { worktreePath, sidecarPath } = + await provisionTransferredWorktree('stale-owned'); + + const result = await new ExitWorktreeTool(supersededConfig(worktreePath)) + .build({ + name: 'stale-owned', + action: 'remove', + discard_changes: true, + }) + .execute(new AbortController().signal); + + expect(result.error).toBeUndefined(); + await expect(fs.access(worktreePath)).rejects.toBeDefined(); + expect(await readWorktreeSession(sidecarPath)).toBeNull(); + }); + }); }); diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index 816a3b5a6f4..f54ff5351db 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -240,8 +240,14 @@ class ExitWorktreeInvocation extends BaseToolInvocation< const currentSessionId = this.config.getSessionId(); if (owner !== null && owner !== currentSessionId) { currentWorktreeSession ??= await this.readCurrentWorktreeSession(); + // A sidecar carrying `supersededBy` is proof the marker is *not* stale: + // the worktree-reset transfer deliberately retains the superseded + // sidecar with the same slug and path, so without this term the hatch + // below collapses to `ownerActive` and a resumed superseded session + // drops the checkout (and branch) the replacement now owns. const currentSessionOwnsPath = currentWorktreeSession?.slug === this.params.name && + currentWorktreeSession.supersededBy === undefined && samePath(currentWorktreeSession.worktreePath, worktreePath); const ownerActive = await isSessionRuntimeActive( owner, diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index c8841317940..01bb032a716 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -676,6 +676,21 @@ export interface RestoreSessionRequest { timeoutMs?: number; } +export interface WorktreeResetSessionRequest { + /** + * Workspace path the daemon must have registered. Omit to let the daemon use + * its advertised primary workspace, mirroring restores — pass the session's + * workspace path for sessions on other workspaces. + */ + workspaceCwd?: string; + modelServiceId?: string; + approvalMode?: string; + /** Attribution stamped on the replacement session. */ + sourceType?: string; + /** Optional source-specific identifier. Requires `sourceType`. */ + sourceId?: string; +} + export interface PromptRequest { prompt: PromptContentBlock[]; /** Deliver the successful final answer directly through a channel worker. */ @@ -3308,6 +3323,72 @@ export class DaemonClient { return this.restoreSession('resume', sessionId, req, clientId); } + /** + * Transfer a worktree session's checkout ownership to a fresh replacement + * session (`POST /session/:id/worktree-reset`, capability + * `session_worktree_reset_v1`). Resolves 200 with the replacement + * session's create-shape response. The route never reads a caller-supplied + * client id, so this method takes none — but the response is not + * registration-free: a fresh transfer mints an owner-style `clientId` for + * the spawn it performs, registers it on the replacement, and returns it in + * the body, while an idempotent resume of a committed transfer returns + * none. That registration is not an attachment (the replacement's attach + * count is unaffected), and nothing detaches it for you — pass a minted id + * you do not keep using to `detachSession`, or the replacement never + * reaches the daemon's idle cleanup. Typed 409 bodies carry the reset + * taxonomy: `worktree_reset_unsupported` (not a worktree session), + * `worktree_reset_active` (a session involved is busy), and + * `worktree_reset_invalid_state` (corrupt or ambiguous ownership state; a + * partial transfer this request started is rolled back, while a + * pre-existing interrupted state is left untouched for operator repair). + * A crashed transfer whose sidecar links agree is self-healed here: a + * pre-commit one (the marker still names this session, or is absent while + * the replacement is dormant) is rolled back and re-run in the same + * request, and a committed one (the marker names the replacement) is + * finished idempotently. Every other interrupted shape — disagreeing links, + * an invalid marker, a marker naming a third session, a replacement still + * live with no marker — is reported as `worktree_reset_invalid_state` (or + * `worktree_reset_active` when the committed replacement is busy) and left + * untouched: a retry re-reads the same state and returns the same 409, so + * it does not converge and needs operator repair rather than a retry loop. + * `worktree_reset_interrupted`, `worktree_session_superseded`, and + * `worktree_marker_missing` belong to the restore surface (`loadSession` / + * `resumeSession`) instead; for `worktree_reset_interrupted` — the + * agreeing-links shape — retrying this reset against the superseded session + * is the repair. + */ + async resetWorktreeSession( + sessionId: string, + req: WorktreeResetSessionRequest = {}, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${urlEncode(sessionId)}/worktree-reset`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + ...(req.workspaceCwd !== undefined ? { cwd: req.workspaceCwd } : {}), + ...(req.modelServiceId !== undefined + ? { modelServiceId: req.modelServiceId } + : {}), + ...(req.approvalMode !== undefined + ? { approvalMode: req.approvalMode } + : {}), + ...(req.sourceType !== undefined + ? { sourceType: req.sourceType } + : {}), + ...(req.sourceId !== undefined ? { sourceId: req.sourceId } : {}), + }), + }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'POST /session/:id/worktree-reset'); + } + return (await res.json()) as DaemonSession; + }, + ); + } + async branchSession( sessionId: string, req: HistoricalBranchSessionRequest, diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 0ff87ec4201..2bd0067d4f9 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -16,6 +16,7 @@ import { type PromptRequest, type RestoreSessionRequest, type SubscribeOptions, + type WorktreeResetSessionRequest, } from './DaemonClient.js'; import type { DaemonForkSessionResult, @@ -450,6 +451,41 @@ export class DaemonSessionClient { }); } + /** + * Transfer a worktree session's checkout ownership to a fresh replacement + * session and return a client bound to the replacement's identity. That + * client carries whatever `clientId` the response had: a fresh transfer + * mints and registers an owner-style one for the spawn it performs, while + * an idempotent resume of a committed transfer returns none. The + * registration is not an attachment (the replacement's attach count is + * unaffected), but it is a client registration, and a non-empty one is what + * holds the daemon's idle cleanup off — so detach a minted id you do not + * keep using, or the replacement stays live indefinitely. A registered + * attachment still requires the normal restore surface: load or resume the + * replacement, passing the minted id to reuse that registration instead of + * adding a second one. The replacement is a brand-new conversation; the + * superseded session keeps its transcript on the daemon and is never + * restored again (its restore surfaces `worktree_session_superseded`). The + * existing reattach identity guard applies to the replacement client + * unchanged. + */ + static async resetWorktree( + client: DaemonClient, + sessionId: string, + req: WorktreeResetSessionRequest = {}, + ): Promise { + const session = await client.resetWorktreeSession(sessionId, req); + return new DaemonSessionClient({ + client, + session, + hasActivePrompt: session.hasActivePrompt, + // The replacement is freshly created: seed from the start of its bus + // so events fired during the transfer window are not skipped. + lastEventId: 0, + eventEpoch: session.eventEpoch, + }); + } + static async createStandalone( client: DaemonClient, options: CreateStandaloneSessionOptions = {}, @@ -747,19 +783,37 @@ export class DaemonSessionClient { }); this.reattaching = resume.then(async (session) => { if (this.session.worktreeState === 'persisted-v1') { - const sameWorktree = - session.worktreeState === 'persisted-v1' && - session.worktree?.path === this.session.worktree?.path; - if (!sameWorktree) { - await this.client - .detachSession(session.sessionId, session.clientId) - .catch(() => {}); - throw new Error( - `Daemon lost durable worktree identity for session ${this.sessionId}`, - ); + if (!session.worktree) { + // The daemon resumed the session with no worktree object at all — + // most often a legitimate exit (exit_worktree removes the sidecar + // without notifying this client), but also a cleared in-memory + // association or a provenance that skips sidecar restore. The + // client cannot distinguish these, so it drops the cached claim and + // lets the caller's identity gate re-establish the truth on its + // next load or selection. The heal does not gate this call's retry: + // `withClientIdSelfHeal` re-runs the pending operation right after + // the reattach, so one turn can still execute in a directory this + // client can no longer attest. A caller that requires worktree + // attestation must re-verify it before dispatching, not rely on the + // dropped claim. A response that still carries `worktree` metadata + // is never treated as proof of exit. + this.session.worktree = undefined; + this.session.worktreeState = undefined; + } else { + const sameWorktree = + session.worktreeState === 'persisted-v1' && + session.worktree.path === this.session.worktree?.path; + if (!sameWorktree) { + await this.client + .detachSession(session.sessionId, session.clientId) + .catch(() => {}); + throw new Error( + `Daemon lost durable worktree identity for session ${this.sessionId}`, + ); + } + this.session.worktree = session.worktree; + this.session.worktreeState = session.worktreeState; } - this.session.worktree = session.worktree; - this.session.worktreeState = session.worktreeState; } // Refresh only the client identity; leave the SSE cursor and ACP state intact. this.session.clientId = session.clientId; diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 29f5634dfb2..4406e59c867 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -23,6 +23,7 @@ export { type PromptRequest, type RestoreSessionRequest, type SubscribeOptions, + type WorktreeResetSessionRequest, } from './DaemonClient.js'; export { isSessionLevelNotFound, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index c57461ee3f1..935f3f152bd 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1152,6 +1152,24 @@ export interface DaemonSession { worktree?: DaemonWorktreeInfo; /** Durable worktree metadata/ownership attestation from the daemon. */ worktreeState?: 'persisted-v1'; + /** + * Present on a worktree-reset response when the superseded session survived + * the sever step because its child refused the conditional idle close (it + * holds work — a background shell inside the worktree, for example). The + * transfer itself committed and `worktreeState` still attests the + * replacement, but the old session is still live inside that checkout, so + * the daemon keeps its reset barrier armed: prompts and the other writers + * that reach the checkout keep failing with `worktree_reset_active` until + * the survivor is discarded. + * + * Diagnostic only: no first-party caller reads it, and nothing depends + * on one doing so. The channel worker reports the same success message + * either way, and what actually keeps the survivor out of the checkout is + * the armed barrier plus the on-disk sidecar link, not this flag. An + * external caller that needs to tell "the old id is gone" from "still + * live but fenced" should read it, since a `200` alone does not say which. + */ + supersededSessionLive?: boolean; /** Present when the session was created with a new branch. */ branch?: DaemonBranchInfo; } diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 19f8b61a0ac..4071127a3ad 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -391,6 +391,7 @@ export { type SetUserLanguageResult, type SessionMetadataResult, type SubscribeOptions, + type WorktreeResetSessionRequest, } from './daemon/index.js'; export { diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 984aedc36eb..c5828c2a4e3 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -3436,6 +3436,47 @@ describe('DaemonSessionClient clientId self-heal', () => { }, ); + it('heals the cached worktree claim when the daemon resumes with no worktree object', async () => { + let promptCalls = 0; + let detachCalls = 0; + const { fetch } = recordingFetch((req) => { + if (req.url.endsWith('/session/s-1/resume')) { + // The worktree was legitimately exited (its sidecar is gone), so the + // daemon's own restore gate resumed the session without one. + return jsonResponse(200, { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + clientId: 'client-2', + state: {}, + }); + } + if (req.url.endsWith('/session/s-1/detach')) { + detachCalls++; + return new Response(null, { status: 204 }); + } + if (req.url.endsWith('/session/s-1/prompt')) { + promptCalls++; + return promptCalls === 1 + ? invalidClientIdResponse() + : jsonResponse(200, { stopReason: 'end_turn' }); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const session = newWorktreeSession( + new DaemonClient({ baseUrl: 'http://daemon', fetch }), + ); + + await expect( + session.prompt({ prompt: [{ type: 'text', text: 'hi' }] }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + expect(promptCalls).toBe(2); + expect(detachCalls).toBe(0); + expect(session.clientId).toBe('client-2'); + expect(session.worktreeState).toBeUndefined(); + expect(session.worktree).toBeUndefined(); + }); + it('re-registers and retries once when the blocking prompt is rejected with invalid_client_id', async () => { let promptCalls = 0; let resumeCalls = 0;