From cae475f78367401618f402fb2c28db8c58ee4c63 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Mon, 17 Aug 2026 10:47:34 +0800 Subject: [PATCH 01/29] docs: finalize standalone PR2 core design Co-authored-by: Qwen-Coder --- docs/design/standalone-daemon-sessions.md | 496 +++++++++++++-- docs/plans/2026-08-14-standalone-pr2-core.md | 634 +++++++++++++++++++ 2 files changed, 1063 insertions(+), 67 deletions(-) create mode 100644 docs/plans/2026-08-14-standalone-pr2-core.md diff --git a/docs/design/standalone-daemon-sessions.md b/docs/design/standalone-daemon-sessions.md index 294797a1a7a..7fb0a2f6979 100644 --- a/docs/design/standalone-daemon-sessions.md +++ b/docs/design/standalone-daemon-sessions.md @@ -7,9 +7,11 @@ This document is the versioned architecture companion to source of truth for the standalone-session design and delivery plan. [PR #8890](https://github.com/QwenLM/qwen-code/pull/8890) is implementation PR0, not a documentation-only gate: it keeps this document synchronized while -delivering the Conversations runtime foundation. The remaining ownership, -standalone core, capability, SDK, WebUI, and WebShell work is delivered in PR1 -through PR6 below. +delivering the Conversations runtime foundation. +[PR #9181](https://github.com/QwenLM/qwen-code/pull/9181) is the merged PR1 +implementation of runtime ownership and ordinary-workspace isolation. The +remaining standalone core, capability, SDK, WebUI, and WebShell work is +delivered in PR2 through PR6 below. The design builds on the projectless conversation infrastructure introduced for Live Voice. It does not authorize a second projectless runtime, a second session @@ -59,10 +61,25 @@ product surface while preserving Live-specific behavior. - A separate ACP child per standalone session. - Standalone attachments, durable scheduled tasks, storage quotas, retention policy, or general orphan cleanup beyond deletion recovery. +- Workflow execution and workflow snapshot/journal browsing. Those artifacts + are project-scoped under `Config.storage.getProjectDir()/workflows`; the + Conversations storage root is shared across standalone and Live sessions, so + standalone MVP disables the workflow tool and `/workflows` instead of + pretending that store is private. - Moving or forking a standalone session into a project. +- Generic transcript branch and WebShell side-task creation from a standalone + session. The MVP supports guarded background fork-agent work and explicit + `create_sub_session` children, but it does not route these separate creation + products around the standalone transaction. +- Agent-managed or caller-pinned Git worktrees. Ordinary Agent/fork work may run + in the private child, but `isolation: "worktree"`, `working_dir`, and + enter/exit-worktree tools are rejected for standalone sessions. - Cascading archive or deletion from parent sessions to child sessions. - Git branches, worktrees, repository status, or project settings for standalone sessions. +- Native LSP for standalone sessions. The current service captures its startup + workspace and has no managed-relocation contract, so PR2 forces it disabled + instead of pointing it at the shared Conversations root. - Changing Live Voice product semantics, Realtime behavior, or its tool surface. - Multi-master ownership, proxying between daemon processes, or guaranteed mixed-version concurrent access to the Conversations root. @@ -104,6 +121,29 @@ and project groups. Their chat surface hides workspace selection, Git status, branch and worktree controls, project files, project settings, pin/group controls, and attachments/uploads. Normal model, approval, tool, permission, transcript, and supported session metadata controls remain available. +Approval-mode changes are session-local: the generic `persist: true` form is +rejected because it would write the shared Conversations root as a workspace +setting and affect unrelated standalone and Live sessions. User-global settings +retain their existing scope. Model selection is also session-local: every +standalone create, attach, HTTP, or ACP model switch forces +`persistDefault: false` in the ACP child so it cannot write the shared +model route settings. Bridge-driven standalone model changes publish only the +target session's model event and suppress the workspace-wide +`settings_changed(model.name)` broadcast, because no shared default changed and +that broadcast would leak into other standalone and Live session buses. Live +and ordinary sessions keep their existing persistence and workspace-event +rules. ACP slash commands use the same boundary: session reset, +workspace-directory/settings, Git diff, project-skill, and cwd-derived +transcript operations are rejected before their first side effect, while a +plain primary-model or reasoning-effort change applies only to the current +session. Explicit model persistence and auxiliary model selectors remain +unsupported until they have an honest session-local implementation. +Permission persistence follows the same scope boundary. Primary and nested +sub-agent permission dialogs omit project-persistent “Always Allow” for +standalone sessions, reject an unoffered project outcome before tool callbacks +or in-memory rule mutation, and retain one-shot, session-local edit/plan mode, +and user-global permission choices. Live and workspace permission options are +unchanged. ### Persisted source @@ -129,14 +169,24 @@ loadable by identity but are excluded from top-level Recents. Parent and child archive or deletion operations do not cascade; each transcript and private directory has an independent lifecycle. +Generic transcript branch and side-task endpoints reject explicit and legacy +standalone parents before creating a transcript. Those endpoints have different +fork/copy semantics and do not inherit support merely because their parent can +be owner-routed. Background fork-agent execution remains an operation inside the +current session and uses the normal standalone working-directory guard. + PR2 extends the relocated source-classification helper so Live task list, read, wait, and follow-up operations treat explicit and legacy standalone sessions as loadable projectless task targets. It accepts top-level explicit standalone -sources with no `sourceId` and standalone children resolved through their parent -chain. This does not relabel them as Live in WebShell and does not expose -Live-only tools in their ordinary text turns. Projectless Live task creation -must use the same standalone creation service instead of creating new legacy -`sourceType: "default"` sessions. +sources with no `sourceId` and standalone children resolved through their +persisted explicit source and parent ID. Depth-1 is enforced when the child is +created and whenever a session with a parent tries to create another child; an +explicit child remains independently loadable after its parent is archived or +deleted. Only legacy children without source metadata must resolve a surviving +top-level parent to distinguish standalone from Live. This does not relabel them +as Live in WebShell and does not expose Live-only tools in their ordinary text +turns. Projectless Live task creation must use the same standalone creation +service instead of creating new legacy `sourceType: "default"` sessions. ## Runtime architecture @@ -242,12 +292,159 @@ base). User-authored Conversations-root configuration remains under that root. Neither is moved into the session's private child, which is only the effective tool and shell working directory. Managed relocation updates the effective target directory and workspace context without changing transcript ownership. +For the same reason, the existing per-session `QWEN_CODE_PROJECT_DIR` shell +context continues to identify the Conversations-owned transcript/harness +project directory; changing it to the private child would make nested Qwen +helpers look in a storage namespace that does not own the session. The process +cwd, Config target, workspace context, file discovery, and cwd-derived tool +state still use the child. This environment value is not a sandbox grant: shell +access remains behind the existing approval boundary, and the MVP does not +claim OS-level containment. +For a normalized standalone session, the daemon sends the root and child +identity it already pinned as an internal relocation expectation. The ACP child +validates that exact expectation before and after changing `Config`, but leaves +the session in a pending state that cannot start turns. The daemon validates the +original pin, invokes an idempotent internal binding commit, and validates the +pin again before recording the session as bound. That commit revalidates in the +ACP child, activates deferred post-replay state, promotes the turn guard, and +publishes the artifact store's ready base, but keeps automatic work held. Only +after the daemon's final identity check records the matching session epoch as a +not-yet-released binding does a second idempotent release revalidate the child +and start queued automatic work. Only a successful release promotes the daemon +record to reusable `agentBound`; all owner preflights reject the intermediate +record. The lifecycle admission remains held until that release succeeds. All +standalone relocation callers must provide the expectation; Live +keeps its existing request shape. Identity details are never exposed in logs, +warnings, or public responses. + +The bridge's session-artifact store must not continue treating the shared +Conversations root as the session workspace. A normalized standalone entry +defers every workspace-path restore, replay, stat, hash, list, and upsert until +managed relocation has passed ACP pre/post validation and daemon post-validation +for the exact child. The combined binding commit only changes the store's ready +base; deferred filesystem work runs later under a fresh daemon cwd preflight or +ACP turn/rewind guard. Same-path repair clears cached realpaths. Standalone +load/resume never restores worktree state from the Conversations root; paused +background-agent state is restored only during the post-relocation binding +commit, while execution remains held until the daemon has recorded the final +binding and released it. Automatic turns remain queued through pending, +activation, and final daemon validation. Live and ordinary +artifact and post-replay behavior is unchanged, and attachment/upload product +support remains out of MVP. A history-only rewind restores artifact metadata +without refreshing, stating, or hashing workspace files, so it remains +available when the private child is missing. + +The turn guard is not the only startup boundary. Before `loadCliConfig`, the ACP +manager derives a trusted provisional-workspace host policy from normalized +source state; it is not an argv, setting, environment, or request option. The +loader does not construct a Conversations-root `FileDiscoveryService` or select +project `output-language.md`, but it may read the Conversations-owned transcript +store and explicitly shared settings, hooks, extensions, skills, MCP config, and +user-global output language. Workspace include-directory values from settings or +daemon argv are not shared configuration: the policy forces the Config's +explicit include-directory set empty, so relocation produces exactly the private +child as its only workspace root. The same policy reaches Config initialization. +It is stored once as read-only Config construction state; there is no second +initialize-time switch that can disagree with loader behavior. +Native LSP, eager file discovery, +initial memory refresh and team sync, MCP discovery, lazy-tool warmup, Gemini +chat initialization, auto-skill curation, stale-worktree cleanup, ACP filesystem +fallback, initial auth refresh, and per-cwd OpenAI-log housekeeping are disabled +or deferred. Session registration and metadata/UI replay may proceed, but the +ordinary ACP fallback that initializes Gemini before storing a Session is also +disabled. Cwd-rooted file-history hydration/validation and restore finalization +are deferred by the same internal activation switch. Managed relocation creates +child-rooted file discovery, refreshes +memory, and may start the existing MCP reconcile only after the target is the +validated child. The binding commit calls the existing Gemini initialization; +that strictly warms tools, builds the initial history and system instruction, +and invokes the `SessionStart` hook from the child. It then performs initial +auth, so the asynchronously scheduled `AuthSuccess` hook also observes the child, +hydrates/finalizes file history from the child Config, and installs the ACP +filesystem wrapper and log housekeeping before promoting the guard. Hook +failures retain the core's existing best-effort logging semantics and do not +become standalone-fatal errors. Successful steps are tracked idempotently, so +response-loss retries do not repeat tool/chat initialization, hook invocation or scheduling, +or registrations. The entry is marked `activating` before the first +non-best-effort step. A returned activation error marks it +`activationPoisoned`; that ACP Session must close, and an unprovable close or +concurrent attach that prevents zero-attach close quarantines the runtime rather +than reusing unknown state. A transport-level response loss is retried against +the same entry first. The CLI Session keys a `bindingPromise` by expectation and +session epoch, so concurrent calls for that key join one activation and an +in-flight different key is rejected; a settled retry reads the recorded bits. +After a completed cycle, only a new expectation installed by successful managed +relocation may start a repair cycle, and completed initial-activation bits are +not replayed. Poisoned state cannot start another cycle. The daemon performs only +one bounded retry/status read, then quarantines if the channel or outcome remains +unknown. Activation state, rather than network ambiguity, decides whether work +continues. Failures before `activating` leave the pending entry retryable. The +commit leaves an `automaticWorkHeld` latch set even after guard and artifact +promotion. After the daemon's final pin check records a matching, unreleased +binding, an idempotent release revalidates the ready identity and session epoch, +clears the latch, starts the scheduler and queued automatic work, publishes the +filtered command set, and schedules the existing MCP failure surface exactly +once. Only the confirmed response promotes the daemon record to reusable +`agentBound`; preflight rejects its unreleased phase. The daemon keeps runtime +activity and lifecycle admission until release succeeds; an explicit release +failure or identity change clears the local binding, while a still-unknown +response follows the same quarantine rule because automatic work may already +have started. This preserves +deferred discovery warning behavior without duplicate output on retries and +prevents automatic work from running inside a transaction that the daemon may +still reject. Stale-worktree cleanup and auto-skill +curation remain disabled because those project-maintenance features are not +standalone behavior; native LSP and its slash command remain unavailable +because the service cannot be relocated. Read-only shared settings, hooks, +extensions, skills, and ancestor +instructions, plus process-global capability probes proven not to inspect cwd, +may still be assembled from the Conversations root. This prevents pre-prompt +file/Git discovery, model-context construction, hook execution, subprocess, +project mutation, cleanup, or local-read fallback from treating the shared root +as the session workspace. Existing Live and ordinary initialization is +unchanged. + +Team-memory and auto-skill management remain disabled for a normalized +standalone Config even after relocation, overriding project settings and +environment toggles. Team memory can discover and synchronize an ancestor Git +repository, while auto-skill management mutates project skill state; neither is +honest standalone behavior. Managed auto-memory may use the private child, and +explicit user/shared skills remain readable. Agent and fork work also remains +available in the child, but worktree isolation, a pinned `working_dir`, and the +enter/exit-worktree tools fail before Git or filesystem side effects. Explicit +shell commands outside this product integration continue through the ordinary +approval boundary. + +Workflow execution is also disabled for normalized standalone sessions, +overriding settings and environment enablement before tool registration. Its +snapshot and resume journal use the Config storage project directory rather +than the relocatable cwd, which would merge unrelated sessions in the shared +Conversations namespace. The ACP `/workflows` command is hidden and rejected +before listing that store. Source normalization occurs before Config +initialization and tool registration, so a standalone Session cannot acquire a +running Workflow that would need a separate relocation rule. User/global settings and user-authored Conversations-root configuration continue to apply. A child may inherit ancestor `QWEN.md`/`AGENTS.md` and shared Conversations-root MCP/config state. Primary-project settings, memory, Git state, trust, and cwd must not leak. The design must not describe shared user-level or Conversations-root configuration as per-session private. +The internal ACP slash-command policy makes this distinction explicit. It keeps +default/user-global language, authentication, and generic user-setting edits, +but rejects session reset, workspace directory management, Git diff, project +skill learning/curation, project-scoped language or config import, explicit +model persistence, and cwd-derived transcript commands for normalized +standalone sessions. In particular, `/dream` and `/export` cannot accidentally +look for the Conversations-owned transcript under the private child. Safe +child-local commands such as init, summary, managed memory, and stats export +remain available after the cwd guard is ready; shared hooks, extensions, and +skills expose only their existing read-only ACP views. The dispatcher checks +the canonical built-in identity before action dispatch and uses the same +predicate for pushed/status command snapshots and model-invocable registration, +so an alias or alternate consumer cannot restore a denied command. The policy +is supplied only by the ACP Session from trusted source state; request metadata +cannot weaken it. Live, ordinary workspace, and other non-interactive callers +retain their existing defaults. ### Permission boundary @@ -290,8 +487,19 @@ the primary runtime. The daemon advertises `standalone_sessions_v1` in `GET /capabilities` only when the complete manager, service, route, and managed-directory lifecycle dependency set is installed, including embedded `createServeApp` configurations. A build -constant alone is insufficient. PR0 through PR2 remain behaviorally hidden; PR3 -is the atomic advertisement boundary. +constant alone is insufficient. PR0 through PR2 do not expose the dedicated +standalone API, capability, SDK, or UI; PR2B does migrate the existing +projectless Live task path to explicit standalone persistence and private +directories and atomically applies the source-aware generic mutation +restrictions to explicit and legacy projectless sessions. Those changes require +focused compatibility and E2E coverage. PR3 is the atomic standalone-v1 +advertisement boundary. + +Existing active owner-routed session controls continue to operate by session +ownership during PR2, but the new source classifier must not broaden generic +cold transcript, export, archive, unarchive, delete, organization, or catalog +access to explicit standalone sessions. Those lifecycle surfaces remain behind +the dedicated PR3 API boundary. The capability is not coupled to Live Voice availability or enablement and describes support rather than current cross-daemon ownership availability. Root @@ -333,15 +541,22 @@ interface CreateStandaloneSessionRequest { The wire-level UUID is required and validates as UUID v1 through v5. An SDK convenience method may omit it only if the SDK generates the UUID before sending -the request. The daemon fixes `sessionScope` to `thread` and source to -`standalone`. Unknown keys are rejected, including `cwd`, `workspaceCwd`, +the request. Wire IDs, lifecycle locks, and in-flight maps use lowercase +canonical UUIDs. For compatibility with legacy transcripts whose filename +contains a mixed-case UUID, storage and ACP operations preserve that +authoritative spelling, including the private-directory hash. If more than one +persisted spelling maps to the same canonical UUID, exact lookup fails with a +conflict and listing excludes the ambiguous entries; the daemon never chooses +one by filesystem enumeration order. The daemon fixes `sessionScope` to +`thread` and source to `standalone`. Unknown keys are rejected, including `cwd`, `workspaceCwd`, `workspaceId`, `sourceType`, `sourceId`, `sessionScope`, `branch`, and `worktree`. `GET /standalone/sessions/:id` is the non-mutating exact-identity lookup used for response-loss recovery and deep links: -- Return `202` with `state: "creating"` while the UUID reservation is in flight. +- Return `202` with `state: "creating"` while the UUID reservation is in flight + or terminal runtime quarantine has frozen the transaction. - Return `200` with an active or archived summary when a compatible transcript exists. - Return `404 standalone_session_not_found` when the UUID is absent or belongs @@ -351,6 +566,12 @@ response-loss recovery and deep links: - Return structured ownership, root, or compromise errors when lookup cannot be performed safely. +Exact lookup never writes durable state. A non-quarantined transaction that has +already persisted explicit standalone source releases its process-local +reservation when it exits and leaves the durable transcript intact, so exact +lookup follows the ordinary `200` path. It never tries to reconcile a +quarantine-frozen entry. + Load and resume use `Omit`: they retain the existing approval, history-page, and client timeout options while the route selects the owner runtime and private directory. Repair has no request body. @@ -444,39 +665,83 @@ logical transaction: 5. Create the ACP session with thread scope and standalone source metadata. 6. Require the ACP result to use the reserved UUID and report `sourcePersisted: true`. -7. Relocate the session into its private directory using managed containment. - Directory or containment failure is fatal; memory, MCP, or model-context - refresh failures after a successful target switch are explicit warnings. -8. Commit the durable session before attempting to write the HTTP response. - -Before source persistence, failure closes the ACP session, releases the UUID, -and removes only an empty child after closure succeeds. If ACP-session closure -fails, the UUID remains reserved as `creating`, the Conversations runtime is -quarantined, and its shared ACP child is torn down to eliminate the unpersisted -orphan before the UUID can be released. Exact lookup returns -`202 state: "creating"` until teardown confirms that no orphan remains, then -returns `404`; a connected create request receives -`500 standalone_creation_outcome_unknown` with the UUID and must poll exact -lookup rather than retry create. If pre-persistence cleanup completes, the -connected request returns `500 standalone_creation_rolled_back` with the UUID -and is safe to retry with that UUID. After source persistence, transcript -existence is the durable outcome marker. Under the lifecycle lock, the daemon -first closes the ACP session, removes only an empty child, and then attempts -orphan transcript cleanup. Cleanup is complete only after ACP session teardown -succeeds, the empty child is removed, the orphan transcript is removed, and the -UUID reservation is released. Complete cleanup returns -`500 standalone_creation_rolled_back` with the UUID and is safe to retry with -that UUID. If ACP-session closure or transcript cleanup fails, or the process -crashes, the daemon preserves the transcript and UUID and reports -`500 standalone_creation_outcome_unknown` with the UUID so the client can query -exact identity. A relocated child that is non-empty or cannot be removed is not -deleted, and transcript cleanup is not attempted. The daemon preserves the -transcript, child, and UUID and returns the same outcome-unknown result; exact -lookup exposes the partial but loadable session. -Once source persistence has succeeded, transcript deletion is not attempted -unless ACP session teardown and empty-child removal have both succeeded; a -partial unwind therefore remains discoverable by exact lookup. The design does -not claim rollback atomicity beyond the transcript store's actual behavior. +7. Re-read the transcript location and source through `SessionService`. Require + one active transcript whose authoritative storage ID matches the reserved + UUID and whose persisted source is explicit standalone. The bridge receipt + alone never authorizes workspace activation. +8. Relocate the session into its private directory using managed containment. + Directory or containment failure is fatal. Memory or MCP refresh failures + after a successful target switch are explicit warnings. A fresh binding + builds model context during deferred Gemini initialization, so that failure + is fatal activation; only an already-initialized session repair can report a + sanitized model-context refresh warning. The daemon then commits deferred + activation while automatic work remains held, validates the pinned identity + again, records an unreleased matching epoch, and invokes the idempotent + release. Only a confirmed release promotes the record to reusable + `agentBound` and permits prompts or automatic work. +9. Commit process-local creation state and invalidate the catalog cache before + attempting to write the HTTP response; no further fallible durable or + workspace operation occurs after an initial prompt is admitted. + +Before source persistence, failure closes any owned ACP session and releases the +UUID after closure succeeds. The deterministic empty child is retained and may +be reused by a later create with the same UUID. PR2 does not attempt to remove a +standalone child: Node exposes only path-based directory removal, which cannot +atomically bind deletion to the inode validated earlier, so a same-path +replacement race would make an “exact identity” cleanup claim false. If +ACP-session closure fails before a durable standalone marker exists, the UUID +remains reserved as `creating`, the Conversations runtime is quarantined, and +its shared ACP child is torn down to eliminate the unpersisted orphan before the +UUID can be released. Quarantine is +terminal for the current daemon: the triggering transaction performs no further +private-directory or transcript cleanup after quarantine begins, every creation +already in flight remains frozen, and exact lookup for those UUIDs returns +`202 state: "creating"` until daemon shutdown. After restart, normal ownership +acquisition and persisted lookup converge each UUID to `200` or `404`; the +quarantined daemon never invents either result after losing its runtime. A +connected create request receives `500 standalone_creation_outcome_unknown` +with the UUID and polls exact lookup rather than retrying create. If the +pre-persistence close completes without quarantine, the connected request +returns `500 standalone_creation_rolled_back` with the UUID and is safe to retry +with that UUID; the retained empty child is reused. + +After persistence, only a durable reread proving one active explicit standalone +transcript makes transcript existence the outcome marker. PR2 never deletes that +verified transcript or its private child as part of creation unwind. If the +owned ACP session closes cleanly, the daemon releases its local creation state, +preserves the partial but loadable session, and reports +`500 standalone_creation_outcome_unknown`; ordinary exact lookup returns `200` +and load/resume completes directory repair and binding. If the child explicitly +refuses close while the binding state proves activation never began, the daemon +may likewise preserve the guarded pending live session and let a later load +retry binding; its turn guard still rejects work. A wrong source, conflicting +location, unreadable metadata, activation that has started or become poisoned, +or an unknown release outcome is not safe for that recovery and requires +terminal quarantine rather than releasing the UUID around unqueryable or +partially activated state. This conservative rule avoids turning a recoverable +session into an untracked non-empty directory and leaves intentional user +deletion to PR3's journaled lifecycle. + +“Before source persistence” clean rollback requires proof, not merely a missing +bridge response. If `spawnOrAttach` was dispatched and its response is lost, an +absent transcript does not rule out a live ACP entry that has not persisted its +source yet, and a later summary lookup can race that still-running creation. +The daemon therefore treats every dispatched call without a trusted response as +outcome-unknown, triggers terminal quarantine, and preserves the UUID, child, +and any transcript. Only a failure explicitly reported before dispatch may use +the ordinary absence proof for clean rollback; PR2 does not add a second +starting-state or request-order protocol for this edge case. +Once source persistence has succeeded, the transaction does not attempt +creation rollback through transcript or directory deletion. A partial unwind is +therefore discoverable immediately by ordinary exact lookup instead of requiring +a process-local cleanup-reconciliation state. + +Quarantine teardown progress remains part of the daemon's runtime-lifecycle +proof. Shutdown continues or waits for safe incomplete drain/dispose steps and +aggregates any terminal failure. The daemon actively removes its owner record +only after runtime disposal and registry/controller completion are proven; an +unresolved containment failure leaves the record for dead-owner recovery after +the old process exits. It never reopens admission or republishes the runtime. Client disconnect does not abort the logical transaction. If relocation commits but the response cannot be written, detach the phantom response client without @@ -498,28 +763,59 @@ coordinator and establishes the new validated child identity before returning. A suspicious existing path fails closed and is never chmodded, replaced, or deleted. +Persisted source ownership alone does not authorize attachment to an already +live bridge entry with the same UUID. Before load/resume can attach or apply +model/approval options, it verifies the live summary's authoritative storage +ID, normalized source, parent lineage, and event generation against the durable +fact and daemon record, then verifies the returned identity again. A Live, +foreign, malformed, or replaced entry is rejected before relocation and is +never adopted as standalone. + Before every standalone prompt is admitted, revalidate the root, exact child, and current session cwd while holding the shared lifecycle admission boundary. If the child disappeared, return `409 working_directory_missing` without dispatching the prompt. The UI offers explicit repair and never replays a prompt whose commit status is uncertain. +The same preflight applies on both REST and ACP owner surfaces to direct shell +execution and session-artifact list/add, and on their applicable surface to +background fork-agent launch and file-restoring rewind. These operations are +cwd-bound even when they are not ordinary model turns. History-only rewind, +artifact metadata removal, and tool-free side generation do not require a +working directory. Generic session `cd` and the ACP session's `/cd` +slash command are rejected for explicit and legacy standalone sessions; only +daemon-managed relocation and the repair operation may change their effective +cwd. The ACP guard owns this source-aware restriction rather than relying only +on the generic command-mode filter. + Repair acquires the exclusive lifecycle coordinator, closes new prompt admission, waits for the active prompt to settle or cancel, restores a valid staged child when required, recreates only an absent child, reapplies relocation, -and returns the resulting working-directory state. +and returns the resulting working-directory state. Relocation also checks the +ACP child's cwd-bound background work under its close gate after active turns +drain. The check includes active-work holds plus running Monitors, which the +health protocol intentionally does not report. Workflow cannot be registered +for a normalized standalone source. Any +blocker returns retryable `409 session_busy`; the daemon does not refresh the +directory identity guard while such work may still refer to the previous +directory. ### Durable cron boundary -ACP currently starts the cron scheduler before managed relocation. Project-level -durable cron state would initially bind to the shared Conversations root, so -standalone MVP must not load, create, or fire durable scheduled tasks there. +ACP currently starts the cron scheduler before managed relocation. Standalone +defers scheduler startup and every automatic-turn producer until the daemon has +recorded the final binding and the post-binding release succeeds. Project-level +durable cron state would otherwise bind to the shared +Conversations root, so standalone MVP must not load, create, or fire durable +scheduled tasks there. - Normalize explicit and legacy standalone source before ACP session startup. - Disable durable cron initialization for standalone sessions and children. - Reject `cron_create({ durable: true })` with a clear unsupported error. - Keep session-only cron and loop wakeups because they are in-memory and die - with the session. Live behavior remains unchanged. + with the session; queued work begins only after binding is finally recorded + and released. Live + behavior remains unchanged. Per-standalone durable scheduling requires a separate design for relocation, archive, deletion, restart ownership, and UI management. @@ -667,9 +963,10 @@ child as belonging beside an older staged child. | UUID creation is currently in flight | Exact lookup returns `202 state: "creating"` | | Private child disappeared before prompt | `409 working_directory_missing` | | Existing managed path fails validation | `409 working_directory_compromised` | +| Active work prevents safe relocation or identity refresh | `409 session_busy`, retryable | | Deletion journal or staged state is inconsistent | `409 deletion_recovery_compromised` | -| Create crossed persistence and cleanup completed | `500 standalone_creation_rolled_back` with UUID | -| Create failed before persistence and cleanup completed | `500 standalone_creation_rolled_back` with UUID | +| Create crossed persistence and owned session closed | `500 standalone_creation_outcome_unknown` with UUID | +| Create failed before persistence and owned session closed | `500 standalone_creation_rolled_back` with UUID | | Transcript deletion failed and directory state recovered | `500 transcript_deletion_failed` | | Transcript or sidecar deletion outcome is partial/unknown | `500 transcript_deletion_outcome_unknown` | | Transcript rollback cannot restore staged child | `500 working_directory_recovery_failed` | @@ -749,6 +1046,8 @@ lazily ensured without enabling Live or starting the ACP child. ### PR1: Runtime ownership and isolation +Implementation PR: [#9181](https://github.com/QwenLM/qwen-code/pull/9181) + Suggested title: `fix(cli): Harden the Conversations runtime boundary` - Add the cross-daemon owner record, stale-owner recovery, legacy Live-owner @@ -776,12 +1075,13 @@ Suggested title: `feat(cli): Add standalone session creation and restore` - Add reserved explicit standalone source, compatible legacy normalization, explicit child inheritance, and top-level filtering. -- Add a focused `StandaloneSessionService` for required-UUID creation, exact - lookup, listing, load, resume, directory repair, prompt preflight, and - working-directory warnings. -- Add the per-session lifecycle coordinator needed for shared prompt/load - admission and exclusive repair; PR3 extends the same coordinator to the - remaining lifecycle mutations. +- Add a focused `StandaloneSessionService` for required-UUID creation with an + initial prompt, exact lookup, listing, load, resume, internal directory + repair, prompt preflight, and working-directory warnings. PR3 exposes + prompt-less create and explicit repair only when their public routes exist. +- Extend the existing per-session lifecycle coordinator with waiting exclusive + repair/create admission; PR3 extends the same coordinator to the remaining + lifecycle mutations. - Implement the persistence-boundary-aware creation transaction and response-loss semantics. - Route projectless Live task creation through the standalone service. @@ -789,12 +1089,31 @@ Suggested title: `feat(cli): Add standalone session creation and restore` retaining session-only cron. - Keep the public capability absent until PR3 completes the lifecycle contract. +PR2 is one logical phase and is delivered as two mandatory serial review units: +PR2A lands source, directory-identity, and persisted-ID resolution primitives +without normalizing legacy ACP sessions; PR2B atomically lands +managed-relocation identity propagation, the ACP turn/cron guards, generic +standalone mutation denials (including ACP slash reset/workspace/Git/storage/ +skill/model boundaries), provisional file/tool/Gemini bootstrap and cwd-side- +effect deferral, +project-permission persistence denial, lifecycle-wait, runtime quarantine, the +service, and Live-task/sub-session adoption. This keeps every identity-wire +field paired with production writers and avoids a partially guarded legacy +intermediate state. Neither unit advertises the capability. PR3 adds +deletion-journal reconciliation to the same service before registering public +routes. + Verification covers the source/owner matrix, UUID conflicts, every creation -failure boundary, response disconnect before/after persistence, exact lookup -`202/200/404`, missing/compromised children, concurrent prompt/repair admission, -children, Live task compatibility, and durable-cron denial. +failure boundary, caller cancellation without transaction cancellation, exact +lookup `202/200/404`, missing/compromised children, active-work-safe relocation, +concurrent prompt/repair admission, children, Live task compatibility, and +durable-cron denial. PR3 adds transport disconnect coverage with the public +route adapter. -Estimated size: 450-750 production lines and 850-1,400 test lines. +Audited estimate: 1,720-2,500 production lines and 3,400-5,050 test lines across +PR2A and PR2B. The two serial review units are mandatory at this size; a lower +implementation count does not justify collapsing their source/isolation and +service/lifecycle review boundaries. Exit criterion: the core service creates and restores standalone sessions without primary fallback, but clients are not yet told that the full v1 @@ -922,8 +1241,8 @@ merged by PR #8882. PR #8874 (workspace uploads) and PR #8817 (fork/move foundations) are follow-up dependencies rather than MVP blockers. No capability is advertised before PR3. -Expected total implementation size is approximately 2,500-4,400 production -lines plus 5,050-8,150 test lines. The companion document is excluded from +Expected total implementation size is approximately 3,800-6,170 production +lines plus 7,600-11,800 test lines. The companion document is excluded from those totals. Capability advertisement is the atomic rollout boundary: partial internal stages remain unavailable to SDK/WebShell clients until PR3 completes the daemon contract. @@ -950,6 +1269,36 @@ the daemon contract. first ACP use, the runtime owns one healthy child in steady state. - Multiple standalone and Live sessions share the child without cwd, event, permission, transcript, source, or model-state leakage. +- A standalone model change publishes only its session-scoped model event and + never tells another standalone or Live session that the Conversations + workspace default changed; Live and ordinary workspace broadcasts are + unchanged. +- Standalone bootstrap does not create cwd-rooted file discovery, warm + cwd-sensitive tool factories, initialize Gemini/chat or its system instruction, + select project output language, refresh project/team memory, sync/probe project + Git, run `SessionStart` or `AuthSuccess`, start native LSP or MCP, run + auto-skill/worktree maintenance, install ACP local-read fallback, or schedule + per-cwd log cleanup against the Conversations root. Cwd-rooted file-history + hydration and restore finalization are also deferred. Supported file discovery, + tool/chat initialization, memory, auth, file history, MCP, filesystem, and + housekeeping activation begins only from the validated child; user-global + language and the documented shared-config reads remain available. Successful + binding and response-loss retry schedule each hook and registration once. + Automatic work and the deferred MCP failure surface remain held until the + daemon's final identity check records the matching session epoch and an + idempotent release succeeds. A partial + non-best-effort activation closes the entry or quarantines the runtime. Hook + outcomes retain their existing best-effort semantics. LSP and project + maintenance stay disabled. +- Team-memory and auto-skill source gates override settings/environment state; + Agent worktree isolation, pinned working directories, and enter/exit-worktree + tools are denied before Git or filesystem mutation, while ordinary child-local + Agent/fork work remains available. +- Settings and daemon argv include directories are ignored for standalone + Configs; after relocation the exact private child is the only WorkspaceContext + root, so tool `directory` parameters cannot recover an ambient project path. +- Workflow settings/environment cannot register the workflow tool for a + standalone Config, and `/workflows` cannot read the shared snapshot store. - Two supporting daemons contend safely; dead-owner reclaim, PID reuse, corrupt owner records, and shutdown races follow the specified failure semantics. - Explicit standalone, compatible legacy, Live, unrelated source, top-level, and @@ -968,8 +1317,9 @@ the daemon contract. disconnect, cleanup, and outcome-unknown boundaries are fault-injected. - Exact lookup returns creating, existing, or absent without mutation or primary fallback. -- Active and archived sessions list/load/resume across restart and retain the - deterministic path. +- Active sessions list/load/resume across restart and retain the deterministic + path. Archived sessions remain visible to list/exact lookup and require + unarchive before load/resume, matching the existing daemon archive contract. - Missing child recreates with warning; link/junction, wrong owner, unsafe POSIX mode, non-direct child, root change, and identity race fail closed. - Prompt preflight rejects missing/compromised children before dispatch; repair @@ -1001,6 +1351,18 @@ the daemon contract. upgrade rejects the internal runtime. - Primary project settings, memory, Git state, trust, and cwd do not leak; shared user and Conversations configuration follows the documented boundary. +- Standalone ACP command projection and dispatch use one canonical deny predicate: + workspace/session-reset, Git diff, project-skill management, and cwd-derived + transcript commands are absent and fail before their actions; supported + child-local and user-global commands retain their documented behavior. +- Standalone permission prompts, including nested sub-agents, cannot persist a + project rule into the Conversations root; user-global permission persistence + remains available and does not mutate another session's in-memory rule set. +- Workspace-backed session artifacts remain deferred before relocation and use + only the validated private child afterward; restore, replay, list, and upsert + never stat or hash paths relative to the shared Conversations root. REST and + ACP artifact list/add both require the shared cwd preflight; metadata removal + does not. - macOS/Linux cover owner, mode, identity, restart, rename, journal, and deletion semantics. - Windows covers canonical path, symlink/junction/reparse behavior, open-handle diff --git a/docs/plans/2026-08-14-standalone-pr2-core.md b/docs/plans/2026-08-14-standalone-pr2-core.md new file mode 100644 index 00000000000..ad88bee903d --- /dev/null +++ b/docs/plans/2026-08-14-standalone-pr2-core.md @@ -0,0 +1,634 @@ +# 实施计划:Standalone PR2 —— Session core 与私有目录隔离 + +日期:2026-08-14 + +上游设计:`docs/design/standalone-daemon-sessions.md` + +关联:Issue #8908、PR0 #8890、PR1 #9181 + +设计与实现审计基线:`origin/main` at `7091b8c76157501fab5761f96dafbc1612723456`。PR1 已通过 [#9181](https://github.com/QwenLM/qwen-code/pull/9181) 合入,merge commit 为 `889f0d8bbdf24ed55b32061cac3db7451afd80c0`。2026-08-17 integration checkpoint 已直接读取最终 main,而不是把脏设计分支先 rebase 到预期接口上。 + +Integration checkpoint 锁定以下最终接口与增量: + +- `ConversationRuntimeManager` 当前只公开 one-flight `ensure()`;它会取得 ownership、重验 root,并要求 registry 中 exact cached runtime 仍为 active/current。PR2B 才增加无 I/O 的 `assertCurrent()`和terminal `quarantine()`。Standalone service 的 `ensureRuntime` 必须直接注入manager `ensure()`,不能复用server的`ensureLiveConversationRuntime()`,后者还会绑定三个Live handler、触发Appshot/feature publication并受Live enable/seal状态约束。 +- PR1 只构造一个server-owned `ConversationRuntimeActivityGate`,现有API为`run()`与`sealAndWait()`。它已覆盖Conversations list及部分internal workspace mutation,但没有覆盖owner-routed prompt、continue、shell、fork-agent、rewind、artifact或ACP active-session操作;PR2B必须在这些handler进入bridge/filesystem前显式复用同一个gate,不能假设PR1 wrapper已经代劳,也不能在service里创建第二个gate。 +- PR1 已允许internal runtime上的active owner control,并把REST transcript branch与side-task扩为internal owner-routed;PR2B必须按source在对应handler内拒绝standalone,不能依赖primary-only routing。REST `/session/:id/fork`是当前session内的background fork-agent,属于受cwd guard保护的支持路径,不得与transcript branch混淆。ACP `session/fork`在Conversations mount仍由`liveSessionIsolation`整体拒绝,保持该边界。 +- `SessionService.findSessionIdIgnoringCase()`现有ACP child load/resume consumer会先走exact `sessionExists()` fast path,`RequestedSessionIdAdmission`也会先走exact location;这会漏掉lowercase exact与uppercase twin并存的case-only duplicate。PR2A必须移除所有resolver consumer的exact bypass,并让唯一resolver在active/archived两个目录收集完整候选后返回authoritative spelling或抛typed conflict。 +- `killSession(..., { requireZeroAttaches: true })`现在会在child明确拒绝close时返回`false`并保留session,而不是升级为channel kill。PR2B cleanup必须把`false`视为“未证明关闭”;activation poison、dispatched spawn ambiguity或其他要求terminal containment的路径不能在该结果后删除目录/transcript或释放UUID,必须进入既定quarantine流程。 +- `9f8f65dde0`增加durable Assistant-response transcript branching,但没有改变上述产品边界:REST transcript branch和ACP `session/fork`仍是独立的新transcript产品;background fork-agent仍是在当前standalone session私有child内运行的cwd-bound work。`4257916e7e`增加daemon Git worktree mutation guard,不能替代standalone对Agent `working_dir`/worktree isolation与enter/exit-worktree工具的source-aware deny。 + +依赖:PR1 Conversations runtime ownership 与 ordinary-workspace isolation 已满足。PR2A从上述main基线开始;PR2B开始前再次刷新main并重建source/create/prompt/automatic-turn consumer inventory。 + +## 结论 + +PR2 是完整 standalone daemon API 出现前的内部核心阶段。它让 daemon 能在唯一的 Conversations runtime 中安全创建、识别、恢复和运行 standalone session,但不注册 `/standalone/sessions` 路由,不声明 `standalone_sessions_v1` capability,也不增加 SDK 或 UI。PR2B 会把现有 projectless LiveTask 创建迁移为 explicit standalone source和私有目录,并在legacy projectless session上收紧generic cd/branch/side-task与persisted approval mode;这些都是既有内部表面的用户可观察变化,必须执行下述E2E计划,但不等同于公开 standalone v1。 + +深度盘点后的生产逻辑预计为 1,720–2,500 行、测试为 3,400–5,050 行。为保持 review 边界,PR2 作为一个逻辑阶段交付为两个串行、可独立回归的 PR: + +- **PR2A — source 与 directory primitives**:280–420 行生产逻辑,550–850 行测试。 +- **PR2B — containment 与 standalone session service**:1,440–2,080 行生产逻辑,2,850–4,200 行测试。 + +PR2B 依赖 PR2A;二者都依赖 PR1。它们不与 PR1 以 stacked PR 形式同时送审,也不修改 PR3 的公开 API/lifecycle 范围。当前估算已超出单 PR 的可审核范围,因此 PR2A/PR2B 拆分是必须的;不以压缩测试、隐藏辅助逻辑或合并职责来追求原估算。 + +## 已锁定的语义修正 + +### Archived session + +沿用 daemon 现有 archive contract:archived standalone 可以 list 和 exact lookup,但 `load`/`resume` 返回既有 `session_archived`,必须先由 PR3 的 unarchive 操作恢复为 active。PR2 不引入“直接恢复 archived transcript”的第二套语义。 + +### Deletion journal + +PR3 才创建 deletion journal 和 staged directory。PR2 没有 delete 入口,因此 PR2 的 create/load/repair 不读取不存在的 journal namespace,也不预埋 no-op recovery abstraction。PR3 必须在 capability 发布前,把 journal reconciliation 加到同一个 service 与 lifecycle coordinator 的 create/load/repair 前置区。 + +### Managed relocation token + +继续复用内部 token `managedRelocation: "live-conversation"`。它实际表示可信 private ACP parent 对 Conversations direct child 的 managed relocation;PR2 不做高风险的协议 token rename。新用户可见错误和代码符号使用中性 `conversation`/`standalone` 名称,历史 token 与现有 Live 错误文本保持兼容。 + +### Runtime quarantine + +创建在 session 尚未安全收口时若 ACP session 无法关闭,仅保留 transcript 或释放 UUID 都不安全。PR2B 增加一个仅供 Conversations manager 使用的 terminal quarantine seam:关闭 manager admission、从 registry drain internal runtime、dispose bridge/ACP child,并使本 daemon 后续 `ensure()` 固定返回 `conversation_runtime_unavailable`。它不释放跨 daemon owner record;owner 仍由 PR1 的 daemon shutdown gate 释放。 + +## 不变量 + +- Standalone 只存在于已验证且由当前 daemon 持有的 Conversations runtime。`sourceType: "standalone"` 本身不能把 project transcript 变成 standalone。 +- 新 top-level transcript 固定写 `sourceType: "standalone"`,没有 `sourceId` 和 `parentSessionId`。 +- Standalone child 固定写 `sourceType: "standalone"` 和 `parentSessionId`;现有 depth-1 sub-session 限制不变。 +- Live 继续使用 `sourceType: "default"` 和 `sourceId: "realtime_voice:"`;project source 和其他 feature source 不被重新分类。 +- 兼容 legacy standalone 只在 Conversations runtime 中成立:top-level、无 `sourceId`,且 `sourceType` 缺失或为 `default`。只读时归一化,不重写 transcript。 +- Generic REST/ACP creation 对任何 `sourceType: "standalone"` 都拒绝,即使同时携带非法 `sourceId`;只有 standalone service 可创建该 source。 +- PR1 已允许的 active owner-routed session control(prompt、cancel、status、subscribe、permission、close和live metadata)继续按owner工作;PR2不得借新classifier让explicit standalone进入generic cold transcript/export/archive/unarchive/delete/organization或catalog API。完整standalone lifecycle仍由PR3 dedicated routes发布。 +- 每个 standalone session 使用 deterministic direct child。有效现有空目录可在 create 时复用;无 transcript 的非空目录是 conflict,不自动采用、清空或删除。 +- PR2不删除standalone private child。Node当前只有路径式`rmdir`,无法把删除原子绑定到已验证inode;校验后同path替换会让“exact identity delete”误删replacement。持久化前clean rollback因此保留可复用empty child;durable reread已证明active explicit standalone后的失败保留transcript与child,由exact lookup/load收敛。Wrong source/location/metadata proof仍terminal quarantine。PR3 deletion journal再实现有durable阶段证明的用户删除,不把创建回滚伪装成安全删除。 +- `sourcePersisted: true` 不是唯一提交证据。成功返回前必须再次从 SessionService 证明 active transcript 存在且 source 是 explicit standalone。 +- Client/Live-task caller 的取消不会传入创建事务。创建从 UUID reservation 开始后必须运行到 success、clean rollback 或 outcome unknown。 +- Prompt admission 必须同时通过 daemon-side root/child/current-cwd preflight 和 ACP-child turn guard;session-only cron、background notification 等绕过 HTTP 的自动 turn 由 child guard 覆盖。 +- Direct shell、background fork-agent和`rewindFiles: true`同样是cwd-bound work,必须在执行前走同一个daemon preflight;generic session `cd`与ACP Session内的`/cd` slash command对explicit与legacy standalone一律拒绝,只能使用managed relocation或repair。ACP当前command-mode过滤即使已把`/cd`排除,也不能替代source-aware hard gate。 +- Standalone允许普通Agent/fork在private child内运行,但不允许Agent tool的`isolation: "worktree"`或`working_dir` pin,也不允许`enter_worktree`/`exit_worktree`工具。ACP Session在tool build、Git probe、directory creation或subprocess前按trusted tool identity和参数拒绝;shell中用户明确执行Git仍由现有approval边界处理,不把私有目录描述成OS sandbox。 +- Workflow tool的snapshot与resume journal固定写`Config.storage.getProjectDir()/workflows`,而standalone transcript Storage按设计仍属于共享Conversations runtime;不能把它误当child-local。Normalized standalone的`Config.isWorkflowsEnabled()`必须在settings/env判断前固定false,使workflow factory不注册,ACP `/workflows`也在canonical dispatch前拒绝且不读取shared snapshot。Source normalization在Config初始化与tool registration前完成,因此standalone Session不存在需要另设relocation blocker的running Workflow路径。 +- Bridge的session artifact store当前会相对bound workspace执行realpath/stat/hash,ACP restore也会构造cwd-rooted `FileHistoryService`;normalized standalone在managed relocation前不得让artifact restore/replay/list/upsert或file-history hydrate/validation触碰共享Conversations root。Workspace artifact只在exact child绑定后处理,且每次daemon GET/POST artifact操作先走同一个cwd preflight;file-history metadata在binding commit从child Config hydrate并随restore finalize,实际file rewind仍走turn guard。Attachments/uploads仍不进入MVP,但模型生成的私有目录artifact不能因此被错误绑定到root。 +- Generic REST `branch`和`side-task`都是创建新transcript的独立产品语义,不得复用ordinary/Live bridge派生路径处理explicit或legacy standalone;PR2在bridge调用前明确拒绝,避免生成无reserved source或无私有目录的session。它们不等同于在当前session内运行、且受cwd guard保护的background fork-agent,也不等同于本阶段明确支持的`create_sub_session` child。 +- Standalone的approval mode可以按session切换,但generic `persist: true`会写共享Conversations root的workspace setting,必须在persist callback前拒绝;不能让一个standalone session改写Live和其他conversation的默认值。User-global language设置不是project setting,保持现有行为。 +- Tool permission的“Always Allow in project”同样会写共享Conversations root。Normalized standalone的primary和nested sub-agent exec/MCP/info permission request都不提供`ProceedAlwaysProject`,也不接受这些类型上的deprecated `ProceedAlways`作为project outcome。`ProceedOnce`、edit/plan的session-local `ProceedAlways` mode transition以及`ProceedAlwaysUser`保持可用。ACP host返回未提供的project option时继续由offered-option validation拒绝,且必须在tool `onConfirm`、settings callback和in-memory persistent-rule mutation之前失败。Ordinary与Live的permission options和persistence保持不变。 +- Standalone primary model选择同样必须session-local。ACP child的`Session.setModel()`在normalized standalone source下无条件把effective `persistDefault`设为false,覆盖create/attach的`modelServiceId`、HTTP model route和任何ACP caller;request不能重新打开persistence。Bridge的create/attach `applyModelServiceId()`和HTTP `setSessionModel()`仍发布目标session自己的`model_switched`/failure事件,但normalized standalone成功切换不得再广播workspace-wide `settings_changed(model.name)`,否则同一Conversations runtime中的Live和其他standalone session会收到并不存在的共享default变更。不得写任一scope的`model.name`、`model.baseUrl`或`security.auth.selectedType`;Live/ordinary保留现有persistence与workspace event语义。 +- Create-time `modelServiceId`仍可在managed relocation前更新尚未初始化的session Config;这依赖deferred bootstrap保证`contentGeneratorConfig`尚不存在,使现有model-change callback不refresh auth、不构造model context也不执行hook。实现不得通过提前初始化content generator破坏该前提;首次child binding必须用已经选定的model完成唯一一次auth和Gemini/system-instruction初始化。 +- ACP slash command与HTTP control不是同一个入口。Normalized standalone必须给`handleSlashCommand()`传internal execution policy:canonical dispatcher list禁止`cd`、session reset、directory/Git、cwd-derived transcript和project-skill命令;argument-level gate禁止workspace settings与model/reasoning持久化。`/clear|/reset|/new`在hook、background abort、metrics和`Config.startNewSession()`前拒绝;`/directory`在realpath、WorkspaceContext mutation和settings write前整体拒绝;`/diff`、`/dream`、`/export`、`/learn`和`/curator`在各自Git/storage/skill helper前拒绝;`/language ... --project`与`/import-config ... --scope project`在任何write/helper调用前拒绝;`/model --project|--global`以及auxiliary selector(fast/voice/vision/compaction/image)在PR2中拒绝,普通`/model `与`/effort `只修改当前session Config且不写任何scope。安全的child-local与user-global命令保持既有语义。Ordinary、Live及非ACP caller使用policy默认值,行为不变。 +- Config构建和初始化本身也是cwd side-effect边界。Normalized standalone在调用`loadCliConfig()`前建立可信`provisionalWorkspace` host policy并强制`experimentalLsp: false`:loader不构造initial `FileDiscoveryService`、不探测Conversations-root project output-language,并把该policy固化为Config构造时默认false、只读的internal state。Config initialize直接读取这一状态,蕴含skip Gemini,并跳过eager file service、initial hierarchical/managed/team-memory refresh或sync、MCP discovery、strict tool warmup、auto-skill curator和stale-worktree cleanup;不再增加第二个可与loader状态漂移的initialize boolean。Loader仍可只读装配被明确允许的Conversations shared settings/hooks/extensions/skills/MCP配置与transcript storage;这些不能被误称为私有child配置。ACP同样不安装filesystem wrapper、不执行initial auth refresh,也不启动per-cwd OpenAI log housekeeping。LSP在PR2/MVP中明确不可用;现有loader因`isLspEnabled() === false`不注册或广告`/lsp`。Managed relocation在child target生效后才由既有Config relocation创建child-rooted file discovery、刷新memory并reconcile MCP;binding commit在guard ready前由现有Gemini initialization严格warm工具、以child构建system instruction并执行`SessionStart` hook,再完成auth、安装以child Config计算read roots的ACP filesystem wrapper,并启动child-scoped log housekeeping。这样file/Git discovery、project output-language、model context、`SessionStart`/`AuthSuccess` hook、memory/team git、MCP subprocess、LSP process、project maintenance、local-read fallback和log cleanup都没有以共享root作为standalone workspace的窗口。Transcript/harness storage仍属于Conversations runtime,因此`registerSessionProjectDir()`和nested Qwen helper的`QWEN_CODE_PROJECT_DIR`保留该storage project dir;不得把它误改成child。它不改变process cwd、Config target、WorkspaceContext或tool filesystem root,也不构成OS sandbox授权。Live与ordinary初始化保持不变。 +- Settings或daemon argv中的`context.includeDirectories`/`--include-directories`不是允许继承的shared配置。`provisionalWorkspace`必须在loader解析处把两类输入都固定为空,令Config的`explicitIncludeDirectories`为空;managed relocation后的`WorkspaceContext`必须只含exact child。否则Shell/Monitor等tool的`directory`参数会把ambient project path重新变成合法workspace root。该host gate不改变用户在shell命令文本中经approval显式访问绝对路径的既有能力,也不声称OS sandbox。 +- Team memory、auto-skill management和Workflow persistence是project/shared-storage mutation,不因relocation变得安全。Core Config的现有source getter在normalized standalone下固定`getTeamMemoryEnabled() === false`、`getTeamMemorySyncEnabled() === false`、`getAutoSkillEnabled() === false`和`isWorkflowsEnabled() === false`,覆盖settings与环境变量;managed auto-memory仍可在private child使用,显式user/shared skills仍可只读装配。该source gate在`setSessionSource()`后、任何Config initialize/tool registration/refresh前已生效。Live与ordinary保持现有settings/env优先级。 +- 所有未知、冲突、root/child compromised、runtime generation closed 和 quarantine 状态 fail closed,不回退 primary。 +- PR2 不增加 cwd、workspace、project、branch、worktree 或 source override。 + +## PR2A:Source 与 directory primitives + +建议标题:`feat(cli): Add standalone conversation isolation primitives` + +### 1. Source classifier + +扩展 `packages/cli/src/serve/conversations/session-source.ts`,增加: + +```ts +const STANDALONE_SESSION_SOURCE_TYPE = 'standalone'; + +type ConversationSessionKind = 'live' | 'standalone'; + +interface LoadableConversationSession { + kind: ConversationSessionKind; + persistence: 'explicit' | 'legacy'; + metadata: { + parentSessionId?: string; + sourceType?: string; + sourceId?: string; + }; +} + +interface ConversationSessionMetadataStore { + getSessionLocation( + sessionId: string, + ): Promise<'active' | 'archived' | 'conflict' | undefined>; + readCreationMetadata(sessionId: string): Promise; +} +``` + +提供三个窄操作: + +1. `isReservedStandaloneSessionSource()`:只看 `sourceType === "standalone"`,供 generic create gate 使用。 +2. `classifyTopLevelConversationSource()`:同步分类 list/live summary;只有 exact Live、explicit standalone、compatible legacy standalone 三类结果。 +3. `readLoadableConversationSession()`:从 transcript metadata 分类 exact session,并返回给 restore/task/sub-session 调用者。它接收上述existence-aware store,而不是只接收`readCreationMetadata()` callback;后者用空object同时表示“existing legacy transcript”与“missing transcript”,会把deleted legacy parent误判为top-level standalone。Reader必须先检查location,missing/conflict parent都返回unknown,绝不从`{}`猜存在性。 + +PR2A保留现有`readLoadableLiveConversationMetadata()`导出作为薄兼容adapter,改为接收同一个existence-aware store并复用新reader的分类结果,但对现有Live与legacy projectless caller返回PR2前的metadata shape:legacy不能在这一子PR提前被改写成ACP看到的normalized standalone source。它也不能让explicit standalone穿过generic REST/ACP restore。这样PR2A只提供可审查的分类primitive和reserved-source gate,不在daemon preflight/service存在前部分激活containment。PR2B再把explicit standalone cold restore以及generic legacy standalone兼容恢复迁移到service:generic REST/ACP只调用`restoreLegacyForCompatibility()`窄入口,该入口在任何materialize/bridge调用前重读并要求`kind: "standalone"`且`persistence: "legacy"`,并从此处开始把legacy source归一化为ACP所见的standalone;explicit standalone仍只允许dedicated service consumer。Live和legacy-Live-child继续走Live adapter。若grep确认旧adapter只剩Live consumer则收窄为Live-only或删除无调用导出,不同时维护两套分类规则。 + +Generic legacy restore在调用reader前也必须通过唯一case-insensitive resolver把canonical caller ID解析为authoritative storage ID。Archive/lifecycle admission继续使用canonical ID;metadata、bridge session ID和conversation-directory hash统一使用storage spelling。这样现有mixed-case transcript不会先被legacy route绑定到lowercase hash目录、再被PR2B service切换到另一目录。仅大小写不同的重复transcript在任何materialize/bridge调用前fail closed。 + +Lineage规则固定为当前daemon支持的depth 1,同时保持父子lifecycle独立: + +- top-level explicit standalone:`standalone` 且无 `sourceId`。 +- top-level legacy standalone:无 parent/sourceId,type 缺失或 `default`。 +- explicit standalone child:`standalone`、无`sourceId`、有非self且语法有效的parent ID。它的reserved source与已确认的`parentSessionPersisted`使其自描述;read时不要求parent transcript仍存在,因此parent archive/delete后child仍可独立load。Depth-1由创建时parent summary gate和任何child的非空`parentSessionId`共同强制,explicit child不能再spawn child。 +- legacy child:有parent且完全没有source;必须读到top-level standalone或compatible Live parent才能分类,并拒绝grandparent/cycle/self。父transcript已删除的legacy child因无法消歧而返回unknown,不猜测上下文。 +- child携带其他source、explicit standalone带sourceId、invalid/self parent、legacy parent不存在、grandparent、循环或source/sourceId不配对都返回unknown。 + +Standalone 的 normalized restore metadata 始终带 `sourceType: "standalone"`。PR2B的service adoption保证legacy restore进入ACP Config前就被识别,durable cron guard生效,但transcript不被改写。`persistence`只供daemon分类/admission,绝不传给ACP或写入transcript。PR2A compatibility adapter和Live metadata保持原样。 + +修改 generic create 的两个边界: + +- `packages/cli/src/serve/routes/session.ts` +- `packages/cli/src/serve/acp-http/dispatch.ts` + +两者都先检查 raw `sourceType`,再调用通用 source parser;因此即使 request 同时带非法 `sourceId`,也在 bridge、UUID reservation 和 runtime mutation之前按 reserved standalone source拒绝。Catalog source filter不被当成创建入口,不需要禁止查询该字符串。 + +PR2A不改变legacy projectless session的现有runtime行为。Generic REST `cd`、`branch`、`side-task`、approval-mode `persist: true`拒绝,与legacy normalization、managed identity writer和daemon cwd preflight在PR2B同一子PR原子启用,避免先把legacy session置为standalone却留下direct-shell保护缺口。Explicit standalone在此之前没有受支持的create/restore consumer;generic create与restore仍拒绝它。 + +### 2. Conversation directory identity + +把 `ConversationWorkspace` 当前 root/direct-child 校验提取到 `packages/cli/src/utils/conversation-directory-identity.ts` 的纯安全 primitive,供 daemon 和 ACP child 复用。它不能依赖 `serve/`、Express、registry 或 ACP protocol 类型,避免形成 `acp-integration → serve` 的反向层依赖。`ConversationRootIdentity` 一并移到该中性模块。新增类型: + +```ts +interface ConversationDirectoryIdentity { + root: ConversationRootIdentity; + storageSessionId: string; + name: string; + canonicalPath: string; + device: number; + inode: number; +} +``` + +`ConversationWorkspace` 新增窄方法: + +- `prepareStandaloneDirectory(sessionId)`:返回 `{ identity, created }`;valid existing empty child 可复用,existing non-empty 返回 conflict。 +- `ensureStandaloneDirectory(sessionId, expected?)`:load/repair 使用;与expected同identity的existing返回`ready`,missing创建后返回`recreated`,existing replacement返回compromised。 +- `inspectStandaloneDirectory(sessionId, expected?)`:区分 `ready`、`missing`、`compromised`;给 prompt preflight 使用。 +- 现有`discardEmptyConversationDirectory(sessionId)`保持Live-only兼容实现;standalone路径不调用它。路径式删除无法原子绑定到前一次`lstat`得到的identity,PR2A不增加一个名为exact但仍有replacement race的overload。 + +每个检查执行 root identity revalidation、child `lstat → realpath → lstat`、owner/mode/direct-child/device/inode 校验。传入 `expected` 时,inode/device 变化也是 compromised。Windows 继续只声明现有 API 可验证的 non-reparse/canonical identity,不虚构 POSIX mode/uid 保证。 + +Child validation还必须证明basename等于对authoritative storage ID计算出的deterministic hash;“同一root下任意direct sibling”不满足条件。新建session的storage ID就是lowercase canonical ID;legacy mixed-case restore保留transcript filename spelling。ACP managed relocation用Session自己的storage ID计算expected name,防止server wiring错误把两个standalone session指向彼此的目录。 + +目录检查绝不 `chmod` 既有目录,不跟随 link/junction,不递归删除,不接受 nested child,不把路径写入用户可见错误。`readdir` 只用于 create 的 empty-orphan gate;检查失败不自动清理。 + +Primitive 用 typed scope 区分 `root` 与 `child`,并保留内部 reason 供日志和测试断言。`ConversationWorkspace` 把 root failure 映射为 PR1 的 `conversation_root_compromised`/runtime unavailable,service只把 child missing/compromised映射为 standalone working-directory错误;任何 root error都不得被包装成 session conflict。对外error data不携带canonical path、目录名、device或inode。 + +现有`materializeConversationDirectory()`、`discardEmptyConversationDirectory()`和Live managed relocation继续使用原有错误文本与行为;typed primitive不能借中性化之名改变Live-visible message/status。只有新增standalone service/guard路径映射中性structured code。 + +## PR2B:Containment 与 standalone session service + +建议标题:`feat(cli): Add standalone session creation and restore` + +### 1. Managed relocation identity 与 ACP-child turn guard + +Standalone managed relocation不能让daemon与ACP child各自捕获“当时看到的”身份。内部bridge shape固定为: + +```ts +interface BridgeConversationDirectoryExpectation { + storageSessionId: string; + root: { + canonicalPath: string; + device: number; + inode: number; + }; + child: { + name: string; + canonicalPath: string; + device: number; + inode: number; + }; +} + +interface ChangeSessionCwdRequest { + // Existing fields omitted. + conversationDirectoryExpectation?: BridgeConversationDirectoryExpectation; +} +``` + +Daemon把已经pin住的`ConversationDirectoryIdentity`转换成该expectation;`packages/acp-bridge`只定义结构并把字段原样转发给现有`sessionCd` ext method,不执行filesystem判断。ACP child先按固定字段、绝对canonical path、非空storage ID/name及non-negative safe-integer device/inode做严格schema校验,再要求`allowedRoots`恰好是expectation root、request `path`恰好是expectation child,使用自己的session storage ID重新计算deterministic basename,并在Config mutation前后都要求root、child、platform-canonical path、device和inode与expectation完全一致;daemon收到成功响应后再以原pin执行第三次检查。这样root/child在daemon precheck与child precheck之间、child mutation期间或RPC返回后被替换时,至少一个检查或下一次turn guard会拒绝。Wire expectation只允许`managedRelocation: "live-conversation"`携带,standalone source缺失或malformed expectation直接在filesystem/Config mutation前返回compromised;Live现有managed relocation不安装standalone guard,保持原request和行为。Identity字段不进入日志、warning或HTTP response。 + +该字段在TypeScript层可以为Live兼容而保持optional,但对normalized standalone语义上是required,不能成为无人设置的dead switch。PR2B在同一个子PR中同时增加wire字段和全部生产writer:generic legacy REST/ACP restore、所有被归一化的projectless restore/sub-session relocation、create、load/resume、repair、LiveTask和standalone child路径都必须传入daemon已pin的expectation;Live source明确不传。每次实现审计都要grep全部`managedRelocation`写入点,证明不存在“standalone request无expectation仍到达Config mutation”的生产路径。 + +Daemon preflight无法覆盖 session-only cron、loop wakeup、background notification 和其他 child-internal automatic turn。PR2B 在 ACP session 中安装第二道 guard: + +- CLI `Session` 在构造时读取 `config.getSessionSourceType()`;standalone session立即进入“relocation required”guard状态,在daemon确认binding release前,外部turn返回`working_directory_missing`。Guard和`automaticWorkHeld`必须在构造器调用`#bindGoalRuntime()`、注册background notification/sub-session/workflow callback之前同步建立,使恢复出的Goal或即时registry callback只能排队,不能在构造窗口启动turn。Child-internal automatic producer在该状态下只保留/排队已有work,不消费cron、Goal continuation或background notification,也不把一次预绑定拒绝当成terminal task failure。 +- Standalone `Session`在slash dispatch前按解析出的canonical builtin command identity硬拒绝一组固定命令,并从available-command更新中排除它们:`cd`、`clear`(覆盖`reset`/`new` alias)、`directory`、`diff`、`dream`、`export`、`learn`、`curator`和`workflows`。`cd`/`directory`是workspace管理,`diff`引入Git project语义,`learn`/`curator`依赖PR2明确不支持的project-skill管理;`dream`/`export`当前又从cwd构造transcript storage,迁移后会错误指向private child而不是Conversations storage;`workflows`从不可relocate的shared Storage读取project snapshot。不能只依赖各command当前的`supportedModes`或action内校验,因为通用声明将来可能变化且alias/子命令可能绕过。拒绝不调用command action、不读取Git/transcript/skill/workflow/filesystem、不修改Config,并返回固定无path的`unsupported_action`。`init`、`summary`、`remember`、`forget`和stats export可继续在ready guard后的private child内工作;`skills`、`hooks`和`extensions list`只投影允许的shared read-only配置。 +- `sessionCd`在close gate内按“对daemon提供的exact expectation做pre-validation → drain/blocker check → `Config.relocateWorkingDirectory`(same-path也执行)→ 对同一expectation做post-validation → 原子记录pending guard”的顺序运行。Pending仍阻止external turn并暂停automatic producer,不能在daemon post-check前直接变成ready。Fresh/cold standalone的Gemini尚未初始化,因此现有`addWorkingDirectoryChangedContext()`是no-op,新的child system instruction和`SessionStart` context留给binding commit完整构建;已ready session的same-path repair若Gemini已初始化,则保留既有model-context refresh并把失败作为sanitized warning。若post-validation失败,Config可能已刷新到相同path string,但旧guard保留并阻止所有turn,调用返回compromised;绝不把race后的identity提交为可信。Standalone request没有exact expectation或identity与本session deterministic child不一致时,在Config mutation前拒绝。 +- Daemon以原pin检查`sessionCd`响应后,调用internal且幂等的`commitManagedConversationBinding(sessionId, expectation)`。CLI Session以`expectation + session event epoch`为key保存独立`bindingPromise`:一个cycle执行中只有同key并发/response-loss重试可以join,不同key拒绝;Promise settle后保留activation state但清除pending引用。前一cycle完成后,只有新的成功`sessionCd`已原子安装另一个pending expectation时才允许repair开启新cycle;已经完成的初始activation bits沿用,只做该identity的重验、guard promote和artifact-base commit。`activationPoisoned`永远不能开启新cycle。Bridge先让ACP Session在close gate内重验pending expectation;首次cold/fresh binding在开始任何activation前原子标记entry为`activating`,然后在已经relocate的child Config上调用现有`geminiClient.initialize()`,由其严格warm lazy tool factories、以child构建initial history/system instruction并调用一次`SessionStart` hook;随后完成initial auth refresh(因此异步调度的`AuthSuccess` hook也以child为cwd)、从child Config hydrate file-history snapshot并在其后`finalizeSessionRestore()`、跳过worktree restore、恢复paused background agents、安装以当前child Config计算local-read roots的ACP filesystem wrapper、启动child-scoped OpenAI log housekeeping,再次重验后把identity guard提升为ready,并让bridge把同一expectation的artifact store从pending同步提交为ready;该store transition不执行filesystem I/O。独立release latch仍阻止external与automatic turn,因此identity/artifact ready不等于可运行。`SessionStart`失败和`AuthSuccess`异步失败继续沿用core现有best-effort日志/吞错语义,不把它们升级为standalone fatal error;activation bit在现有API成功返回后立即提交。Guard/artifact ready后仍保持独立`automaticWorkHeld`,不启动cron、不释放Goal/background/notification、不发布commands、不调度MCP failure surface。每个成功步骤使用独立activation bit;相同ready expectation重试只补齐尚未完成的ACP/bridge activation step,不重复Gemini/tool warm、`SessionStart`调用/`AuthSuccess`调度、file-history hydration/finalization、background-agent restore、filesystem wrapper或housekeeping registration。Commit明确返回activation error时entry原子变为`activationPoisoned`。Transport层失败不猜测poisoned:daemon以同一expectation做至多一次有界重试/状态读取,它会join仍在执行的one-flight或读取settled bits;仍无法判定或entry/channel不可达则terminal quarantine。除既有best-effort hook结果外,poisoned entry不能在同一ACP Session上重试:service关闭该session;无法证明关闭(包括并发attach导致zero-attach close拒绝)时进入runtime quarantine,保留持久化transcript/child而不把半初始化Session重新交付。尚未进入`activating`的expectation/identity拒绝仍保持pending且可安全重试。Daemon对组合commit响应再做一次原pin检查,只有匹配才写入带event epoch且`released: false`的binding record;所有reuse和`assertCwdReadyUnderShared()`只接受`released: true`。随后在仍持有runtime activity和session lifecycle admission时调用幂等`releaseManagedConversationBinding(sessionId, expectation, eventEpoch)`。Release在child再次重验ready guard/identity/epoch后原子清`automaticWorkHeld`、启动scheduler并释放排队automatic work;source-filtered command publication和`surfaceMcpFailuresWhenReady()`各用独立scheduled bit维持best-effort,response-loss重试不重复。Release确认成功后daemon才把同一record提升为可复用`agentBound`(`released: true`);成功前service不返回且并发owner preflight会因unreleased record失败。Release明确失败或identity变化时清本地record并按activation失败的close/quarantine规则收口;一次有界重试后仍unknown时也清record并terminal quarantine,因为child可能已经release,不能承诺零automatic execution。该语义保证成功binding和响应丢失重试的调用/调度幂等,不虚假承诺外部hook执行成功,也不承诺失败后新entry的跨进程exactly-once。Commit/release都不接受request metadata,Live不调用它们。 +- `Session.assertCanStartTurn()`在调用现有`Config.assertCanStartTurn()` writer-lease检查的前后各执行一次guard。guard每次重新验证root、deterministic exact child、expected identity和`config.getTargetDir()`;这样等待writer lease期间发生的替换也会被第二次检查拒绝。missing与compromised使用无path的structured ACP error。 +- ACP child的file-restoring rewind与background fork-agent入口在任何文件恢复、child session创建或background process启动前复用同一guard;history-only rewind不要求目录。Agent tool在同一层拒绝standalone的worktree isolation/working-dir pin以及trusted enter/exit-worktree tool,普通fork/sub-agent继续使用parent private child。Direct shell不在ACP turn入口执行,由daemon owner route的shared preflight和bridge内已绑定的effective cwd共同保护。 +- repair 对同一路径的新 inode执行 managed `sessionCd`;即使字符串 cwd 没变,也必须在 child close gate内重验并刷新 guard,不能被当前 no-op return 跳过。 +- 最新main把running background agent、未完成notification与shell暴露为Session active-work holds,但Monitor被该健康协议明确排除。Standalone managed`sessionCd`因此使用child-local `hasStandaloneRelocationBlockers()`:在close gate内等待active turn后,原子重读active-work holds与running Monitor;任一blocker存在就返回typed`session_busy`并保持旧guard,不执行relocation或identity refresh。Workflow因source gate不注册,不能成为standalone active work。Paused background agent和paused Goal没有驻留的cwd-bound执行体,后续resume从已迁移的parent Config重建,因此不阻塞;queued cron/loop同样不阻塞,真正active的automatic turn已由现有turn drain覆盖。Untracked external process不在本产品保证内。不能只依赖daemon heartbeat/cache,因为它既非完整集合也不是原子授权。Live和普通`sessionCd`保持原行为。 + +只有 normalized standalone source安装该 guard。Guard状态、一次性post-replay activation和刷新方法留在 CLI `Session`,不为单一调用者扩大 core `Config` API。ACP load/resume的既有`#restoreWorktreeOnResume()`对standalone永远跳过;`#restoreBackgroundAgentsOnResume()`不在pre-relocation hook执行,而由上述commit在child Config就绪后执行。History/artifact replay仍可在pending状态完成metadata处理,但任何workspace artifact filesystem工作继续由下面的deferred store拦截。Live 与普通 workspace 的启动、relocation、worktree/paused-agent restore和错误语义不变。 + +`newSessionConfig()`必须在调用`loadCliConfig()`和`config.initialize()`前,从可信的normalized source建立一次不可由request覆盖的bootstrap policy。该policy沿用`loadCliConfig()`已有的internal `hostPolicy`参数增加`provisionalWorkspace?: true`,由loader写入Config构造参数并由Config initialize读取;只有ACP manager根据normalized source设置,不增加argv/settings/env字段: + +- `argvForSession.experimentalLsp = false`。`NativeLspService`捕获初始`WorkspaceContext`且没有relocation协议,PR2不尝试晚绑定;standalone的`Config.isLspEnabled()`固定为false,现有loader因此不注册或广告`/lsp`,且不创建LSP process/watcher。LSP支持留给单独后续设计。 +- `loadCliConfig()`收到host policy后不创建或传入initial `FileDiscoveryService`,不执行Conversations-root project `output-language.md`的`existsSync`选择;user-global output-language仍可只读装配。它继续使用Conversations root的`SessionService`读取/创建transcript,并可读取shared settings、`.mcp.json`和MCP approval配置,因为这些按产品定义属于Conversations shared configuration;但MCP连接仍延迟。Loader不得运行Git discovery、hook、tool factory或subprocess。该窄host policy不改变普通caller。 +- 同一loader分支忽略settings的`context.includeDirectories`、`loadFromIncludeDirectories`与argv的`includeDirectories`,构造空`explicitIncludeDirectories`;不能只清argv而保留shared settings值。Relocation重建WorkspaceContext后断言root set恰为exact child,外部路径不能通过Shell/Monitor的`directory`参数或local-read roots进入。Ordinary/Live仍使用现有include-directory语义。 +- `ConfigParameters`增加默认false、构造后只读的internal `provisionalWorkspace` state,唯一生产writer是上述loader host policy。Config initialize读取它并蕴含`skipGeminiInitialization: true`,跳过会把初始target当作真实project的工作:eager `getFileService()`、initial hierarchical/managed/team-memory refresh(包括team index/git sync)、MCP discovery、`toolRegistry.warmAll()`、auto-skill curator和stale-agent-worktree cleanup。ToolRegistry仍创建lazy factories,settings/hooks/extensions/skills/permission rules的只读装配仍按允许的Conversations shared configuration进行;MCP配置、runtime overlay和transport pool也只装配不连接。`createToolRegistry()`内部若只做不依赖cwd的process capability probe可以保留,但任何factory construction、Git/file discovery或subprocess不得发生。`sessionCd`把Config target切换到exact child后,既有`relocateWorkingDirectory()`清空旧cache并负责首次child-rooted file discovery、memory refresh和MCP reconcile;stdio MCP subprocess的cwd因此是child。Binding commit直接调用现有Gemini initialize,复用其strict warm而不增加第二套tool activation API。Standalone不支持的auto-skill curator/worktree cleanup不在binding后补跑。Ordinary/Live保持默认false;不得顺势重构各scheduler。 +- ACP new/load/resume在bootstrap阶段除跳过`ensureAuthenticated(config)`、`setupFileSystem(config)`和`startNonInteractiveOpenAILogHousekeeping(config, settings)`外,还给`createAndStoreSession()`传一个仅内部的`deferWorkspaceActivation: true`。这个单一开关避免其现有兜底Gemini初始化在relocation前构建chat/system instruction和执行`SessionStart`,同时延迟`hydrateSessionRestoreFileHistory()`、`sessionData.fileHistorySnapshots` restore、`finalizeSessionRestore()`、post-replay services、cron和available-command publication;不要为这些步骤增加一组容易漏设的独立boolean。Session仍可注册、恢复纯transcript metadata并replay UI/history projection,但不能调用需要`GeminiChat`、cwd-rooted FileHistory或workspace artifact filesystem的路径;这些调用由测试逐一证明延迟。Binding commit从Session持有的internal activation state取得所需restore data,在child上按上述顺序完成并以activation bit保证成功/response-loss重试不重复;后续session内auth操作已在ready guard后,沿用普通路径。Filesystem若提前安装会把auto-memory/local-read fallback按root Config固化,housekeeping则会把default OpenAI log cleanup target按root入队;commit在最后一次promote前各执行一次。`setupFileSystem`生成的storage/runtime/user-global roots保持既有语义,唯一cwd-derived auto-memory root必须属于child。任一步失败不得留下ready guard;non-repeatable部分初始化失败必须关闭该session并按上述close/quarantine规则收口,其他cleanup走现有session shutdown/quarantine。 + +Config初始化中对Conversations-root settings、hooks、extensions、skills和ancestor instructions的只读装配是设计允许的shared configuration,不伪装成per-session私有;除这些只读shared-configuration装配及其filesystem watcher和明确证明不读取cwd的process-global capability probe外,任何会执行hook、实例化cwd-sensitive tool/file service、启动subprocess/worker、写project memory/skills、运行Git或清理文件的初始化必须落入上述provisional gate。`Config.relocateWorkingDirectory()`已刷新target、WorkspaceContext、runtime status、file-discovery/session/file-history cache、memory和MCP;transcript `Storage`按设计继续归属Conversations runtime。实现审计必须逐项核对这些已知root-capturing consumer,不能把“无turn”误当作“无cwd副作用”。 + +同一source-aware边界还约束model persistence:`Session.setModel()`计算最终`persistDefault`时,standalone固定为false,不能只在HTTP route改request,因为create/attach的`modelServiceId`和ACP config-option也会直达该方法。该限制不阻止当前session切换model或发布session-scoped事件,但跳过整组model route persistence(`model.name`、`model.baseUrl`和`security.auth.selectedType`)。Bridge在`applyModelServiceId()`和`setSessionModel()`成功后也按entry的normalized source决定是否广播workspace `settings_changed`:standalone跳过该广播,只保留entry bus上的model事件;Live和ordinary保留caller option/default及现有workspace broadcast。Agent-originated slash model update本来只走session event,不增加第二个分支。 + +ACP slash command action会直接拿到`Config`和`LoadedSettings`,不能假定HTTP route的source gate会保护它。PR2B给`handleSlashCommand()`增加一个internal optional execution policy,默认值完全保留现有caller;normalized standalone Session显式传入: + +```ts +interface NonInteractiveSlashCommandPolicy { + allowSessionReset: boolean; + allowWorkspaceSettingsWrite: boolean; + persistModelSelection: boolean; + blockedBuiltinCommandNames: readonly string[]; +} +``` + +该policy不是public capability,也不由request metadata控制。它定义在command types中,并以optional `CommandContext.executionPolicy`透传;缺失时使用全allow/empty-blocked默认。Normalized standalone Session从可信Config source构造一次immutable policy,不能接受caller覆盖。唯一的`isCommandAllowedByPolicy()`先把alias/subcommand解析回canonical top-level builtin identity,再执行blocked判断;`handleSlashCommand()`的普通parse、`getAvailableCommands()`、`buildAvailableCommandsSnapshot()`、Session的available-command update、ACP `buildSessionSupportedCommandsStatus()`以及model-invocable provider/executor全部使用该predicate,不能维护第二份名单。后两个ACP快照caller必须从目标Session取得其policy,不能只拿Config后隐式回到默认。`clearCommand`、`directoryCommand`、workspace-scoped language/import-config action仍在第一个副作用前检查对应allow位,作为非Session internal caller的防御;`/directory show`也拒绝,避免把project-only workspace-root管理误当作standalone功能。`modelCommand`在`persistModelSelection: false`时只支持无scope的primary `/model `,切换当前Config但跳过上述全部setting write;显式`--project|--global`与所有auxiliary selector在Config mutation前返回固定`unsupported_action`,避免报告一个无法持续或查询的半生效选择。`effortCommand`仍apply当前Config,但跳过`model.reasoningEffort` persistence。`/config`只写User scope,默认/user-global language和auth也是明确的跨session user preference,继续沿用现有行为。其他可在ACP执行的slash command仍位于现有`Session.assertCanStartTurn()`之后;其cwd文件访问使用已经验证的private child。实现时必须枚举全部ACP-supported builtin、file、skill和MCP command,逐项检查workspace/session-reset/model persistence、Git、transcript-storage推导、project-skill管理以及直接filesystem/process副作用;新增consumer要么接入policy,要么在PR描述解释为何在private child或user-global scope内安全。Live与ordinary不传policy,不能被这套限制改变。 + +以审计基线`7091b8c761`为准,ACP command inventory锁定如下;PR2B实现checkpoint必须对新增/改名命令重做同一分类: + +| 分类 | 命令 | PR2语义 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | +| Canonical dispatcher deny | `cd`、`clear`(`reset`/`new`)、`directory`、`diff`、`dream`、`export`、`learn`、`curator`、`workflows` | action前固定`unsupported_action`且不广告 | +| Argument-level deny | `language ui --project`、`import-config --scope project`、scoped/auxiliary `model` | helper或Config mutation前拒绝 | +| Session-local | `btw`、`compress`、`compress-fast`、`effort`、`goal`、`insight`、plain primary `model` | 只修改/读取当前session,不持久化workspace/model default | +| Child-local file/memory | `init`、`summary`、`remember`、`forget`、`stats export` | ready guard后只访问validated child;其余stats为read-only | +| Shared/user-global or read-only | `about`、`auth`、`bug`、`config`、`context`、`docs`、`doctor`、`extensions list`、`hooks list`、default/global `language`、`skills`、`tasks`、`update` | 保持既有user-global/process-global或read-only语义;不得宣称per-session私有 | +| Explicitly disabled service | `lsp` | 不注册、不广告,也不创建service/process/watcher | +| Dynamic file/skill/MCP commands | 已加载的file command、shared skill和MCP prompt | 先过ready guard;后续tool执行沿用child context与现有approval/permission边界 | + +`bug`、`doctor`和`update`包含既有process-global side effect,但不读取或持久化workspace语义;PR2不借standalone功能改变它们。若后续产品要限制daemon-host全局操作,应以所有ACP session一致的新策略单独设计,不能只对standalone临时分叉。 + +Tool permission用独立的CLI-local option filter,不把permission scope塞进slash policy。`toPermissionOptions()`增加默认兼容的scope filter;normalized standalone的primary `Session`和其构造的`SubAgentTracker`都传`allowProjectPersistence: false`、`allowUserPersistence: true`。该filter仅从exec/MCP/info移除`ProceedAlwaysProject`,保留`ProceedAlwaysUser`;edit与plan使用`ProceedAlways`表达session-local approval-mode transition,继续保留。`resolvePermissionOutcome()`仍以过滤后的snapshot校验host响应,因此伪造project option在`confirmationDetails.onConfirm`和`event.respond`前拒绝。Primary path只对exec/MCP/info调用permission persistence,并在调用前拒绝project/deprecated-project outcome;edit/plan的`ProceedAlways`仅交给既有`onConfirm`更新当前session mode,绝不进入permission-rule helper。Nested path只能把过滤后outcome交给core scheduler;edit details不携带permission rules且tracker不转发伪造payload rules,exec/MCP/info因此至多写User scope。Workflow tool在standalone不注册;Live/ordinary的既有workflow once/cancel approval保持不变。实现时同时盘点core scheduler与CLI Session两个`persistPermissionOutcome()` consumer,不能只修primary dialog。 + +Bridge侧的`SessionArtifactStore`也必须随standalone managed relocation切换effective workspace,但不复制identity validator: + +- `createSessionEntry()`看到normalized standalone source时,以`workspaceAccess: "deferred"`构造artifact store。该状态下任何会解析、realpath、stat、hash或refresh `workspacePath`的restore、history replay、list、upsert入口都不得访问bound Conversations root;workspace-backed snapshot/update排入该session的bounded deferred queue,非workspace URL/published metadata可继续按既有规则处理。 +- Exact expectation通过ACP child pre/post validation后,`changeSessionCwd`只在bridge entry/store记录与该expectation绑定的`pending` child base,清除旧ready状态,但绝不解析或排空workspace artifact;否则会在daemon最终post-validation前形成文件读取窗口。上述组合`commitManagedConversationBinding()`在ACP guard commit成功后,要求当前entry、effective cwd和artifact pending expectation完全匹配,随后只同步发布ready base并清除realpath cache,不执行artifact filesystem I/O。Expectation mismatch或entry generation变化fail closed,service不得写binding record;final validation后只能先写`released: false`,release确认后才能提升为可复用`agentBound`。 +- Deferred snapshot/update仍按原sequence留在bounded queue,直到下一次有独立cwd授权的artifact操作才惰性排空:daemon GET/POST先做fresh shared preflight,tool/hook update依赖当前turn已经通过ACP guard,file rewind在其child guard后执行。单条恢复失败沿既有metadata-only语义降级并只产生固定、无path的bounded日志,不反转已经提交的cwd relocation。Queue沿用artifact store现有snapshot/input上限;超限按固定truncation warning丢弃最旧可恢复metadata,绝不能无界缓存。 +- Standalone的`rewindFiles: false`走artifact store的metadata-only snapshot path:before/after list不refresh workspace status,restore只对relative path做无filesystem的语法/containment normalization并保留已持久化status,不realpath/stat/hash。这样纯history rewind在child missing时仍可用;`rewindFiles !== false`继续在child guard和daemon preflight后执行完整artifact/file恢复。Live/ordinary rewind保持现有实现。 +- Same-path repair也必须重新经历pending → daemon post-check →组合commit → daemon final check → unreleased record → release → `released: true`并清cache,使新inode下的后续status refresh不复用旧realpath Promise。Store不得自行接受任意request path或allowed root;pending只由exact-expectation relocation queue建立,commit/release只由持有同一expectation和runtime/lifecycle admission的standalone service调用。 +- 在bind完成前,外部artifact list/upsert返回structured `working_directory_missing`,不能把deferred内容或root-relative status暴露出去。Bind后,REST artifact GET/POST与ACP artifact list/add handler仍在其PR1 activity lease和session lifecycle shared admission内调用`assertCwdReadyUnderShared()`再进bridge;REST DELETE/ACP remove只删artifact metadata/sidecar且不读取workspace file,可保持普通owner-routed路径。Tool/hook artifact update发生在已经通过ACP turn guard的turn内。 +- Live与ordinary entry继续以当前workspace立即ready,不改变artifact restore、refresh、warning或cwd-change行为。 + +### 2. Durable cron boundary + +不要把 `experimental.cron` 设为 false,因为那会同时移除 session-only cron。采用两个窄 gate: + +- `Session.#startCronSchedulerIfNeeded()` 在 standalone source 下跳过 `enableDurable(sessionId)`;scheduler本身保持`automaticWorkHeld`,直到daemon final check和幂等release完成,之后仍根据in-memory `hasPendingWork`启动;daemon只在release确认后把binding record提升为`released: true`。 +- `CronCreateInvocation.execute()` 在 `durable === true && config.getSessionSourceType() === "standalone"` 时返回明确 unsupported error;`durable: false` 保持原行为。 + +Legacy standalone restore在PR2B service adoption时normalized,因此ACP scheduler在任何durable read/watch/fire之前就能识别。Live behavior unchanged。 + +### 3. Lifecycle wait 与 terminal quarantine + +复用现有单例`SessionArchiveCoordinator`,不创建第二张per-session lock map。PR2只增加单ID的`runExclusiveAfterShared(sessionId, fn)`:同步检查maintenance seal/existing exclusive,先把ID加入现有`exclusive`并增加`activeMaintenance`,再等待该ID现有shared count归零,最后运行fn。`runSharedMany()`在最后一个shared release时唤醒waiter;因为exclusive在等待前已发布,之后没有新shared能穿过。并发waiting exclusive继续fail-fast,不形成无界队列。fn失败、等待失败和shutdown都在finally清exclusive/maintenance count;`sealMaintenanceAndWait()`把正在等待shared的operation也算active并等到它完成。本次增加不改变现有archive/delete的fail-fast`runExclusiveMany()`语义;PR3再决定哪些lifecycle route迁移到wait语义。 + +`ConversationRuntimeManager` 增加 terminal quarantine: + +```ts +quarantine( + expectedRuntime: WorkspaceRuntime, + reason: 'standalone_session_containment_failed', +): Promise; +``` + +它先验证`expectedRuntime`仍是manager cached且registry active/current的同一实例;错误expected不得把manager置为terminal。合法调用才设置不可恢复terminal state并以one-flight合并同一runtime的并发调用。Manager在terminal state写入后、启动任何异步dispose前,同步且仅一次调用构造时注入的`onTerminalQuarantine` observer;server assembly用该无throw observer冻结service当前全部`creating` entry和reservation。这样错误expected不冻结service,并发quarantine不重复冻结,而runtime teardown也不可能先于状态冻结。随后manager调用server注入的`quarantineRuntime`。所有已经in-flight的`ensure()`在返回runtime前重查terminal epoch,quarantine开始后不得向新consumer交出runtime。无论observer或disposal成败,后续`ensure()`都返回typed`conversation_runtime_unavailable`;observer异常只记bounded日志且不能阻止dispose。不得清缓存后publish第二个runtime。 + +Server wrapper先复用/扩展现有Live seal-and-wait路径:seal Live adapter,等待in-flight Live binding与Appshot probe都settle并清除三个handler,同时把`liveVoiceEnabled`及`app.locals.liveVoiceEnabled`置false、把Appshot readiness设为unavailable、撤销Live discovery并invalidate feature cache;probe晚完成不得重新发布readiness,后续hot-enable会因sealed manager明确失败。它还同步seal PR1的Conversations runtime activity gate并等待既有lease退出;manager terminal state已经阻止service取得新lease,因此该gate不需要、也不得重新开放。等待动作发生在触发transaction按两阶段sentinel释放自己lease之后,避免自等待。随后调用`WorkspaceManagementHandle.quarantineOwnedRuntime(expected)`。该internal方法沿用workspace-management的cwd mutation lane和shutdown计数,但绕过公开remove route的`removable`与persistence逻辑:依次执行`workspaceRegistry.beginDrain → runtimeRemoval.beginDrain → workspaceRegistry.commitDrain → runtimeRemoval.disposeRuntime(runtime, "workspace_removed") → runtimeRemoval.completeDrain → workspaceRegistry.completeDrain`。复用既有dispose reason,避免扩大runtime-removal协议;日志仍记录conversation quarantine reason。Terminal transition后任何begin/commit/dispose错误都不得调用cancel-drain或恢复active:已取得的gate保持draining,并继续尝试不会重新开放admission的后续containment;只有dispose明确成功才调用两个`completeDrain`,且一个complete失败不阻止尝试另一个。`beginDrain`若发现shutdown已先行取得gate,则不争抢,改为等待/依赖统一shutdown disposal。若management已sealed,同样交由daemon shutdown统一dispose,manager仍保持terminal。所有结果都让Live surface保持unavailable。该seam不暴露给普通workspace route,不操作ACP ordinary mount,不删除workspace registration,不release owner record,也不尝试重启runtime。 + +Quarantine completion不是“记录日志后遗忘”的best effort。`WorkspaceManagementHandle`把每个未完成或失败的containment阶段保留在它现有的lifecycle/shutdown proof中;统一shutdown重新等待或继续尚可安全执行的drain/dispose/complete步骤,并聚合最终错误。只在runtime disposal和双方complete都已证明完成后,owner shutdown gate才可主动unlink owner record;若直到进程退出仍无法证明,则shutdown返回聚合错误并保留record,让PR1的stale-owner recovery在确认旧进程已死亡后处理。Manager admission仍永久关闭,重试证明不得调用cancel-drain、恢复Live或重新发布runtime。 + +### 4. Service boundary + +新增 `packages/cli/src/serve/conversations/standalone-session-service.ts`。构造器只接收窄依赖,不做 I/O、不 ensure runtime、不启动 ACP: + +```ts +interface StandaloneSessionServiceOptions { + ensureRuntime(): Promise; + assertRuntimeCurrent(runtime: WorkspaceRuntime): void; + quarantineRuntime(runtime: WorkspaceRuntime): Promise; + runRuntimeActivity( + runtime: WorkspaceRuntime, + operation: () => Promise, + ): Promise; + workspace: ConversationWorkspace; + lifecycle: SessionArchiveCoordinator; + requestedSessionIdAdmission: RequestedSessionIdAdmission; + invalidateSessionListCache(runtime: WorkspaceRuntime): void; +} +``` + +公开给 daemon assembly 的方法固定为: + +- `createWithInitialPrompt(request, prompt)` +- internal `createChildWithInitialPrompt(parentSessionId, request, prompt)` +- `get(sessionId)`、`list(options)` +- `load(sessionId, options)`、`resume(sessionId, options)` +- internal `restoreLegacyForCompatibility(action, sessionId, options)` +- `assertCwdReadyUnderShared(expectedRuntime, sessionId)`、`dispatchPrompt(sessionId, dispatch)`、`continueSession(sessionId, dispatch)` + +PR2不预先暴露无人调用的prompt-less `create()`、独立`repairDirectory()`、public `classify()`或`childSourceFor()`。Top-level LiveTask与standalone child都走带首prompt的同一create engine;load/resume内部复用private directory-repair engine,分类继续复用source helper和service私有读取。PR3在注册对应public route时再增加必要的窄adapter,不复制事务、repair或source构造逻辑。 + +Service不持有第二个 runtime/bridge、不根据 cwd选择 runtime、不把 manager 放入 `app.locals`。Manager提供不做I/O、不触发publication的`assertCurrent(expectedRuntime)`:同步验证terminal epoch、cached runtime identity和registry active/current状态,并通过`assertRuntimeCurrent`窄依赖注入service。除两个明确例外外,每个daemon-facing method都从`ensureRuntime()`得到active/current exact Conversations runtime,再进入PR1提供的Conversations runtime activity gate,并在gate admission后、每次bridge调用前以及提交/返回结果前调用该校验。第一个例外是`get()`对本service已登记的in-flight `creating`直接返回202;它只读process-local事务状态,不触碰root、transcript或bridge。第二个例外是`assertCwdReadyUnderShared(expectedRuntime, ...)`:它只供已经持有PR1 runtime activity lease和`SessionArchiveCoordinator` shared admission的owner-routed handler调用,不重复ensure或进入任一gate,而是先通过同一校验证明expected runtime仍是manager active/current cached instance,再执行validation primitive。其他ID在manager terminal时返回runtime unavailable。Activity gate只管理runtime lifetime,不替代per-session lifecycle lock,且service不得自行创建第二个gate。 + +`restoreLegacyForCompatibility()`不是一个可由request flag切换的通用restore。PR1的generic resolver形成internal restore candidate后,在取得route-local reservation、activity或archive lock之前,把整个legacy standalone transaction委托给该入口;Live candidate保留原路径。Service内部读取authoritative storage ID/source/location并强制legacy persistence,再委托同一个load/resume engine;caller不得先持gate再嵌套调用。这样PR2A阶段可先维持旧路径,PR2B阶段则由service建立`pinned`和`agentBound`;generic restore完成后紧接的owner prompt不会因daemon state缺失而被误拒绝。Explicit source进入该入口只得到not-found且不触碰directory/bridge,任一阶段都不能让它通过generic restore。 + +Quarantine采用两阶段退出以避免activity-gate自等待:失败transaction在gate/lifecycle内部调用manager `quarantine()`完成同步terminal transition与creating freeze,保存其completion Promise,然后抛出仅service内部可见的sentinel;所有`finally`先保留frozen reservation并释放writer/lifecycle/activity gate。Public method在`runRuntimeActivity`外捕获sentinel,才等待quarantine completion并返回`standalone_creation_outcome_unknown`。不得在activity callback内`await` runtime disposal,因为`quarantineOwnedRuntime`会seal并等待同一个gate。并发transaction共享manager one-flight,各自先退出gate;shutdown或dispose失败也只影响completion结果,不允许transaction恢复或释放frozen UUID。 + +### 5. 创建状态机 + +Service维护process-local `creating: Map`,state只区分`running`与`quarantine-frozen`。它表达尚未到达可查询durable terminal state的创建,不替代全局UUID admission。第一次terminal quarantine开始前,service同步设置terminal/frozen并把现有entry标记为`quarantine-frozen`;后续transaction finally不得移除或release这些entry。这样并发创建不会在共享runtime被隔离后各自猜测持久化结果。Source已经持久化且runtime仍可证明安全的失败不需要第三种process-local恢复状态:事务停止mutation、保留transcript与child、释放本地reservation/map,普通exact lookup直接从durable事实返回200。 + +`creating` insert与service terminal flag检查必须是同一个无`await`同步临界区。已经拿到runtime但尚未insert的请求若在此之前观察到terminal,直接返回runtime unavailable且不得取得global reservation;observer不需要追踪一个尚未拥有任何资源的request。若insert先发生,随后observer必定看到并冻结该entry。Insert后、每个下一次异步边界前仍执行`assertRuntimeCurrent()`;finally同时按entry object identity和service terminal/frozen状态决定是否移除,不能因该transaction本地还未看到quarantine completion而释放reservation。 + +Service同时维护`directoryStates: Map`。`pinned`是本daemon ownership lifetime的child identity,合法写入只有三处:new create materialization、daemon启动后该session第一次load/repair安全观察、以及exclusive load/repair证明old child absent后创建的新identity。普通load遇到已有pin必须传给workspace检查;同路径不同inode不能被当成“重新发现”。`agentBound`包含pinned identity、bridge session event epoch和`released` phase:同一runtime generation的managed relocation完成、daemon再次inspect得到同一pinned identity后先写`released: false`,只有child release确认成功才原子提升为`released: true`。所有reuse和cwd preflight只接受true;failure/unknown在close/quarantine前先清record。复用时重读`getSessionEventEpoch(storageSessionId)`,因此ACP channel/session重建不会误用旧bound。Cold session、epoch变化或pin替换都使它无效。PR3接入archive时保留pin但清除agentBound,clean rollback或PR3 delete确认child absent后才清除整个state;PR3也负责deletion journal恢复时的更新。ACP Session内的turn guard保留独立副本作为child-side defense,不能替代daemon state。 + +所有接受session identity的service方法都先执行同一个UUID v1-v5 parser并得到lowercase `canonicalSessionId`;malformed id返回`invalid_request`,map、reservation、lifecycle lock和wire DTO只使用canonical value。新建session的`storageSessionId`和canonical value相同。恢复历史mixed-case transcript时,service通过SessionService的case-insensitive resolver得到文件名中的authoritative `storageSessionId`,并且只在SessionService、bridge和directory hash操作中使用该原始拼写。这保持现有ACP mixed-case load语义,也不会把老transcript绑定到lowercase重算后的另一个child目录。若active/archived namespace中存在两个仅大小写不同的持久化ID,resolver返回conflict,service fail closed;不依赖`readdir`顺序选择其中一个。 + +创建步骤: + +1. 校验 UUID v1-v5和 request fields;内部 request也不接受 cwd/source/sessionScope/project/branch/worktree。 +2. `ensureRuntime()`,验证 root,再以同步 map insert完成本进程same-UUID admission;成功insert后 exact lookup返回 `202`。并发create在调用全局admission前就返回 conflict。 +3. `RequestedSessionIdAdmission.reserveCreate()` 做 daemon-wide live/pending/active/archived冲突检查。 +4. 进入 lifecycle exclusive-wait;再次确认 transcript location absent。 +5. `prepareStandaloneDirectory()`;valid empty orphan复用,non-empty orphan返回 `standalone_session_conflict`。 +6. `bridge.spawnOrAttach()` 固定 `sessionId`、`sessionScope: "thread"`、`sourceType: "standalone"`,传入允许的 model/approval/client context。 +7. 要求 `attached === false`、返回 UUID exact match、`sourcePersisted === true`。 +8. 在任何workspace activation前从SessionService重读唯一authoritative storage ID、location和creation metadata;只有reserved canonical UUID对应单一active transcript且持久化source为explicit standalone才继续。`sourcePersisted`回执本身不授权relocation、Gemini初始化、hook或automatic work。错误source、location或case conflict直接进入post-persistence unwind。 +9. managed relocate 到 identity path;要求 `newCwd` exact match,然后daemon以原pinned identity inspect root/child,再用同一expectation执行组合binding commit,并在响应后再次inspect。只有relocation RPC、两次daemon validation、ACP guard/post-replay activation和artifact ready commit都成功才能写`released: false`的binding record;随后必须完成幂等release并把record提升为`released: true`,才允许事务继续和automatic work运行。Identity/entry generation race按compromised回滚。Fresh binding的memory/MCP warning不回滚已成功relocation;model-context在未初始化Gemini时由binding完整构建,失败是fatal activation而不是warning。只有已初始化live session的repair refresh才可能产生model-context warning。Service不得透传ACP原始异常字符串;仍只暴露固定、安全的`workingDirectory.warnings`分类消息,raw cause只进入bounded、control-character-safe内部日志。Create engine随后只做同步process-local commit、best-effort catalog cache invalidation和map移除,不再执行可失败的durable或workspace I/O。 + +`createWithInitialPrompt()`也必须先完成步骤8的durable reread和步骤9的binding release,但暂不移除`creating`。随后在同一exclusive内调用“exclusive already held”的内部preflight/dispatch helper,不再次获取shared lock。只有prompt被bridge admission接受才提交;同步admission失败走创建rollback。Admission callback之后不再执行任何可失败I/O;只允许同步重查terminal epoch、best-effort cache invalidation和map移除。若另一个transaction已terminalize manager,则保留frozen map并返回outcome unknown,绝不回滚已接受turn。其他已接受后的turn error是正常session结果,同样不回滚。这样不会因prompt已经开始后的一次transcript reread失败而反向撤销正在运行的用户工作。 + +`createChildWithInitialPrompt()`不复制事务:launcher先生成canonical UUID v4,service先解析parent/child canonical ID并在任何lock或reservation前拒绝两者相同,再把caller解析为canonical parent lock key与authoritative parent storage ID,在parent lifecycle shared内重读parent source并拒绝已有parent的child,然后复用cwd validation primitive证明parent root、pin、current cwd、agentBound epoch与`released: true`仍可信;只有该preflight成功才调用同一create engine并对child ID持有exclusive。Bridge与persisted `parentSessionId`使用parent storage spelling以匹配现有live ACP session;wire summary返回canonical parent ID。它与top-level唯一差异是固定parent、同时要求`sourcePersisted`与`parentSessionPersisted`、并返回首prompt的turn handle/event cursor供sent/wait completion编排。Parent shared先于child exclusive获取;PR2没有反向child→parent lock路径,PR3若新增多ID操作必须统一排序重新审计。Parent在创建期间若已进入repair/archive exclusive则child创建直接拒绝。ID相同或parent preflight失败均不得预留child UUID、创建目录或调用bridge。 + +#### Failure unwind + +每个 fault injection点记录 `phase`,但用户错误不带 path: + +- `spawnOrAttach()`尚未dispatch且transcript可证明未留下:释放reservation并返回`standalone_creation_rolled_back`。本次准备的empty child保留,下一次同UUID create可以安全复用;PR2不执行有replacement race的路径式目录删除。一旦bridge调用已经dispatch,“没有收到response”就不等于“没有session”:transcript尚未出现也不能证明ACP child没有创建到source-persistence前的live entry。 +- Spawn一旦已经dispatch而response-loss或返回shape无法确认,就立即terminal quarantine并保留UUID、child和任何transcript,返回outcome unknown;不得用后发summary/lookup的暂时absence推断clean rollback,因为原new-session调用可能仍在异步执行。PR2不为这个故障新增starting-state或request-order barrier协议。只有bridge明确报告调用未dispatch时,才回到上一条的clean rollback证明。 +- 只有`attached === false`且returned UUID exact match时,才证明本次request拥有fresh bridge session并可在失败时调用`killSession({ requireZeroAttaches: true })`。返回`false`只证明child明确拒绝close,不等于关闭成功:若durable reread已证明active explicit standalone,且ACP binding状态证明尚未进入`activating`,事务可保留pending live session、释放本地map/reservation并让后续load重试binding;没有durable marker、activation已开始/poisoned、release outcome unknown或状态无法证明时必须terminal quarantine。不用force `closeSession`越过意外attach。 +- `attached === true`在caller-supplied thread scope下属于bridge invariant violation:只回滚本次client attach,绝不force-close、删除或改写既有session,然后terminal quarantine并返回outcome unknown。 +- PR2不删除standalone child,也不在durable reread已证明active explicit standalone后删除transcript。只有这份验证过的transcript才是可查询的durable outcome marker;若fresh ACP session能clean close,事务保留transcript与child、释放本地map/reservation并返回`standalone_creation_outcome_unknown`,随后普通exact lookup返回200且load/resume完成repair/binding。错误source、location conflict或无法读取metadata不满足该分支,必须按下一条terminal quarantine,不能释放UUID后把foreign/malformed transcript留成不可查询占用。这样不会因一个无法原子绑定inode的目录删除,把可恢复session变成non-empty orphan或误删replacement。 +- 任一close/identity/source证明失败、active/archive conflict、wrong returned UUID、transcript metadata未知或 quarantine失败返回 `standalone_creation_outcome_unknown`。无法证明session已关闭或identity仍可信的情况走terminal quarantine。若bridge返回错误UUID且明确`attached === false`,只能尝试关闭该returned session后quarantine;绝不删除returned UUID对应的transcript或目录,也不把它改写为reserved UUID。若attached或ownership不明,连force-close也禁止,只撤销本次client registration并quarantine。 +- terminal quarantine一旦开始,manager不能再提供exact persisted lookup。所有当时仍在`creating`中的entry和reservation都保留到daemon shutdown,exact按map返回202;同daemon内不虚构404/200。重启释放旧reservation并重新取得owner/runtime后,普通exact lookup才根据持久化事实收敛为200或404。connected caller始终得到outcome unknown;该路径不伪装为普通rollback。 + +Reservation仅在success、已证明pre-persistence clean rollback、或未发生quarantine且已重读到durable transcript可阻止重复创建时释放。Quarantine路径统一保留到shutdown。所有release幂等。全局reservation失败或进入exclusive前的任何错误也必须在同一catch/finally中移除本次owned的`creating` entry;map entry使用object identity校验,旧请求不得删除后来请求的状态。 + +### 6. Exact lookup 与 listing + +`get(sessionId)`: + +1. canonical UUID对应`running`或`quarantine-frozen`时直接返回 `{ state: "creating" }`,不等待exclusive operation;quarantine-frozen不触碰terminal runtime。 +2. 其他ID ensure owner/runtime/root,在 lifecycle shared中解析唯一storage ID并读取 `getSessionLocation()`;active/archive或case-only duplicate conflict为409。 +3. 读取并分类 metadata。只有 standalone结果返回 summary;Live、project/other、source metadata malformed和 absent统一 `standalone_session_not_found`,不透露 foreign context。Request UUID malformed已在入口按`invalid_request`返回400,不进入此步。 +4. active runtime summary可合并volatile字段;persisted metadata对source/parent/created identity权威。Archived只返回cold summary,不load。 + +Listing复用 `server/session-list.ts` 的全量 persisted snapshot/cache和 live merge,不在 page之后过滤。新增 internal standalone predicate path:先筛选 compatible top-level standalone、排除所有 child/Live/other,再按 `(activityTime, sessionId)`排序分页。Cursor绑定 `archiveState + catalogKind: "standalone"`,不能与 generic metadata cursor互换。`truncated`/abort/liveMergeFailed语义保持现有实现。 + +列表对外返回canonical UUID,但service内部record保留storage ID供bridge/child路由;storage ID是non-DTO字段,不能被object spread或error serialization带到响应。同一canonical UUID出现多个storage spelling时不选择或合并,而是记录bounded conflict并从列表排除;exact lookup仍返回409。列表不 probe child directory;工作目录状态只在 create/load/resume/repair/prompt中检查。 + +### 7. Load、resume 与 repair + +Load/resume只接受 active standalone。流程: + +1. exact source/location/root验证;archived沿用 `session_archived`,conflict为 `standalone_session_conflict`。 +2. reserve restore,检查 runtime generation;reservation在success、attach/fresh cleanup和所有throw路径的`finally`中幂等释放。 +3. 若 child missing,先用 lifecycle exclusive-wait重验并 `ensureStandaloneDirectory(sessionId, pinnedIdentity)`;记录 `recreated` warning并原子替换pin。Compromised path直接409。没有pin表示本daemon首次安全观察,可接受valid existing identity;一旦建立就不能在非repair路径变化。 +4. 进入lifecycle shared,重新验证durable source/root/child。若bridge已有同UUID live summary,在调用load/resume或应用attach model/approval前,要求其storage ID、normalized source、parent lineage和event epoch与durable fact及service record一致;Live/foreign/malformed summary直接conflict,不attach、不relocate、不配置mutation。然后使用normalized standalone metadata调用bridge load/resume,并在返回后再次验证返回ID/source/parent与调用前event generation;不一致时detach本次client并按可能已发生attach-side mutation的containment规则close/quarantine,绝不把它采用为standalone entry。 +5. 已有live ACP session只有在上述ownership proof成立、bridge summary的`currentCwd`存在且canonical value等于pinned path,并且service的`agentBound`等于同一pinned identity、event epoch也匹配且`released: true`时,才可在daemon post-inspect后直接复用,不因idle状态重复刷新MCP/memory;该状态也意味着同一expectation的ACP ready guard、post-replay activation、filesystem/housekeeping activation、artifact ready commit和automatic-work release均已完成。若bound/cwd不满足且有active prompt,则detach本次client并fail closed;service不发明不存在的“远程读取ACP guard”能力。只有idle且unbound/stale的session才执行managed relocation并收集warning,RPC后由daemon inspect pinned identity、执行幂等组合binding commit、再次inspect,写入`released: false`后在仍持有runtime activity与lifecycle admission时完成幂等release并提升为true才返回。 +6. relocation/restore失败:attach只detach本次client;fresh registration也先detach本次client,再用`killSession({ requireZeroAttaches: true })`尝试关闭。若失败发生在entry进入`activating`前且已有其他attach导致拒绝,则保留这个已持久化且仍受pending turn guard保护的live session,不force-close、不quarantine、不删除transcript或目录,允许重试。若ACP返回`activationPoisoned`,zero-attach close拒绝或关闭结果不确定都必须terminal quarantine,保留transcript/有效目录但不允许本daemon重用半初始化Session。Transport response-loss不直接判poisoned,先以相同expectation重试幂等commit读取真实activation state。 + +Repair只处理 active standalone: + +- lifecycle exclusive-wait阻止新 daemon prompt admission;ACP `sessionCd` close gate等待 child-internal/active turn。 +- valid current child保持 `ready`;missing创建为 `recreated`;compromised不修改。 +- session live时即使cwd字符串相同也执行managed relocation;随后执行daemon check →组合binding commit → daemon final check → unreleased record →幂等release → `released: true`;Config relocation负责cwd-derived memory/MCP/file services,组合commit恢复尚未激活的post-replay state并刷新ACP identity guard/artifact base但保持external/automatic work held,release才启动scheduler并释放排队工作。若child报告任一relocation blocker则返回`session_busy`,保留已创建目录但不刷新guard,caller在background work停止后重试。cold session只修复目录,不为repair启动ACP,也不伪造不存在的bridge/ACP ready state。 +- 返回 working-directory state/warnings,不 replay失败 prompt。 + +### 8. Cwd-bound work admission + +Cwd-bound admission分成两个不会嵌套lock的窄入口: + +- `assertCwdReadyUnderShared(expectedRuntime, ...)`只做runtime identity/generation、source/root/pinned child/current-cwd/agentBound epoch与`released: true`验证,要求caller已经持有PR1 runtime activity lease与现有`SessionArchiveCoordinator` shared admission;它不嵌套进入任一gate。 +- `dispatchPrompt()`/`continueSession()`供Live task、sub-session等未持锁caller使用,自行获取shared admission后调用同一validation primitive。 + +对 `sendPrompt`,shared gate持有到 `onPromptAdmitted`、同步失败或turn promise在admission前settle三者之一;不持有到整轮完成。对 `continueSession`,持有到bridge返回accepted/refused。任何提前settle都必须释放shared计数。这样repair先标记exclusive后不会再有新daemon prompt穿过,同时现有active turn由ACP close gate等待。 + +`routes/session.ts` 的owner-routed prompt、continue、direct shell、background fork-agent、rewind和session artifact handlers已有通用`SessionArchiveCoordinator` shared wrapper,但PR1没有给这些路径套Conversations activity gate。PR2B必须在shared handler内、任何bridge/filesystem调用前复用server-owned activity gate;取得gate后对standalone传入已解析runtime调用`assertCwdReadyUnderShared()`,再执行prompt、continue、shell、fork-agent、`rewindFiles !== false`以及artifact GET/POST。纯history rewind必须显式选择上述metadata-only artifact path,artifact DELETE不读取workspace file,二者不要求child。Generic `POST /session/:id/cd`、`POST /session/:id/branch`与`POST /session/:id/side-task`在任何bridge、fresh-session admission或目录调用前拒绝explicit和legacy standalone;branch/side-task不是PR2的dedicated child API,不能借owner routing绕过service transaction。`POST /session/:id/fork`是current-session background agent,保留但必须走相同gate与cwd preflight。`POST /session/:id/approval-mode`只允许standalone的`persist !== true`,持久化请求在bridge及workspace settings callback前返回固定`400 unsupported_action`。Ordinary runtime与Live分类保持原路径。 + +ACP HTTP/WebSocket的active owner methods是另一组caller,不能因workspace-qualified mount被PR1隔离就遗漏。`session/prompt`、`qwen/session/shell`、`qwen/session/artifacts` list/add分别在bridge前取得相同activity/shared admission并调用`assertCwdReadyUnderShared()`;artifact remove是metadata-only,不要求child。`session/set_config_option`的mode + `persist: true`在bridge前使用同一`unsupported_action`拒绝,model与reasoning仍由ACP child的session-local规则处理。ACP cold create/restore和workspace mount仍不能选择internal runtime,既有Conversations `session/fork`拒绝保持不变,不新增standalone bypass。REST与ACP两套handler必须用同一个source classifier和validation primitive,不能只给其中一套打补丁。 + +ACP child guard再次检查所有真正开始的turn,覆盖HTTP route之外的 session-only cron、loop、sub-session completion与background notification。 +带文件rewind在child实际修改file-history前、fork-agent在调用agent tool前也调用同一guard-only validation;direct shell由daemon bridge在`effectiveCwd`执行,其daemon preflight与shared lifecycle admission是授权边界。Recap、btw和stateless generate不声明工具执行且不访问working-directory filesystem,不误纳入该gate。 + +### 9. Live task 与 sub-session compatibility + +`LiveTaskService`: + +- projectless `create_thread`调用 `createWithInitialPrompt()`并在发送前生成 UUID;不再创建 `sourceType: "default"` legacy session。 +- list在ordinary runtime继续使用既有catalog;在Conversations runtime直接调用service的standalone list,让source/child过滤发生在全量snapshot分页之前,不能从generic catalog取一页后再过滤。这样大量Live coordinator/worker或child不会挤掉projectless task,cursor也继续绑定standalone query。 +- read/wait/send的Conversations exact locate调用service `get`/`load`路径并根据classifier识别explicit与legacy standalone;不能扫描到或操作Live source。Project runtime保持既有exact locate行为。 +- cold standalone ensure-resident调用 service resume,不直接materialize/relocate。 +- task响应可继续返回 `projectlessOutputDirectory`兼容字段,但值只来自service结果。 + +`create-sub-session` launcher增加一个窄 conversation hook,由 server assembly注入: + +- caller是explicit或legacy standalone时,调用service的`createChildWithInitialPrompt()`;该方法预生成child UUID、做global reservation、directory pin、spawn/relocation/durable reread/prompt admission与统一rollback。Launcher不得保留第二套standalone spawn/cleanup状态机。Live caller保持现有auto-ID、无child source与materialize流程。 +- standalone parent的sent-completion/background follow-up也通过 admission;Live路径保持现有逻辑。 +- standalone child只有在`sourcePersisted === true`且`parentSessionPersisted === true`时才可dispatch首个prompt;任一false/absent都按fresh child rollback。只验证source不足以证明重启后仍能恢复lineage。失败关闭不确定时复用service quarantine policy。 + +不增加 nested children;现有 depth-1 gate和每caller/total cap保持不变。 + +## 逐文件实施清单 + +### PR2A + +- Create: `packages/cli/src/utils/conversation-directory-identity.ts` 及collocated test。 +- Modify: `packages/cli/src/serve/conversations/session-source.ts`、`conversation-workspace.ts`及各自tests。 +- Modify: `packages/cli/src/serve/routes/session.ts`、`packages/cli/src/serve/acp-http/dispatch.ts`及REST/ACP tests,只增加raw reserved-source create/restore gate,并让既有legacy internal restore在metadata/materialize/bridge前解析唯一storage ID;不在PR2A归一化legacy ACP source或启用新的standalone mutation surface。 +- Modify: `packages/cli/src/acp-integration/acpAgent.ts`及load/resume tests,移除exact-lowercase `sessionExists()` fast path;ACP child必须直接调用唯一case-insensitive resolver,才能在exact与case-only twin并存时于Config/filesystem初始化前fail closed。 +- Modify: `packages/cli/src/serve/live/live-task-service.ts`及现有caller tests,只把旧source adapter调用改为传入existence-aware SessionService store;不在PR2A迁移Live task的创建或restore语义。 +- Modify: `packages/cli/src/serve/session-id-admission.ts`及test,让case-only duplicate resolver结果按persisted UUID conflict处理,而不是被外层catch误映射为临时`session_id_admission_unavailable`;该适配只改变重复持久化ID的fail-closed分类,不改变I/O失败的retryable unavailable语义。 +- Modify: `packages/core/src/services/sessionService.ts`及test,让case-insensitive persisted-ID resolver无论exact lowercase文件是否存在都扫描active/archived候选;单一candidate返回authoritative spelling,仅大小写不同的多个candidate抛typed conflict。 + +PR2A跨到`packages/core`的生产改动只允许`SessionService`既有case-insensitive resolver的唯一性收紧。不增加core field、setter或新service。若实现需要第二个core文件,先停下重新审计是否应留给PR2B containment或由CLI admission完成。 + +### PR2B + +- Create: `packages/cli/src/serve/conversations/standalone-session-errors.ts`、`standalone-session-service.ts`及collocated tests。 +- Modify: `packages/acp-bridge/src/bridgeTypes.ts`、`bridge.ts`、`sessionArtifacts.ts`及tests,增加managed relocation的internal exact-identity wire字段/透传、standalone artifact deferred→pending→ready binding和commit后独立的idempotent release RPC,并让create/attach与HTTP model成功路径对standalone只发布session model事件、不广播workspace `settings_changed`;不在bridge层复制filesystem validator,且wire与全部production writer在同一PR出现。 +- Modify: `packages/cli/src/acp-integration/session/Session.ts`、`SubAgentTracker.ts`、`permissionUtils.ts`、`packages/cli/src/acp-integration/acpAgent.ts`及tests,增加standalone turn guard、managed relocation identity校验和刷新、commit/release one-flight、Agent worktree deny、primary/nested permission scope filter,并用单一内部`deferWorkspaceActivation`把Gemini/tool warm、`SessionStart`、file-history/finalize、ACP auth/filesystem、post-replay services与per-cwd housekeeping延迟到binding commit,再把scheduler、automatic work、command publication和MCP failure surface保持到daemon final check后的release。 +- Modify: `packages/cli/src/config/config.ts`及test,把可信`provisionalWorkspace` host policy带入loader:不创建root-rooted `FileDiscoveryService`或采用project output-language,同时保留明确允许的Conversations shared config/transcript读取;不增加argv/settings/env开关。 +- Modify: `packages/cli/src/nonInteractiveCliCommands.ts`、`packages/cli/src/ui/commands/types.ts`及tests,透传仅internal caller可设且默认兼容的slash execution policy。 +- Modify: `packages/cli/src/ui/commands/clearCommand.ts`、`directoryCommand.tsx`、`languageCommand.ts`、`importConfigCommand.ts`、`modelCommand.ts`、`effort-command.ts`及tests,在首个副作用前实施standalone reset、workspace-setting和model-persistence规则;不得借此重构普通command framework。 +- Modify: `packages/core/src/tools/cron-create.ts`与test,仅增加durable standalone deny;`packages/core/src/config/config.ts`与test增加默认false、构造后只读的`provisionalWorkspace` state,在现有初始化位置跳过eager file discovery、Gemini/chat initialization、initial memory/MCP、strict tool warmup与两项project maintenance,并让team-memory/auto-skill/workflow getter对standalone固定false。Binding继续调用既有`GeminiClient.initialize()`,不修改core client或增加第二套初始化API;除这些窄点外不修改其他core config/service。 +- Modify: `packages/cli/src/serve/server/error-response.ts`及test,把ACP child的`working_directory_missing`/`working_directory_compromised`/`session_busy`映射为无path的stable 409。`session_busy`的ACP `errorKind`当前没有对应HTTP branch,不能误以为既有`SessionBusyError` `instanceof`分支会捕获它;两条来源统一返回`retryable: true`和既有Retry-After语义,但不透传ACP message/path。 +- Modify: `packages/cli/src/serve/server/session-archive.ts`与test,增加exclusive-wait primitive。 +- Modify: `packages/cli/src/serve/conversations/conversation-runtime-manager.ts`、`packages/cli/src/serve/routes/workspace-management.ts`、`packages/cli/src/serve/server.ts`及tests,增加terminal quarantine internal seam与Live adapter seal。 +- Modify: `packages/cli/src/serve/server/session-list.ts`与test,复用snapshot/cache增加standalone predicate pagination。 +- Modify: `packages/cli/src/serve/server.ts`与server test,构造一个lazy service并注入既有consumers;不注册route、不放入`app.locals`。 +- Modify: `packages/cli/src/serve/routes/session.ts`、`packages/cli/src/serve/acp-http/dispatch.ts`与multi-workspace/ACP/server tests,把legacy standalone generic restore迁移到受限service入口;给REST owner-routed prompt/continue/direct shell/background fork-agent/file rewind/artifact GET+POST以及ACP owner-routed prompt/shell/artifact list+add增加已持shared的preflight,并在同一adoption边界拒绝standalone generic cd/branch/side-task及REST/ACP persisted approval mode。 +- Modify: `packages/cli/src/serve/live/live-task-service.ts`与test,把projectless create/restore/message迁移到service。 +- Modify: `packages/cli/src/serve/create-sub-session.ts`与test,增加standalone child source、directory和prompt hooks。 + +若实现需要修改清单外production文件,先说明对应不变量;无法对应则视为scope leakage。特别是SDK/WebShell/capabilities/scheduled-task routes和archive/delete helpers不属于PR2。 + +`SessionService.findSessionIdIgnoringCase()`当前生产consumer只有ACP child `loadSession`、ACP child `resumeSession`和`RequestedSessionIdAdmission`,其中三个入口目前都存在exact lookup bypass。PR2A还会让REST internal restore与ACP HTTP internal restore调用它。修改冲突语义时必须回归这五个consumer:单一mixed-case transcript仍返回authoritative spelling并用同一spelling做bridge/directory操作;case-only duplicate在四个restore入口都fail closed;global create/restore admission显式识别resolver的duplicate结果并把它视为persisted占用,不能让现有通用catch把它降成retryable unavailable,且错误不泄露路径。所有consumer都必须直接调用唯一resolver,不能先用exact lowercase fast path绕过duplicate检测。若实现新增返回类型而不是typed exception,同一轮必须更新全部consumer,不保留旧的“任选第一个”入口。 + +## Structured errors + +新增 CLI-local standalone error family,统一字段为 `status`、`code`、`retryable`、可选 `sessionId`,message不包含 root/child path: + +| 条件 | status/code | retryable | +| -------------------------------------- | ----------------------------------------- | --------------------- | +| invalid UUID/fields | `400 invalid_request` | false | +| absent/foreign source | `404 standalone_session_not_found` | false | +| pending create/restore admission | `409 standalone_session_conflict` | true | +| durable UUID/source/directory conflict | `409 standalone_session_conflict` | false | +| child missing before prompt | `409 working_directory_missing` | true | +| existing child/identity compromised | `409 working_directory_compromised` | false | +| background work blocks relocation | `409 session_busy` | true | +| pre-persistence clean creation unwind | `500 standalone_creation_rolled_back` | true | +| uncertain creation outcome | `500 standalone_creation_outcome_unknown` | false;按 UUID lookup | + +PR1 的 `conversation_runtime_*`/`conversation_root_compromised`原样传播,不包装成 standalone conflict。Bridge既有 `session_archived`、writer lease和prompt queue错误保留其现有code。 + +`RequestedSessionIdAdmissionError`只能映射成上述standalone conflict/unavailable语义;其`workspaceCwd`、`workspaceId`、live owner和persistence target细节只写内部日志,不进入standalone response。Exact lookup对Live/project/unknown source统一404,同样不泄露foreign context。 + +ACP relocation warning与filesystem error message也不能原样进入standalone DTO。用户可见warning只区分memory、MCP与model-context refresh失败;session/root path、MCP server stderr和raw exception留在bounded sanitized日志。Live既有warning行为不在PR2中改变。 + +## Test matrix + +### PR2A focused tests + +- Source矩阵:explicit standalone、legacy none/default、exact Live、empty Live id、standalone with sourceId、other source、top-level/child/grandchild/self/cycle;explicit child在parent active/archived/deleted时仍独立分类,legacy orphan不猜测;新reader标记explicit/legacy,旧adapter允许Live与legacy但拒绝explicit standalone。 +- Generic REST与ACP create/restore在任何bridge/admission调用前拒绝explicit standalone;legacy restore仍保持PR2前metadata shape和行为,Live reserved gate回归不变。 +- Mixed-case restore:单一legacy storage ID在REST、ACP HTTP和ACP child load/resume中都保留storage spelling用于bridge与directory hash;lowercase exact与uppercase twin并存时四个入口都在materialize/bridge前返回conflict;global admission仍视为persisted占用。 +- Root/child:new、valid empty reuse、non-empty conflict、missing recreate、symlink/junction、wrong owner/mode、file、nested、root replacement、child inode replacement、TOCTOU revalidation、Windows case/canonical behavior;standalone失败路径不调用目录删除,保留empty child可由同UUID重试复用,Live现有empty cleanup行为不变。 + +### PR2B service tests + +- Atomic adoption:新增identity wire与每个production writer同PR落地;generic legacy REST/ACP restore只有在service、daemon cwd preflight和ACP guard均已装配后才归一化为standalone。Generic cd/branch/side-task与approval-mode `persist: true`在bridge、derived-session admission和settings callback前拒绝explicit/legacy standalone;standalone create/attach/HTTP/ACP primary model switch均成功但不持久化`model.name`、`model.baseUrl`或selected auth,且bridge-driven成功路径只在目标session发布model事件、不向同runtime其他standalone/Live bus广播workspace `settings_changed`,request无法覆盖;session-local approval/model、user-global language、Live与ordinary workspace persistence及broadcast回归不变。 +- Bootstrap cwd side effects:normalized standalone无论process argv、settings、team-memory env override或request metadata如何都不初始化LSP;loader host policy是`provisionalWorkspace`的唯一生产writer,Config构造状态与loader行为不可分裂。Settings与argv同时提供external include directories时也被忽略,Config explicit include set为空,relocation后WorkspaceContext root set只有exact child,Shell/Monitor的`directory`参数不能选择ambient path。Root阶段不构造`FileDiscoveryService`、不选择project output-language、不refresh managed/team memory、不sync/probe project Git、不启动MCP、不warm cwd-sensitive tool factory、不初始化Gemini/chat或构建system instruction、不执行`SessionStart`/`AuthSuccess` hook、不运行auto-skill curator/stale-worktree cleanup,team-memory/auto-skill getter持续false,且ACP不安装filesystem wrapper、不登记per-cwd log housekeeping。Create携带会切换auth type的`modelServiceId`时也只能更新未初始化的Config并发布session事件,不能refresh auth或触发hook;child binding的首次Gemini/auth初始化必须使用所选model。User-global output-language与allowed shared settings/MCP/transcript reads保留。`createAndStoreSession`的单一defer option仍可完成metadata/UI replay,但不隐式初始化chat、不构造root FileHistory、不hydrate/validate snapshots、不finalize restore或访问workspace artifact。Relocation后的file discovery、managed memory和MCP只使用exact child;首次binding commit在promote前通过既有Gemini initialize严格warm工具、构建一次child model context并执行一次`SessionStart`,再执行一次child auth、hydrate/finalize一次child file history、安装一个child-derived filesystem wrapper和一个child log target,但保持scheduler、Goal/background/notification、command publication与MCP failure surface held;daemon final check写入matching epoch的unreleased record后,幂等release才各启动或调度一次,确认后daemon才标记`released: true`。成功/响应丢失重试不重复warm、hook、file-history/finalize、registration、automatic work release或failure warning;non-repeatable activation/release失败时entry必须close或quarantine且不交付。Fresh binding的`addWorkingDirectoryChangedContext()`保持no-op且不产生伪warning,已初始化的same-path repair保留sanitized model-context warning。`buildAcpLocalReadRoots()`的cwd-derived auto-memory root是child,Storage/runtime/user-global roots保持既有值;`QWEN_CODE_PROJECT_DIR`保持Conversations transcript/harness storage dir而process cwd、Config target和WorkspaceContext必须是child。Allowed shared settings/skills/extension watcher、root-independent process capability probe以及Live/ordinary逐项回归。 +- Permission scope:primary与nested sub-agent exec/MCP/info permission options在standalone只提供once/cancel/user-global always,host伪造project/deprecated-project option在`onConfirm`/`event.respond`/settings callback/PermissionManager mutation前拒绝;合法user-global always只写User scope,primary edit/plan的`ProceedAlways`只改变当前session mode且不调用permission persistence,nested edit也没有rules/payload旁路。Standalone不注册Workflow tool;Live与ordinary workflow once/cancel及project+user options保持原样,并覆盖CLI/core两个persistence consumer。 +- Slash/tool policy:normalized standalone只由ACP Session注入non-request-controlled policy;canonical dispatcher list在任何action前拒绝`cd`、`clear|reset|new`、`directory`、`diff`、`dream`、`export`、`learn`、`curator`和`workflows`,普通parse、Session update、ACP status snapshot及model-invocable provider/executor复用同一predicate,证明alias不能绕过、被禁命令不广告也不能由model调用,且Git、cwd-derived transcript、shared workflow snapshot和project-skill helper均未调用。Project language/import在helper前拒绝,model scope/aux selector在Config mutation前拒绝;plain primary model与effort切换当前Config但对所有settings scope零write。`isWorkflowsEnabled()`在standalone即使env/settings enable也固定false,tool registry没有Workflow factory/schema;`isLspEnabled()`固定false,loader不注册或广告`/lsp`。`init`、`summary`、`remember`、`forget`、stats export只读写private child,`skills`/`hooks`/`extensions list`只读shared配置;default/user-global language、auth与`/config`仍写User scope。Ordinary、Live和其他non-interactive caller不传policy且行为逐项回归。测试枚举全部ACP-supported builtin/file/skill/MCP command的workspace/session-reset/model persistence、Git、transcript-storage、workflow/project-skill和直接filesystem/process副作用,防止漏掉旁路。 +- Managed identity wire:bridge只透传、不记录;逐个断言generic legacy REST/ACP restore和所有projectless restore/sub-session、create/load/repair/LiveTask/child caller在normalized standalone时都设置expectation;standalone缺失/malformed expectation、非safe device/inode、wrong storage-ID hash/root/path/device/inode在filesystem和Config mutation前拒绝;daemon precheck后替换、child mutation期间替换、relocation响应后替换及binding commit后替换分别由child pre/post、两次daemon check与下一次turn guard拦截;Live request无新字段且行为不变。 +- Binding activation:normalized standalone load/resume在relocation前不初始化Gemini/chat、不warm tool factory、不调用`SessionStart`/initial-auth、不构造或hydrate cwd-rooted FileHistory、不finalize restore、不restore worktree、不load paused agents、不启动cron、不发布未过滤commands;Session构造器在绑定Goal/background/sub-session/workflow callback前已同步安装guard/latch,pending期间external turn拒绝,cron/Goal/background notification保留而不消费。首次同expectation组合commit在首个activation前标记`activating`,只在relocated child完成一次Gemini initialize(含strict warm、initial history/system instruction和一次`SessionStart`调用)、一次auth(含一次`AuthSuccess`调度)、一次file-history hydrate/finalize、恢复paused-agent state一次、安装child filesystem/log housekeeping一次,重验后promote identity guard并以零artifact filesystem I/O发布artifact ready,但release latch仍阻止external turn,`automaticWorkHeld`仍阻止scheduler、Goal/background/notification、command publication与MCP warning;相同expectation/epoch的并发与response-loss重试join一个`bindingPromise`,settled retry读取bits并幂等继续,不重复任一成功activation step或hook调用/调度;执行中different key拒绝,前一cycle settle后只有新的successful `sessionCd` pending expectation可启动repair cycle且初始activation bits不重跑,poisoned永久拒绝;daemon只做一次有界重试,仍unknown则quarantine;hook自身失败维持既有best-effort且不阻断ready,其他non-repeatable部分抛错原子标记`activationPoisoned`并关闭entry,关闭不确定或并发attach阻止关闭时quarantine,绝不重用半初始化Session;首个activation前的expectation/identity失败仍pending可重试;wrong entry/epoch/path/identity拒绝。Daemon final check前不写binding record也不释放automatic work;final check后写matching epoch且`released: false`的record,在同一runtime activity/lifecycle admission内调用release,child再次验证ready identity/epoch后一次启动scheduler/automatic work、发布filtered commands并调度MCP failure surface,确认响应后daemon才把record提升为true。Release响应丢失只join/读取bits;明确失败或identity变化清record并close/quarantine,owner prompt在released true前不能admit;一次有界重试后unknown同样清record并quarantine,但因child可能已经release而不虚假断言零automatic execution。Fresh/restore/same-path repair、commit/release各阶段失败、final-check或release明确拒绝时零automatic execution、response-loss unknown时terminal containment、并发commit/release、explicit poisoned response与Live/ordinary post-replay行为都覆盖。 +- Artifact binding:normalized standalone entry在relocation前的snapshot restore、history replay、list与upsert对workspacePath零filesystem调用;child RPC成功只建立pending,daemon post-check失败时不commit/不排空;组合commit中的artifact阶段只发布ready base且本身零filesystem调用,wrong entry/epoch/path/identity拒绝;首次已守卫REST/ACP list/add或turn按sequence有界排空并只访问child,same-path新inode清realpath cache;queue超限固定降级,artifact异常不泄露root/path也不反转cwd;`rewindFiles:false`的before/restore/after全程metadata-only并在missing child下成功,file rewind执行完整guarded refresh;REST/ACP artifact remove与nonworkspace URL/published metadata不误要求child;Live/ordinary行为不变。 +- Turn guard:relocation前、pending commit和commit已ready但尚未release阶段拒绝external turn、暂停automatic work,release后允许;ordinary、authenticated channel prompt和forged channel metadata都必须经过同一daemon/child gate,且不改变现有loop-detected terminal语义;missing/replace/unsafe拒绝;standalone available commands不广告完整blocked canonical list,alias解析后同样在command action前硬拒绝;Workflow tool/schema即使env/settings开启也不存在,`/workflows`不读取shared snapshot;file rewind与fork-agent在child副作用前复用guard;普通Agent/fork成功但`isolation:"worktree"`、`working_dir`和trusted enter/exit-worktree在tool build/Git/filesystem前拒绝;same-string repair刷新identity及cwd-derived services;agent/notification/shell与running Monitor blocker原子拒绝relocation;paused background agent、paused Goal和queued cron/loop不误阻塞;blocker释放后可重试;错误不泄露path。 +- HTTP error mapping:daemon `SessionBusyError`与ACP `errorKind: session_busy`都为retryable 409;working-directory两类ACP error使用固定无path消息,不能落入generic 500或透传raw RequestError。 +- Cron:standalone不调用 `enableDurable`但session-only fire;durable create拒绝;legacy normalized restore同样拒绝;Live/project durable行为不变。 +- Lifecycle:exclusive-wait先关闭新shared、等待existing shared、并发exclusive拒绝、fn错误释放、shutdown seal等待。 +- Quarantine:expected runtime only、one-flight、terminal before dispose、Live binding与Appshot probe settle/handler clear、late probe不能重发readiness、Conversations activity gate先seal并等待全部既有lease、registry/controller begin/commit/dispose/complete顺序;逐点注入activity wait及begin/commit/dispose/两个complete失败,断言触发transaction退出自己的lease后才可完成wait、terminal后不cancel/reopen、dispose未证明成功时不complete、shutdown已持gate时不争抢;未完成阶段必须进入shutdown proof并由shutdown继续/聚合,证明完全dispose/complete前不得主动release owner record,失败record留给dead-owner reclaim;ensure始终fail,不得republish/primary fallback;`assertCurrent()`不做root I/O或publication,并拒绝terminal、cached identity替换和非active/current entry。 +- Quarantine freeze:错误expected不触发observer;合法terminal transition在dispose前同步冻结全部creating entry;并发调用只冻结一次;observer异常仍继续dispose且manager保持terminal。覆盖ensure已返回但creating尚未insert的竞态:terminal-first不得insert/reserve,insert-first必被observer冻结,finally不得释放;创建失败在runtime activity gate内只保存completion并抛sentinel,断言lifecycle/activity lease释放后才等待dispose,无self-deadlock。 +- Create success:required UUID、thread scope、standalone source、model/approval、empty orphan reuse、`sourcePersisted`后先重读single active explicit standalone durable fact且在此前零workspace activation/hook、再执行relocation pending→组合commit→final daemon validation→matching-epoch unreleased record→release→`released: true`、release前零automatic execution、sanitized relocation warnings、cache invalidation。 +- Global conflicts:live owner、pending create/restore、active、archived、单一mixed-case storage ID兼容、case-only duplicate conflict、foreign runtime、non-empty orphan;standalone error不泄露foreign workspace path/id。 +- 每个事务边界fault injection:ensure/root/reserve/directory/spawn pre-dispatch failure/spawn dispatched response-loss/wrong id/source false/durable reread/relocation/newCwd mismatch/binding commit/release/final identity check/close/quarantine。Spawn pre-dispatch failure只有在transcript absence与“没有owned ACP session”均可证明时clean rollback;empty child保留。任何dispatched response-loss都必须terminal quarantine,不得按后发summary或transcript absence清理或释放UUID。 +- wrong returned UUID测试必须证明只尝试关闭returned session,绝不删除returned UUID的transcript/directory,也不错误提交reserved UUID。 +- Cleanup顺序断言:pre-dispatch或pre-persistence clean close只释放reservation并保留empty child;source持久化后clean close保留child/transcript并让普通exact返回200;close failure或terminal quarantine同样不删child/transcript,但quarantine-frozen始终202且不触碰runtime;任何standalone unwind都不调用Live目录删除或transcript remove。 +- Caller停止等待不取消transaction;成功但响应未消费仍可exact lookup。PR3另测HTTP socket disconnect与detach。 +- Exact lookup:running/quarantine-frozen 202、active/archived 200、absent/Live/other 404、location conflict 409、ownership/root errors原样传播;source已持久化的失败已在transaction退出时释放本地map/reservation,直接走普通durable lookup;旧entry不能删除新entry。 +- List:explicit+legacy top-level included;Live/other/children excluded;filter-before-pagination、equal activity tie、cursor query binding、active/archive、live merge、abort/truncated/cache invalidation;wire ID/parent ID均canonical且DTO/error JSON不含storage ID内部字段。 +- Load/resume:active、legacy normalization、archived、missing child recreate、compromised child、bridge-existing summary在attach/config mutation前与返回后都验证storage ID/source/parent/event generation且Live/foreign/malformed entry零mutation拒绝、valid released bound的idle/active session均直接复用、active prompt wrong cwd/bound时拒绝、idle agentBound missing/unreleased/stale event epoch时relocate或收口、ACP成功后的两次daemon validation race、binding commit/release响应丢失幂等重试、final check拒绝或release明确失败时零automatic execution、release unknown时允许“可能已执行”但必须terminal quarantine、standalone worktree永不restore且paused-agent state只在child commit恢复并在release后运行、relocation warning/failure、attach/fresh cleanup;fresh session在pre-activation失败且并发attach阻止zero-attach close时保留pending而不quarantine,`activationPoisoned`后同一情况必须quarantine,generation close回归;REST/ACP HTTP legacy兼容入口重验legacy persistence并建立同一pin/bound,完成release后prompt成功,explicit source在service调用前拒绝。 +- Cwd-work/repair concurrency:owner route的activity/shared-held helper不重复admit且拒绝错误expected runtime;REST prompt/continue/direct shell/background fork-agent/file rewind/artifact GET+POST与ACP prompt/shell/artifact list+add均在bridge前验证,history-only rewind、REST/ACP artifact remove及recap/btw/stateless generate不误要求child,standalone generic cd/branch/side-task在任何派生副作用前拒绝,REST与ACP approval mode仅session-local而`persist: true`不调用bridge/settings callback;preflight后child消失、repair先exclusive、cwd work先shared、active turn等待、各类background blocker拒绝repair、same-path new inode guard与cwd-derived service/artifact base刷新、cold repair不启动ACP、failed prompt不自动replay。 +- Live task:projectless new session是explicit standalone、首prompt admission、failure rollback;internal list使用filter-before-pagination且排除Live/child,exact read/wait/send与cold resume走service;project catalog/exact与Live source拒绝回归。 +- Sub-session:parent/child canonical ID相同在任何lock前拒绝,防止parent shared→同ID child exclusive自等待;parent shared内先重读source并验证parent root/pin/current-cwd/agentBound epoch与`released: true`,失败时无child reservation/directory/bridge调用;explicit/legacy standalone child同时要求source与parent lineage persisted、mixed-case parent使用canonical lock/wire ID但storage spelling传给bridge与transcript、Live child不变、任一persistence flag failure、child list exclusion、sent completion preflight、depth/cap回归。 +- 多session共享一个runtime/bridge/ACP child;每个cwd、event、permission、source和model状态独立,standalone model切换不会向其他session伪报workspace default变化。 + +### Server regression + +- Live disabled且未调用standalone service时,不ensure root、不claim owner、不publish runtime、不启动ACP。 +- PR2没有 `/standalone/*` route,capabilities不含 `standalone_sessions_v1`。 +- Generic REST/ACP cold restore继续只接受既有Live/legacy projectless集合,不能因新classifier接受explicit standalone;PR2B把legacy standalone迁入受限service兼容入口,explicit standalone仍只有dedicated service consumer可cold restore。 +- PR1既有non-creating active owner-routed control继续工作,但generic branch/side-task创建以及cold transcript/export/archive/unarchive/delete/organization、unfiltered catalog和workspace-qualified生命周期入口都不能因PR2获得explicit standalone访问;对应source proof必须仍走拒绝explicit结果的兼容adapter。PR1最终代码已把branch/side-task扩为internal owner-routed,PR2必须在handler内按source收窄,不能依赖primary-only wrapper偶然拒绝;REST background fork-agent则保留并走cwd guard。 +- Ordinary workspace selectors和ACP mounts仍不能选择 Conversations;prompt只按session owner路由且无primary fallback。 +- Existing Live create/load/resume/worker relocation、legacy projectless restore、ordinary project create/list/load、archive和shutdown行为不变。 +- PR1 activity gate seal与PR2 service create/load/list/prompt并发时不发生late bridge/filesystem work;containment由当前operation触发时先退出自身lease再完成dispose。 + +### PR2B E2E plan + +`LiveTaskService`的projectless `create_thread`会从legacy source切换为explicit standalone,并开始使用确定性私有目录;legacy projectless mutation也会获得新的source-aware限制,因此属于用户可观察行为。实现前在`.qwen/e2e-tests/`写独立计划,并先用全局`qwen` CLI dry-run记录当前baseline。实现后用build+bundle产物和隔离`HOME`运行real-daemon场景:创建projectless task、验证首prompt与后续send/wait、kill daemon后冷恢复、确认transcript source与private cwd、确认session-local approval仍可用而persist拒绝、确认generic cd/branch/side-task在任何副作用前拒绝;通过ACP prompt验证plain model与effort只在本session生效且重启不持久化,并验证完整blocked slash canonical list、project language/import-config及model scope/aux selector均在首个副作用前拒绝且不被alias绕过,同时验证safe child-local slash与shared read-only list正常;即使settings/env强制开启Workflow,也验证tool schema不存在、`/workflows`拒绝且shared Conversations `workflows/`无新增或读取;验证primary与nested permission不出现project-persistent option而user-global option仍可用;验证普通Agent/fork可用而worktree isolation/working-dir pin/enter-exit-worktree拒绝;用含file-history snapshot的restore并配置可观测的team/managed memory、`SessionStart`与`AuthSuccess` hook、file/Git discovery、cwd-sensitive tool factory、LSP、stdio MCP、auto-skill curator、stale worktree、ACP local-read fallback和default OpenAI logs,证明root阶段没有memory/Git/hook/tool/model-context/file-history/process/maintenance/file/log副作用,relocation后只有支持项使用child且team memory/LSP/curator/worktree cleanup不启动,并确认`SessionStart`与`AuthSuccess`各执行一次、首次system instruction只含child context、file history只以child恢复、MCP启动失败只提示一次;确认default/user-global language与普通`/config`仍可用、普通workspace selector/ACP mount不能选择Conversations,以及Live Voice/ordinary project回归。测试目录必须位于临时home,结束后只清理该显式temp tree;不触碰操作者真实Conversations目录。该报告随PR2B提交;PR2A没有独立用户流程,只执行focused integration regression。 + +同一E2E必须从settings和daemon argv同时注入private child之外的include directories,证明standalone Config忽略两者、relocation后的WorkspaceContext只有exact child,且Shell/Monitor的`directory`参数不能选择这些ambient paths;ordinary与Live的include-directory行为保持不变。 + +## Verification + +PR2A: + +```bash +cd packages/cli +npx vitest run \ + src/serve/conversations/session-source.test.ts \ + src/serve/conversations/conversation-workspace.test.ts \ + src/utils/conversation-directory-identity.test.ts \ + src/serve/session-id-admission.test.ts \ + src/serve/acp-http/transport.test.ts \ + src/serve/multi-workspace-sessions.test.ts \ + src/serve/server.test.ts + +cd ../core +npx vitest run src/services/sessionService.test.ts +``` + +PR2B: + +```bash +cd packages/acp-bridge +npx vitest run src/bridge.test.ts src/sessionArtifacts.test.ts + +cd ../cli +npx vitest run \ + src/serve/conversations/standalone-session-service.test.ts \ + src/serve/conversations/conversation-runtime-manager.test.ts \ + src/serve/conversations/session-source.test.ts \ + src/serve/conversations/conversation-workspace.test.ts \ + src/serve/server/error-response.test.ts \ + src/config/config.test.ts \ + src/acp-integration/acpAgent.test.ts \ + src/acp-integration/session/Session.test.ts \ + src/acp-integration/session/SubAgentTracker.test.ts \ + src/acp-integration/session/permissionUtils.test.ts \ + src/serve/server/session-archive.test.ts \ + src/serve/routes/workspace-management.test.ts \ + src/serve/live/live-task-service.test.ts \ + src/serve/create-sub-session.test.ts \ + src/serve/multi-workspace-sessions.test.ts \ + src/serve/server.test.ts + +cd ../core +npx vitest run src/tools/cron-create.test.ts src/config/config.test.ts +``` + +每个实施 PR 的最终验证: + +```bash +npx prettier --check packages/acp-bridge/src packages/cli/src/serve packages/cli/src/acp-integration packages/cli/src/config/config.ts packages/core/src/config/config.ts packages/core/src/tools/cron-create.ts packages/core/src/services/sessionService.ts docs/design/standalone-daemon-sessions.md docs/plans/2026-08-14-standalone-pr2-core.md +npm run lint --workspace @qwen-code/acp-bridge +npm run lint --workspace @qwen-code/qwen-code +npm run lint --workspace @qwen-code/qwen-code-core +npm run build +npm run typecheck +git diff --check +``` + +实现时从 package目录运行focused Vitest;只有最终server回归需要大文件。任何 test command因仓库基线失败都必须区分 branch regression与已知main failure,不以重跑掩盖确定性失败。 + +## Review 与提交门禁 + +- PR2A/PR2B 开始前都刷新 `origin/main`、确认 PR1合入并重建 source/create/prompt/automatic-turn consumer inventory。 +- PR2A跨CLI/core边界,按仓库cross-package/core infrastructure gate主动请求maintainer review,并在PR描述列出case-resolver全部downstream consumer。PR2B跨`packages/acp-bridge`、CLI和core,且触及runtime removal与session lifecycle,必须在PR描述列出bridge内部identity wire、所有managed-relocation writer、唯一cron core gate和ACP slash mutation inventory并主动请求maintainer review。PR2B预计可能超过1,000行production logic,应按仓库规则主动提示maintainer;不再拆第三个可运行子PR,因为slash policy必须与legacy source normalization、cwd guard和service adoption原子启用,拆开会留下可执行的未保护standalone入口。两者都不得把feature标题改成refactor来弱化审查语义。 +- 每个新增 field/option必须grep全部read/write site;未被生产caller设置的optional switch删除。 +- 每个 bridge调用前检查 runtime generation、source ownership和所需目录状态;失败不得调用primary bridge。 +- Production diff超过各自上限100行时先审计重复分类、第二套lock、route leakage和PR3 deletion/lifecycle工作;不得靠减少fault tests维持预算。 +- PR2B若需要 deletion journal、archive/unarchive/delete/rename/export、public route、capability或SDK类型,立即移出到PR3/PR4。 +- 完成代码后按仓库规则执行两轮连续clean、开放式diff审计;任何修复重置clean计数。再运行Codex code-review workflow并逐条验证。 + +## 实施顺序 + +```mermaid +flowchart LR + PR1["PR1 ownership + isolation"] --> A["PR2A source + directory primitives"] + A --> B["PR2B containment + service adoption"] + B --> PR3["PR3 complete lifecycle + public daemon API"] +``` + +PR2A与PR2B不能并行修改同一实现分支。可在PR1评审期间继续做设计和test skeleton,但生产实现必须等PR1最终接口稳定后从最新main创建分支。PR2完成后仍不对客户端宣布功能可用;PR3完成 deletion recovery、剩余lifecycle和route adapters后才发布capability。 From 852308337ec897430479a9175191a24ff975e8d2 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Mon, 17 Aug 2026 13:33:48 +0800 Subject: [PATCH 02/29] feat(cli): Add standalone conversation isolation primitives Co-authored-by: Qwen-Coder --- docs/plans/2026-08-14-standalone-pr2-core.md | 4 +- .../cli/src/acp-integration/acpAgent.test.ts | 55 ++- packages/cli/src/acp-integration/acpAgent.ts | 29 +- packages/cli/src/serve/acp-http/dispatch.ts | 59 ++- .../cli/src/serve/acp-http/transport.test.ts | 231 +++++++++++ .../conversation-workspace.test.ts | 109 +++++ .../conversations/conversation-workspace.ts | 376 ++++++++++-------- .../conversations/session-source.test.ts | 177 +++++++-- .../src/serve/conversations/session-source.ts | 138 ++++++- .../src/serve/live/live-task-service.test.ts | 4 + .../cli/src/serve/live/live-task-service.ts | 2 +- packages/cli/src/serve/routes/session.ts | 62 ++- packages/cli/src/serve/server.test.ts | 180 ++++++++- .../src/serve/server/error-response.test.ts | 15 + .../cli/src/serve/server/error-response.ts | 9 + .../src/serve/session-id-admission.test.ts | 31 +- .../cli/src/serve/session-id-admission.ts | 21 +- .../conversation-directory-identity.test.ts | 153 +++++++ .../utils/conversation-directory-identity.ts | 331 +++++++++++++++ .../core/src/services/sessionService.test.ts | 60 ++- packages/core/src/services/sessionService.ts | 29 +- 21 files changed, 1785 insertions(+), 290 deletions(-) create mode 100644 packages/cli/src/utils/conversation-directory-identity.test.ts create mode 100644 packages/cli/src/utils/conversation-directory-identity.ts diff --git a/docs/plans/2026-08-14-standalone-pr2-core.md b/docs/plans/2026-08-14-standalone-pr2-core.md index ad88bee903d..9096740f7f9 100644 --- a/docs/plans/2026-08-14-standalone-pr2-core.md +++ b/docs/plans/2026-08-14-standalone-pr2-core.md @@ -117,7 +117,7 @@ interface ConversationSessionMetadataStore { PR2A保留现有`readLoadableLiveConversationMetadata()`导出作为薄兼容adapter,改为接收同一个existence-aware store并复用新reader的分类结果,但对现有Live与legacy projectless caller返回PR2前的metadata shape:legacy不能在这一子PR提前被改写成ACP看到的normalized standalone source。它也不能让explicit standalone穿过generic REST/ACP restore。这样PR2A只提供可审查的分类primitive和reserved-source gate,不在daemon preflight/service存在前部分激活containment。PR2B再把explicit standalone cold restore以及generic legacy standalone兼容恢复迁移到service:generic REST/ACP只调用`restoreLegacyForCompatibility()`窄入口,该入口在任何materialize/bridge调用前重读并要求`kind: "standalone"`且`persistence: "legacy"`,并从此处开始把legacy source归一化为ACP所见的standalone;explicit standalone仍只允许dedicated service consumer。Live和legacy-Live-child继续走Live adapter。若grep确认旧adapter只剩Live consumer则收窄为Live-only或删除无调用导出,不同时维护两套分类规则。 -Generic legacy restore在调用reader前也必须通过唯一case-insensitive resolver把canonical caller ID解析为authoritative storage ID。Archive/lifecycle admission继续使用canonical ID;metadata、bridge session ID和conversation-directory hash统一使用storage spelling。这样现有mixed-case transcript不会先被legacy route绑定到lowercase hash目录、再被PR2B service切换到另一目录。仅大小写不同的重复transcript在任何materialize/bridge调用前fail closed。 +Generic legacy restore在调用reader前也必须通过唯一case-insensitive resolver把canonical caller ID解析为authoritative storage ID。Archive/lifecycle admission与daemon bridge的live entry key继续使用canonical ID;metadata、ACP child Config/session storage和conversation-directory hash使用storage spelling。这样现有mixed-case transcript不会先被legacy route绑定到lowercase hash目录、再被PR2B service切换到另一目录,同时后续owner-routed REST/ACP请求仍能以canonical UUID找到同一bridge entry。仅大小写不同的重复transcript在任何materialize/bridge调用前fail closed。 Lineage规则固定为当前daemon支持的depth 1,同时保持父子lifecycle独立: @@ -502,7 +502,7 @@ ACP relocation warning与filesystem error message也不能原样进入standalone - Source矩阵:explicit standalone、legacy none/default、exact Live、empty Live id、standalone with sourceId、other source、top-level/child/grandchild/self/cycle;explicit child在parent active/archived/deleted时仍独立分类,legacy orphan不猜测;新reader标记explicit/legacy,旧adapter允许Live与legacy但拒绝explicit standalone。 - Generic REST与ACP create/restore在任何bridge/admission调用前拒绝explicit standalone;legacy restore仍保持PR2前metadata shape和行为,Live reserved gate回归不变。 -- Mixed-case restore:单一legacy storage ID在REST、ACP HTTP和ACP child load/resume中都保留storage spelling用于bridge与directory hash;lowercase exact与uppercase twin并存时四个入口都在materialize/bridge前返回conflict;global admission仍视为persisted占用。 +- Mixed-case restore:单一legacy storage ID在REST、ACP HTTP和ACP child load/resume中保留storage spelling用于metadata、ACP child持久化与directory hash,同时daemon bridge live key保持canonical;lowercase exact与uppercase twin并存时四个入口都在materialize/bridge前返回conflict;global admission仍视为persisted占用。 - Root/child:new、valid empty reuse、non-empty conflict、missing recreate、symlink/junction、wrong owner/mode、file、nested、root replacement、child inode replacement、TOCTOU revalidation、Windows case/canonical behavior;standalone失败路径不调用目录删除,保留empty child可由同UUID重试复用,Live现有empty cleanup行为不变。 ### PR2B service tests diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 6ab12b650db..45f12de33a7 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -219,6 +219,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ GoalPersistenceUnavailableError: ( await importOriginal() ).GoalPersistenceUnavailableError, + SessionIdCaseConflictError: ( + await importOriginal() + ).SessionIdCaseConflictError, normalizeEventPayload: vi.fn((payload: unknown) => typeof payload === 'object' && payload !== null && @@ -903,6 +906,7 @@ import { SessionEndReason, MCPServerConfig, SessionService, + SessionIdCaseConflictError, MCPDiscoveryState, MCPServerStatus, getMCPDiscoveryState, @@ -16113,6 +16117,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { function bindRestoreMocks(opts: { sessionExists: boolean; + resolverError?: Error; resumedConversation?: { messages: unknown[] }; replayHistoryImpl?: (...args: unknown[]) => Promise; primeTurnFromHistoryImpl?: (...args: unknown[]) => unknown; @@ -16209,7 +16214,13 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { vi.mocked(SessionService).mockImplementation( () => ({ - sessionExists: vi.fn().mockResolvedValue(opts.sessionExists), + findSessionIdIgnoringCase: opts.resolverError + ? vi.fn().mockRejectedValue(opts.resolverError) + : vi + .fn() + .mockImplementation(async (sessionId: string) => + opts.sessionExists ? sessionId : undefined, + ), loadSession, readRestoreProjection, readLiveRestoreProjection, @@ -16464,7 +16475,9 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { const sessionService = vi.mocked(SessionService).mock.results[0]?.value; expect(sessionService).toBeDefined(); - expect(sessionService!.sessionExists).toHaveBeenCalledWith(sessionId); + expect(sessionService!.findSessionIdIgnoringCase).toHaveBeenCalledWith( + sessionId, + ); await agent.cancel({ sessionId: params.sessionId }); expect(lastSessionMock?.cancelPendingPrompt).toHaveBeenCalledOnce(); @@ -16475,15 +16488,41 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }, ); + it.each(['load', 'resume'] as const)( + '%s rejects case-only persisted session conflicts', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + bindRestoreMocks({ + sessionExists: false, + resolverError: new SessionIdCaseConflictError(sessionId), + }); + const { agent, agentPromise } = await spawnAgent(); + + try { + const request = { cwd: '/tmp', sessionId, mcpServers: [] }; + const result = + action === 'load' + ? agent.loadSession(request) + : agent.unstable_resumeSession(request); + await expect(result).rejects.toMatchObject({ + data: { errorKind: 'session_id_conflict', sessionId }, + }); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }, + ); + it('serializes non-live load and resume before settings or disk work', async () => { const innerConfig = bindRestoreMocks({ sessionExists: true }); let releaseExists!: () => void; const existsGate = new Promise((resolve) => { releaseExists = resolve; }); - const sessionExists = vi.fn(async () => { + const findSessionIdIgnoringCase = vi.fn(async (sessionId: string) => { await existsGate; - return true; + return sessionId; }); const loadSession = vi .fn() @@ -16492,7 +16531,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { vi.mocked(SessionService).mockImplementation( () => ({ - sessionExists, + findSessionIdIgnoringCase, loadSession, readRestoreProjection: projectionService.readRestoreProjection, readLiveRestoreProjection: @@ -16508,7 +16547,9 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }; const first = agent.loadSession(params); - await vi.waitFor(() => expect(sessionExists).toHaveBeenCalledOnce()); + await vi.waitFor(() => + expect(findSessionIdIgnoringCase).toHaveBeenCalledOnce(), + ); await expect(agent.unstable_resumeSession(params)).rejects.toMatchObject({ code: -32602, data: { @@ -16517,7 +16558,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }, }); expect(loadSettings).toHaveBeenCalledOnce(); - expect(sessionExists).toHaveBeenCalledOnce(); + expect(findSessionIdIgnoringCase).toHaveBeenCalledOnce(); releaseExists(); await expect(first).resolves.toBeDefined(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 99514a5878b..b979c51729e 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -30,6 +30,7 @@ import { MCP_BUDGET_WARN_FRACTION, MCPServerConfig, runForkedAgent, + SessionIdCaseConflictError, SessionService, SESSION_WRITER_RPC_CODES, SessionWriterUnavailableError, @@ -5279,10 +5280,18 @@ class QwenAgent implements Agent { const persistedSessionId = await profiler.time('existence_check', () => this.runWithPinnedRuntimeBaseDir(settings, params.cwd, async () => { const sessionService = new SessionService(params.cwd); - if (await sessionService.sessionExists(sessionId)) { - return sessionId; + try { + return await sessionService.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if (error instanceof SessionIdCaseConflictError) { + throw new RequestError( + ACP_ERROR_CODES.INVALID_PARAMS, + `Multiple persisted sessions match ${sessionId} by case.`, + { errorKind: 'session_id_conflict', sessionId }, + ); + } + throw error; } - return sessionService.findSessionIdIgnoringCase?.(sessionId); }), ); if (!persistedSessionId) { @@ -5595,10 +5604,18 @@ class QwenAgent implements Agent { const persistedSessionId = await profiler.time('existence_check', () => this.runWithPinnedRuntimeBaseDir(settings, params.cwd, async () => { const sessionService = new SessionService(params.cwd); - if (await sessionService.sessionExists(sessionId)) { - return sessionId; + try { + return await sessionService.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if (error instanceof SessionIdCaseConflictError) { + throw new RequestError( + ACP_ERROR_CODES.INVALID_PARAMS, + `Multiple persisted sessions match ${sessionId} by case.`, + { errorKind: 'session_id_conflict', sessionId }, + ); + } + throw error; } - return sessionService.findSessionIdIgnoringCase?.(sessionId); }), ); if (!persistedSessionId) { diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 13adb3a83ad..44c682ffbfa 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -55,6 +55,7 @@ import { parseSessionSource } from '@qwen-code/acp-bridge'; import { restoreRetryAfterSeconds } from '@qwen-code/acp-bridge/sessionRestoreTimeout'; import { isReservedLiveSessionSource, + isReservedStandaloneSessionSource, readLoadableLiveConversationMetadata, } from '../conversations/session-source.js'; import { @@ -851,6 +852,15 @@ export function toRpcError(err: unknown): { sessionId: (err as { sessionId?: unknown }).sessionId, }, }; + case 'SessionIdCaseConflictError': + return { + code: RPC.INTERNAL_ERROR, + message: errMsg(err), + data: { + errorKind: 'session_conflict', + sessionId: (err as { sessionId?: unknown }).sessionId, + }, + }; case 'SessionArchivingError': return { code: RPC.INTERNAL_ERROR, @@ -1633,6 +1643,23 @@ export class AcpDispatcher { return; } const sessionRuntime = this.getSessionRuntimeContext(); + if ( + isReservedStandaloneSessionSource({ + sourceType: + typeof params['sourceType'] === 'string' + ? params['sourceType'] + : undefined, + }) + ) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + 'The requested session source is reserved for daemon-owned standalone sessions.', + ), + ); + return; + } const source = parseSessionSource( params['sourceType'], params['sourceId'], @@ -1800,30 +1827,42 @@ export class AcpDispatcher { [sessionId], async () => { assertGenerationOpen?.(); + const sessionService = new SessionService(cwd, { + runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, + }); + let storageSessionId = sessionId; + if (this.liveSessionIsolation) { + storageSessionId = + (await sessionService.findSessionIdIgnoringCase( + sessionId, + )) ?? ''; + if (!storageSessionId) { + throw new SessionNotFoundError(sessionId); + } + } await assertSessionLoadable( cwd, - sessionId, + storageSessionId, sessionRuntime.sessionRuntimeBaseDir, ); // Re-seed the persisted parent lineage so a restored sub-session // still reports its parent over the ACP transport (parity with the // REST restore handler); the bridge creates the entry without it. - const sessionService = new SessionService(cwd, { - runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, - }); const metadata = this.liveSessionIsolation ? await readLoadableLiveConversationMetadata( - sessionId, - (candidateId) => - sessionService.readCreationMetadata(candidateId), + storageSessionId, + sessionService, ) - : await sessionService.readCreationMetadata(sessionId); - if (metadata === undefined) { + : await sessionService.readCreationMetadata(storageSessionId); + if ( + metadata === undefined || + isReservedStandaloneSessionSource(metadata) + ) { throw new SessionNotFoundError(sessionId); } const liveConversationCwd = this.liveSessionIsolation ? await this.liveSessionIsolation.materializeConversationDirectory( - sessionId, + storageSessionId, ) : undefined; assertGenerationOpen?.(); diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index c5833c4d48a..135937638aa 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -39,6 +39,7 @@ import { } from '@qwen-code/acp-bridge/bridgeErrors'; import { SessionOrganizationService, + SessionIdCaseConflictError, SessionService, Storage, } from '@qwen-code/qwen-code-core'; @@ -82,6 +83,9 @@ import { MAX_TRUST_REASON_LENGTH, MAX_VOICE_MODEL_LENGTH, } from '../validation-limits.js'; +import { AcpDispatcher } from './dispatch.js'; +import { ConnectionRegistry } from './connection-registry.js'; +import type { TransportStream } from './transport-stream.js'; const stdioMocks = vi.hoisted(() => ({ writeStderrLine: vi.fn(), @@ -1567,6 +1571,35 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('session/new rejects standalone before validating sourceId', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 90, + method: 'session/new', + params: { + cwd: '/ws', + sourceType: 'standalone', + sourceId: 42, + }, + }); + expect(ack.status).toBe(202); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number; message: string }; + }>; + expect(frame).toMatchObject({ + id: 90, + error: { + code: -32602, + message: expect.stringContaining('standalone'), + }, + }); + }); + it('maps workspace session admission failures to retryable RPC error data', async () => { bridge.spawnOrAttach = async () => { throw new SessionLimitExceededError(20); @@ -4773,6 +4806,204 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); + it.each(['session/load', 'session/resume'] as const)( + '%s hides explicit standalone transcripts from generic restore', + async (method) => { + await withRuntimeDir(async () => { + const sessionId = + method === 'session/load' + ? '550e8400-e29b-41d4-a716-446655440133' + : '550e8400-e29b-41d4-a716-446655440134'; + await writeStoredSession(sessionId, 'active', undefined, 'standalone'); + const loadCount = bridge.loadRequests.length; + const resumeCount = bridge.resumeRequests.length; + + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 218, + method, + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ + id: 218, + error: { message: expect.stringContaining('No session with id') }, + }); + reader.close(); + + expect(bridge.loadRequests).toHaveLength(loadCount); + expect(bridge.resumeRequests).toHaveLength(resumeCount); + }); + }, + ); + + it('keeps the bridge key canonical while isolating mixed-case storage', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440135'; + const storageSessionId = sessionId.toUpperCase(); + const explicitSessionId = '550e8400-e29b-41d4-a716-446655440136'; + const materializeConversationDirectory = vi.fn( + async (candidateId: string) => `/live/conversation-${candidateId}`, + ); + await writeStoredSession(storageSessionId, 'active', undefined, 'default'); + await writeStoredSession( + explicitSessionId, + 'active', + undefined, + 'standalone', + ); + const changeSessionCwd = vi.fn( + async (candidateId: string, request: { path: string }) => ({ + sessionId: candidateId, + previousCwd: TEST_WORKSPACE, + newCwd: request.path, + warnings: [], + }), + ); + Object.assign(bridge, { changeSessionCwd }); + const archiveCoordinator = new SessionArchiveCoordinator(); + const registry = new ConnectionRegistry(); + const rememberLane = new WorkspaceRememberTaskLane( + bridge as unknown as HttpAcpBridge, + ); + const dispatcher = new AcpDispatcher( + bridge as unknown as HttpAcpBridge, + TEST_WORKSPACE, + () => process.env, + fakeWorkspace, + rememberLane, + createRequestedSessionIdAdmission({ + archiveCoordinator, + getBridges: () => [bridge as unknown as HttpAcpBridge], + getPersistenceTargets: () => [ + { + workspaceCwd: TEST_WORKSPACE, + runtimeBaseDir: Storage.getRuntimeBaseDir(), + }, + ], + }), + undefined, + undefined, + false, + registry, + archiveCoordinator, + () => true, + () => undefined, + { + materializeConversationDirectory, + isSessionActive: () => false, + }, + Storage.getRuntimeBaseDir(), + ); + const conn = registry.create(true)!; + const frames: unknown[] = []; + let resolverSpy: { mockRestore(): void } | undefined; + conn.attachConnStream({ + kind: 'sse', + isClosed: false, + async send(message: unknown): Promise { + frames.push(message); + }, + async sendSerialized(payload: Buffer) { + frames.push(JSON.parse(payload.toString('utf8'))); + return 'delivered' as const; + }, + close(): void {}, + } satisfies TransportStream); + + try { + await dispatcher.handle(conn, { + jsonrpc: '2.0', + id: 217, + method: 'session/load', + params: { sessionId }, + }); + await waitUntil(() => frames.length > 0); + expect(frames).toContainEqual( + expect.objectContaining({ id: 217, result: expect.any(Object) }), + ); + expect(bridge.loadRequests).toContainEqual( + expect.objectContaining({ sessionId }), + ); + expect(materializeConversationDirectory).toHaveBeenCalledWith( + storageSessionId, + ); + expect(changeSessionCwd).toHaveBeenCalledWith(sessionId, { + path: `/live/conversation-${storageSessionId}`, + allowedRoots: [TEST_WORKSPACE], + managedRelocation: 'live-conversation', + }); + + const loadCount = bridge.loadRequests.length; + const materializeCount = + materializeConversationDirectory.mock.calls.length; + await dispatcher.handle(conn, { + jsonrpc: '2.0', + id: 218, + method: 'session/load', + params: { sessionId: explicitSessionId }, + }); + await waitUntil(() => + frames.some( + (frame) => + typeof frame === 'object' && + frame !== null && + 'id' in frame && + frame.id === 218, + ), + ); + expect(frames).toContainEqual( + expect.objectContaining({ + id: 218, + error: expect.objectContaining({ + message: expect.stringContaining('No session with id'), + }), + }), + ); + expect(bridge.loadRequests).toHaveLength(loadCount); + expect(materializeConversationDirectory).toHaveBeenCalledTimes( + materializeCount, + ); + + resolverSpy = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValueOnce(new SessionIdCaseConflictError(sessionId)); + await dispatcher.handle(conn, { + jsonrpc: '2.0', + id: 219, + method: 'session/load', + params: { sessionId }, + }); + await waitUntil(() => + frames.some( + (frame) => + typeof frame === 'object' && + frame !== null && + 'id' in frame && + frame.id === 219, + ), + ); + expect(frames).toContainEqual( + expect.objectContaining({ + id: 219, + error: expect.objectContaining({ + message: expect.stringContaining('by case'), + data: expect.objectContaining({ errorKind: 'session_conflict' }), + }), + }), + ); + expect(bridge.loadRequests).toHaveLength(loadCount); + expect(materializeConversationDirectory).toHaveBeenCalledTimes( + materializeCount, + ); + } finally { + resolverSpy?.mockRestore(); + registry.dispose(); + rememberLane.dispose(); + } + }); + it('session/prompt reports an archive conflict while prompt is in flight', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440127'; diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index 87d04ae9397..16009458257 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -24,6 +24,7 @@ import { getConversationRootPath, revalidateConversationRoot, } from './conversation-workspace.js'; +import { ConversationDirectoryIdentityError } from '../../utils/conversation-directory-identity.js'; const cleanup: string[] = []; @@ -139,6 +140,34 @@ describe('Live conversation workspace root', () => { await expect(workspace.revalidate()).rejects.toThrow(/identity changed/); }); + it('preserves Live filesystem errors while standalone keeps root scope', async () => { + const liveHome = await tempHome(); + const liveWorkspace = new ConversationWorkspace({ homeDir: liveHome }); + const liveRoot = await liveWorkspace.getRoot(); + await rm(liveRoot.configuredRoot, { recursive: true }); + await expect(liveWorkspace.revalidate()).rejects.toMatchObject({ + code: 'ENOENT', + }); + + const standaloneHome = await tempHome(); + const standaloneWorkspace = new ConversationWorkspace({ + homeDir: standaloneHome, + }); + const standaloneRoot = await standaloneWorkspace.getRoot(); + await rename( + standaloneRoot.configuredRoot, + `${standaloneRoot.configuredRoot}-old`, + ); + await mkdir(standaloneRoot.configuredRoot, { mode: 0o700 }); + await expect( + standaloneWorkspace.inspectStandaloneDirectory('standalone'), + ).rejects.toMatchObject({ + name: 'ConversationDirectoryIdentityError', + scope: 'root', + reason: 'identity_changed', + }); + }); + it('accepts only the exact configured or canonical root identity', async () => { const home = await tempHome(); const workspace = new ConversationWorkspace({ homeDir: home }); @@ -212,4 +241,84 @@ describe('Live conversation workspace root', () => { ).resolves.toBe(false); expect((await lstat(occupied)).isDirectory()).toBe(true); }); + + it('prepares only a new or reusable empty standalone child', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + + const created = await workspace.prepareStandaloneDirectory('standalone'); + const reused = await workspace.prepareStandaloneDirectory('standalone'); + expect(created.created).toBe(true); + expect(reused.created).toBe(false); + expect(reused.identity).toEqual(created.identity); + + await writeFile(join(created.identity.canonicalPath, 'keep.txt'), 'keep'); + await expect( + workspace.prepareStandaloneDirectory('standalone'), + ).rejects.toMatchObject({ + name: 'ConversationDirectoryIdentityError', + scope: 'child', + reason: 'not_empty', + }); + expect((await lstat(created.identity.canonicalPath)).isDirectory()).toBe( + true, + ); + }); + + it('sanitizes standalone child filesystem errors', async () => { + if (process.platform === 'win32') return; + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + await chmod(prepared.identity.canonicalPath, 0o000); + try { + const error = await workspace + .prepareStandaloneDirectory('standalone') + .catch((cause: unknown) => cause); + expect(error).toMatchObject({ + name: 'ConversationDirectoryIdentityError', + scope: 'child', + reason: 'io_error', + }); + expect((error as Error).message).not.toContain( + prepared.identity.canonicalPath, + ); + expect(JSON.stringify(error)).not.toContain( + prepared.identity.canonicalPath, + ); + } finally { + await chmod(prepared.identity.canonicalPath, 0o700); + } + }); + + it('inspects, recreates, and rejects replaced standalone child identities', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + + await expect( + workspace.inspectStandaloneDirectory('standalone'), + ).resolves.toEqual({ status: 'missing' }); + const recreated = await workspace.ensureStandaloneDirectory('standalone'); + expect(recreated.status).toBe('recreated'); + if (recreated.status !== 'recreated') throw new Error('expected recreate'); + + await expect( + workspace.inspectStandaloneDirectory('standalone', recreated.identity), + ).resolves.toMatchObject({ status: 'ready' }); + + await rm(recreated.identity.canonicalPath, { recursive: true }); + await mkdir(recreated.identity.canonicalPath, { mode: 0o700 }); + const compromised = await workspace.inspectStandaloneDirectory( + 'standalone', + recreated.identity, + ); + expect(compromised.status).toBe('compromised'); + if (compromised.status !== 'compromised') { + throw new Error('expected compromised'); + } + expect(compromised.error).toBeInstanceOf( + ConversationDirectoryIdentityError, + ); + expect(compromised.error.reason).toBe('unexpected_identity'); + }); }); diff --git a/packages/cli/src/serve/conversations/conversation-workspace.ts b/packages/cli/src/serve/conversations/conversation-workspace.ts index 3174afbcf6d..730b4605bee 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.ts @@ -4,169 +4,127 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createHash } from 'node:crypto'; -import type { Stats } from 'node:fs'; -import { lstat, mkdir, realpath, rmdir } from 'node:fs/promises'; +import { readdir, rmdir } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { resolve } from 'node:path'; +import { + assertExactConversationRootIdentity, + ConversationDirectoryIdentityError, + createConversationRootIdentity, + inspectConversationDirectoryIdentity, + materializeConversationDirectoryIdentity, + revalidateConversationRootIdentity, + type ConversationDirectoryIdentity, + type ConversationRootIdentity, +} from '../../utils/conversation-directory-identity.js'; -export interface ConversationRootIdentity { - readonly configuredRoot: string; - readonly canonicalRoot: string; - readonly device: number; - readonly inode: number; -} +export type { ConversationRootIdentity } from '../../utils/conversation-directory-identity.js'; export interface ConversationWorkspaceOptions { homeDir?: string; } -const isSamePath = (left: string, right: string): boolean => - process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; +export type StandaloneDirectoryInspection = + | { status: 'ready'; identity: ConversationDirectoryIdentity } + | { status: 'missing' } + | { + status: 'compromised'; + error: ConversationDirectoryIdentityError; + }; -function conversationDirectoryName(sessionId: string): string { - if (sessionId.length === 0 || sessionId.length > 256) { - throw new Error('Live conversation session id is invalid'); - } - return `conversation-${createHash('sha256').update(sessionId).digest('hex')}`; -} +export type StandaloneDirectoryEnsureResult = + | { status: 'ready'; identity: ConversationDirectoryIdentity } + | { status: 'recreated'; identity: ConversationDirectoryIdentity } + | { + status: 'compromised'; + error: ConversationDirectoryIdentityError; + }; -function validateRootStats(stats: Stats, label = 'root'): void { - if (stats.isSymbolicLink() || !stats.isDirectory()) { - throw new Error( - `Live conversation ${label} must be a non-symlink directory`, - ); - } - if ( - process.platform !== 'win32' && - typeof process.getuid === 'function' && - stats.uid !== process.getuid() - ) { - throw new Error( - `Live conversation ${label} must be owned by the daemon user`, - ); +function liveIdentityError( + error: unknown, + exactRoot = false, + creatingRoot = false, +): never { + if (!(error instanceof ConversationDirectoryIdentityError)) throw error; + if (error.reason === 'io_error' && error.cause !== undefined) { + throw error.cause; } - if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) { - throw new Error( - `Live conversation ${label} must be accessible only to its owner`, - ); + if (error.scope === 'root') { + switch (error.reason) { + case 'not_directory': + throw new Error( + 'Live conversation root must be a non-symlink directory', + ); + case 'wrong_owner': + throw new Error( + 'Live conversation root must be owned by the daemon user', + ); + case 'wrong_mode': + throw new Error( + 'Live conversation root must be accessible only to its owner', + ); + case 'canonical_path_changed': + throw new Error('Live conversation root canonical path changed'); + case 'identity_changed': + throw new Error( + creatingRoot + ? 'Live conversation root identity changed during validation' + : 'Live conversation root identity changed', + ); + case 'unexpected_identity': + if (exactRoot) { + throw new Error('Workspace must be the exact Live conversation root'); + } + throw new Error('Live conversation root identity changed'); + default: + throw new Error('Live conversation root identity changed'); + } } -} - -function hasIdentity(stats: Stats, root: ConversationRootIdentity): boolean { - return stats.dev === root.device && stats.ino === root.inode; -} - -async function validateConversationDirectory( - root: ConversationRootIdentity, - name: string, - candidate: string, - parent: string = root.canonicalRoot, -): Promise { - const before = await lstat(candidate); - validateRootStats(before, 'directory'); - const canonical = await realpath(candidate); - const after = await lstat(canonical); - validateRootStats(after, 'directory'); - const child = relative(parent, canonical); - if ( - child !== name || - child.includes(sep) || - child.startsWith('..') || - isAbsolute(child) || - before.dev !== after.dev || - before.ino !== after.ino - ) { - throw new Error( - 'Live conversation directory must be an owned direct child', - ); + switch (error.reason) { + case 'invalid_session_id': + throw new Error('Live conversation session id is invalid'); + case 'not_directory': + throw new Error( + 'Live conversation directory must be a non-symlink directory', + ); + case 'wrong_owner': + throw new Error( + 'Live conversation directory must be owned by the daemon user', + ); + case 'wrong_mode': + throw new Error( + 'Live conversation directory must be accessible only to its owner', + ); + default: + throw new Error( + 'Live conversation directory must be an owned direct child', + ); } - await revalidateConversationRoot(root); - return canonical; } export function getConversationRootPath(homeDir: string = homedir()): string { return resolve(homeDir, 'Documents', 'Qwen Code', 'Conversations'); } -async function createRoot( - configuredRoot: string, -): Promise { - try { - await mkdir(configuredRoot, { recursive: true, mode: 0o700 }); - } catch (error) { - let existing: Stats; - try { - existing = await lstat(configuredRoot); - } catch { - throw error; - } - validateRootStats(existing); - throw error; - } - - const before = await lstat(configuredRoot); - validateRootStats(before); - const canonicalRoot = await realpath(configuredRoot); - const after = await lstat(canonicalRoot); - validateRootStats(after); - if (before.dev !== after.dev || before.ino !== after.ino) { - throw new Error( - 'Live conversation root identity changed during validation', - ); - } - return { - configuredRoot, - canonicalRoot, - device: after.dev, - inode: after.ino, - }; -} - export async function revalidateConversationRoot( root: ConversationRootIdentity, ): Promise { - const configuredStats = await lstat(root.configuredRoot); - validateRootStats(configuredStats); - if (!hasIdentity(configuredStats, root)) { - throw new Error('Live conversation root identity changed'); - } - - const canonical = await realpath(root.configuredRoot); - if (!isSamePath(canonical, root.canonicalRoot)) { - throw new Error('Live conversation root canonical path changed'); - } - - const canonicalStats = await lstat(root.canonicalRoot); - validateRootStats(canonicalStats); - if (!hasIdentity(canonicalStats, root)) { - throw new Error('Live conversation root identity changed'); + try { + return await revalidateConversationRootIdentity(root); + } catch (error) { + liveIdentityError(error); } - return root; } export async function assertExactConversationRoot( root: ConversationRootIdentity, candidate: string, ): Promise { - await revalidateConversationRoot(root); - const resolvedCandidate = resolve(candidate); - if ( - !isSamePath(resolvedCandidate, root.configuredRoot) && - !isSamePath(resolvedCandidate, root.canonicalRoot) - ) { - throw new Error('Workspace must be the exact Live conversation root'); - } - - const stats = await lstat(resolvedCandidate); - validateRootStats(stats); - const canonical = await realpath(resolvedCandidate); - if (!isSamePath(canonical, root.canonicalRoot) || !hasIdentity(stats, root)) { - throw new Error('Workspace must be the exact Live conversation root'); - } - return root; + try { + return await assertExactConversationRootIdentity(root, candidate); + } catch (error) { + liveIdentityError(error, true); + } } export class ConversationWorkspace { @@ -177,9 +135,9 @@ export class ConversationWorkspace { this.rootPath = getConversationRootPath(options.homeDir); } - async getRoot(): Promise { + private getRootIdentity(): Promise { if (!this.rootPromise) { - const pending = createRoot(this.rootPath); + const pending = createConversationRootIdentity(this.rootPath); this.rootPromise = pending; void pending.catch(() => { if (this.rootPromise === pending) this.rootPromise = undefined; @@ -188,6 +146,18 @@ export class ConversationWorkspace { return this.rootPromise; } + async getRoot(): Promise { + try { + return await this.getRootIdentity(); + } catch (error) { + liveIdentityError(error, false, true); + } + } + + private async revalidateStandaloneRoot(): Promise { + return revalidateConversationRootIdentity(await this.getRootIdentity()); + } + async revalidate(): Promise { return revalidateConversationRoot(await this.getRoot()); } @@ -198,54 +168,134 @@ export class ConversationWorkspace { async materializeConversationDirectory(sessionId: string): Promise { const root = await this.revalidate(); - const name = conversationDirectoryName(sessionId); - const candidate = join(root.canonicalRoot, name); try { - await mkdir(candidate, { mode: 0o700 }); + return (await materializeConversationDirectoryIdentity(root, sessionId)) + .identity.canonicalPath; + } catch (error) { + liveIdentityError(error); + } + } + + async discardEmptyConversationDirectory(sessionId: string): Promise { + const root = await this.revalidate(); + let identity: ConversationDirectoryIdentity | undefined; + try { + identity = await inspectConversationDirectoryIdentity(root, sessionId); + } catch (error) { + liveIdentityError(error); + } + if (!identity) return false; + try { + await rmdir(identity.canonicalPath); } catch (error) { if ( - !error || - typeof error !== 'object' || - (error as NodeJS.ErrnoException).code !== 'EEXIST' + (error as NodeJS.ErrnoException).code === 'ENOENT' || + (error as NodeJS.ErrnoException).code === 'ENOTEMPTY' ) { - throw error; + return false; } + throw error; } + await revalidateConversationRoot(root); + return true; + } - return validateConversationDirectory(root, name, candidate); + async prepareStandaloneDirectory( + storageSessionId: string, + ): Promise<{ identity: ConversationDirectoryIdentity; created: boolean }> { + const root = await this.revalidateStandaloneRoot(); + const prepared = await materializeConversationDirectoryIdentity( + root, + storageSessionId, + ); + let entries: string[]; + try { + entries = await readdir(prepared.identity.canonicalPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new ConversationDirectoryIdentityError( + 'child', + 'identity_changed', + ); + } + throw new ConversationDirectoryIdentityError('child', 'io_error', error); + } + const identity = await inspectConversationDirectoryIdentity( + root, + storageSessionId, + prepared.identity, + ); + if (!identity) { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + if (entries.length > 0) { + throw new ConversationDirectoryIdentityError('child', 'not_empty'); + } + return { identity, created: prepared.created }; } - async discardEmptyConversationDirectory(sessionId: string): Promise { - const root = await this.revalidate(); - const name = conversationDirectoryName(sessionId); - const candidate = join(root.canonicalRoot, name); - let canonical: string; + async inspectStandaloneDirectory( + storageSessionId: string, + expected?: ConversationDirectoryIdentity, + ): Promise { + const root = await this.revalidateStandaloneRoot(); try { - canonical = await validateConversationDirectory(root, name, candidate); + const identity = await inspectConversationDirectoryIdentity( + root, + storageSessionId, + expected, + ); + return identity ? { status: 'ready', identity } : { status: 'missing' }; } catch (error) { if ( - error && - typeof error === 'object' && - (error as NodeJS.ErrnoException).code === 'ENOENT' + error instanceof ConversationDirectoryIdentityError && + error.scope === 'child' ) { - return false; + return { status: 'compromised', error }; } throw error; } + } + + async ensureStandaloneDirectory( + storageSessionId: string, + expected?: ConversationDirectoryIdentity, + ): Promise { + const inspected = await this.inspectStandaloneDirectory( + storageSessionId, + expected, + ); + if (inspected.status !== 'missing') return inspected; + + const root = await this.revalidateStandaloneRoot(); try { - await rmdir(canonical); + const materialized = await materializeConversationDirectoryIdentity( + root, + storageSessionId, + ); + if (!materialized.created && expected) { + const raced = await this.inspectStandaloneDirectory( + storageSessionId, + expected, + ); + if (raced.status !== 'missing') return raced; + throw new ConversationDirectoryIdentityError( + 'child', + 'identity_changed', + ); + } + return { + status: materialized.created ? 'recreated' : 'ready', + identity: materialized.identity, + }; } catch (error) { if ( - error && - typeof error === 'object' && - ((error as NodeJS.ErrnoException).code === 'ENOENT' || - (error as NodeJS.ErrnoException).code === 'ENOTEMPTY') + error instanceof ConversationDirectoryIdentityError && + error.scope === 'child' ) { - return false; + return { status: 'compromised', error }; } throw error; } - await revalidateConversationRoot(root); - return true; } } diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/serve/conversations/session-source.test.ts index c8f3020d311..94109f3e285 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/serve/conversations/session-source.test.ts @@ -6,71 +6,186 @@ import { describe, expect, it } from 'vitest'; import { + classifyTopLevelConversationSource, isReservedLiveSessionSource, + isReservedStandaloneSessionSource, + readLoadableConversationSession, readLoadableLiveConversationMetadata, + type ConversationSessionMetadataStore, + type LiveSessionCreationMetadata, } from './session-source.js'; -describe('readLoadableLiveConversationMetadata', () => { - const records = new Map([ +const LIVE_ID = '550e8400-e29b-41d4-a716-446655440000'; +const LIVE_CHILD_ID = '550e8400-e29b-41d4-a716-446655440001'; +const LEGACY_ID = '550e8400-e29b-41d4-a716-446655440002'; +const LEGACY_CHILD_ID = '550e8400-e29b-41d4-a716-446655440003'; +const EXPLICIT_ID = '550e8400-e29b-41d4-a716-446655440004'; +const EXPLICIT_CHILD_ID = '550e8400-e29b-41d4-a716-446655440005'; +const DELETED_PARENT_ID = '550e8400-e29b-41d4-a716-446655440006'; +const ORPHAN_ID = '550e8400-e29b-41d4-a716-446655440007'; +const GRANDCHILD_ID = '550e8400-e29b-41d4-a716-446655440008'; +const ATTRIBUTED_CHILD_ID = '550e8400-e29b-41d4-a716-446655440009'; +const MALFORMED_LIVE_ID = '550e8400-e29b-41d4-a716-44665544000a'; +const SELF_ID = '550e8400-e29b-41d4-a716-44665544000b'; +const CYCLE_A_ID = '550e8400-e29b-41d4-a716-44665544000c'; +const CYCLE_B_ID = '550e8400-e29b-41d4-a716-44665544000d'; +const LEGACY_CHILD_OF_EXPLICIT_ID = '550e8400-e29b-41d4-a716-44665544000e'; + +function createStore( + records: ReadonlyMap, +): ConversationSessionMetadataStore { + return { + async getSessionLocation(sessionId) { + return records.has(sessionId) ? 'active' : undefined; + }, + async readCreationMetadata(sessionId) { + return records.get(sessionId) ?? {}; + }, + }; +} + +describe('conversation session source classification', () => { + const records = new Map([ + [LIVE_ID, { sourceType: 'default', sourceId: 'realtime_voice:call-1' }], + [LIVE_CHILD_ID, { parentSessionId: LIVE_ID }], + [LEGACY_ID, { sourceType: 'default' }], + [LEGACY_CHILD_ID, { parentSessionId: LEGACY_ID }], + [EXPLICIT_ID, { sourceType: 'standalone' }], [ - 'coordinator', - { - sourceType: 'default', - sourceId: 'realtime_voice:call-1', - }, + EXPLICIT_CHILD_ID, + { sourceType: 'standalone', parentSessionId: DELETED_PARENT_ID }, ], - ['worker', { parentSessionId: 'coordinator' }], - ['nested-worker', { parentSessionId: 'worker' }], + [ORPHAN_ID, { parentSessionId: DELETED_PARENT_ID }], + [GRANDCHILD_ID, { parentSessionId: LEGACY_CHILD_ID }], [ - 'attributed-worker', + ATTRIBUTED_CHILD_ID, { - parentSessionId: 'coordinator', + parentSessionId: LIVE_ID, sourceType: 'default', sourceId: 'realtime_voice:forged-worker', }, ], - ['generic', { sourceType: 'default' }], - ['generic-child', { parentSessionId: 'generic' }], - ['empty-call-id', { sourceType: 'default', sourceId: 'realtime_voice:' }], + [MALFORMED_LIVE_ID, { sourceType: 'default', sourceId: 'realtime_voice:' }], + [SELF_ID, { parentSessionId: SELF_ID }], + [CYCLE_A_ID, { parentSessionId: CYCLE_B_ID }], + [CYCLE_B_ID, { parentSessionId: CYCLE_A_ID }], + [LEGACY_CHILD_OF_EXPLICIT_ID, { parentSessionId: EXPLICIT_ID }], ]); - const read = async (sessionId: string) => records.get(sessionId) ?? {}; + const store = createStore(records); - it('reserves even a malformed empty Live call id from generic creation', () => { + it('reserves Live and standalone source strings before full validation', () => { expect( isReservedLiveSessionSource({ sourceType: 'default', sourceId: 'realtime_voice:', }), ).toBe(true); + expect( + isReservedStandaloneSessionSource({ sourceType: 'standalone' }), + ).toBe(true); }); - it('accepts a versioned Coordinator and its direct worker', async () => { - await expect( - readLoadableLiveConversationMetadata('coordinator', read), - ).resolves.toEqual(records.get('coordinator')); + it('classifies compatible top-level sources', () => { + expect( + classifyTopLevelConversationSource(records.get(LIVE_ID)!), + ).toMatchObject({ kind: 'live', persistence: 'explicit' }); + expect( + classifyTopLevelConversationSource(records.get(LEGACY_ID)!), + ).toMatchObject({ kind: 'standalone', persistence: 'legacy' }); + expect( + classifyTopLevelConversationSource(records.get(EXPLICIT_ID)!), + ).toMatchObject({ kind: 'standalone', persistence: 'explicit' }); + expect( + classifyTopLevelConversationSource({ + sourceType: 'standalone', + sourceId: 'unexpected', + }), + ).toBeUndefined(); + }); + + it.each([ + [LIVE_ID, 'live', 'explicit'], + [LIVE_CHILD_ID, 'live', 'legacy'], + [LEGACY_ID, 'standalone', 'legacy'], + [LEGACY_CHILD_ID, 'standalone', 'legacy'], + [EXPLICIT_ID, 'standalone', 'explicit'], + [EXPLICIT_CHILD_ID, 'standalone', 'explicit'], + [LEGACY_CHILD_OF_EXPLICIT_ID, 'standalone', 'legacy'], + ] as const)( + 'classifies %s as %s %s', + async (sessionId, kind, persistence) => { + await expect( + readLoadableConversationSession(sessionId, store), + ).resolves.toMatchObject({ kind, persistence }); + }, + ); + + it.each([ + ORPHAN_ID, + GRANDCHILD_ID, + ATTRIBUTED_CHILD_ID, + MALFORMED_LIVE_ID, + SELF_ID, + CYCLE_A_ID, + ])('rejects malformed or ambiguous lineage for %s', async (sessionId) => { await expect( - readLoadableLiveConversationMetadata('worker', read), - ).resolves.toEqual(records.get('worker')); + readLoadableConversationSession(sessionId, store), + ).resolves.toBeUndefined(); }); - it('accepts a standalone projectless task and its direct child', async () => { + it('keeps the compatibility adapter limited to Live and legacy projectless sessions', async () => { + await expect( + readLoadableLiveConversationMetadata(LIVE_CHILD_ID, store), + ).resolves.toEqual(records.get(LIVE_CHILD_ID)); + await expect( + readLoadableLiveConversationMetadata(LEGACY_ID, store), + ).resolves.toEqual(records.get(LEGACY_ID)); + await expect( + readLoadableLiveConversationMetadata(EXPLICIT_ID, store), + ).resolves.toBeUndefined(); await expect( - readLoadableLiveConversationMetadata('generic', read), - ).resolves.toEqual(records.get('generic')); + readLoadableLiveConversationMetadata(EXPLICIT_CHILD_ID, store), + ).resolves.toBeUndefined(); await expect( - readLoadableLiveConversationMetadata('generic-child', read), - ).resolves.toEqual(records.get('generic-child')); + readLoadableLiveConversationMetadata(LEGACY_CHILD_OF_EXPLICIT_ID, store), + ).resolves.toBeUndefined(); }); - it('rejects nested, attributed, and malformed Live sessions', async () => { + it('rejects missing and conflicting transcripts without reading metadata', async () => { + let reads = 0; + const unavailableStore: ConversationSessionMetadataStore = { + async getSessionLocation(sessionId) { + return sessionId === LEGACY_ID ? 'conflict' : undefined; + }, + async readCreationMetadata() { + reads++; + return {}; + }, + }; + await expect( - readLoadableLiveConversationMetadata('nested-worker', read), + readLoadableConversationSession(LEGACY_ID, unavailableStore), ).resolves.toBeUndefined(); await expect( - readLoadableLiveConversationMetadata('attributed-worker', read), + readLoadableConversationSession(EXPLICIT_ID, unavailableStore), ).resolves.toBeUndefined(); + expect(reads).toBe(0); + }); + + it('rejects a transcript that disappears while its metadata is read', async () => { + let locationReads = 0; + const disappearingStore: ConversationSessionMetadataStore = { + async getSessionLocation() { + locationReads++; + return locationReads === 1 ? 'active' : undefined; + }, + async readCreationMetadata() { + return {}; + }, + }; + await expect( - readLoadableLiveConversationMetadata('empty-call-id', read), + readLoadableConversationSession(LEGACY_ID, disappearingStore), ).resolves.toBeUndefined(); }); }); diff --git a/packages/cli/src/serve/conversations/session-source.ts b/packages/cli/src/serve/conversations/session-source.ts index cbd72820e89..ec05ead58b1 100644 --- a/packages/cli/src/serve/conversations/session-source.ts +++ b/packages/cli/src/serve/conversations/session-source.ts @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + isValidSessionId, + normalizeSessionIdForLookup, +} from '../../config/session-id.js'; + export const LIVE_SESSION_SOURCE_PREFIX = 'realtime_voice:'; +export const STANDALONE_SESSION_SOURCE_TYPE = 'standalone'; export interface LiveSessionCreationMetadata { parentSessionId?: string; @@ -12,6 +18,21 @@ export interface LiveSessionCreationMetadata { sourceId?: string; } +export interface ConversationSessionMetadataStore { + getSessionLocation( + sessionId: string, + ): Promise<'active' | 'archived' | 'conflict' | undefined>; + readCreationMetadata(sessionId: string): Promise; +} + +export type ConversationSessionKind = 'live' | 'standalone'; + +export interface LoadableConversationSession { + kind: ConversationSessionKind; + persistence: 'explicit' | 'legacy'; + metadata: LiveSessionCreationMetadata; +} + export function isReservedLiveSessionSource(source: { sourceType?: string; sourceId?: string; @@ -34,7 +55,13 @@ export function isCompatibleLiveSessionSource(source: { ); } -function isStandaloneConversationSessionSource(source: { +export function isReservedStandaloneSessionSource(source: { + sourceType?: string; +}): boolean { + return source.sourceType === STANDALONE_SESSION_SOURCE_TYPE; +} + +function isCompatibleLegacyStandaloneSource(source: { sourceType?: string; sourceId?: string; }): boolean { @@ -44,29 +71,106 @@ function isStandaloneConversationSessionSource(source: { ); } -export async function readLoadableLiveConversationMetadata( +export function classifyTopLevelConversationSource( + metadata: LiveSessionCreationMetadata, +): LoadableConversationSession | undefined { + if (metadata.parentSessionId !== undefined) return undefined; + if (isCompatibleLiveSessionSource(metadata)) { + return { kind: 'live', persistence: 'explicit', metadata }; + } + if ( + isReservedStandaloneSessionSource(metadata) && + metadata.sourceId === undefined + ) { + return { kind: 'standalone', persistence: 'explicit', metadata }; + } + if (isCompatibleLegacyStandaloneSource(metadata)) { + return { kind: 'standalone', persistence: 'legacy', metadata }; + } + return undefined; +} + +async function readExistingMetadata( sessionId: string, - readMetadata: (sessionId: string) => Promise, + store: ConversationSessionMetadataStore, ): Promise { - const metadata = await readMetadata(sessionId); + const location = await store.getSessionLocation(sessionId); + if (location !== 'active' && location !== 'archived') return undefined; + const metadata = await store.readCreationMetadata(sessionId); + const confirmedLocation = await store.getSessionLocation(sessionId); + return confirmedLocation === location ? metadata : undefined; +} + +export async function readLoadableConversationSession( + sessionId: string, + store: ConversationSessionMetadataStore, +): Promise { + const metadata = await readExistingMetadata(sessionId, store); + if (!metadata) return undefined; + + const topLevel = classifyTopLevelConversationSource(metadata); + if (topLevel) return topLevel; + + const parentSessionId = metadata.parentSessionId; + if ( + parentSessionId === undefined || + !isValidSessionId(parentSessionId) || + normalizeSessionIdForLookup(parentSessionId) === + normalizeSessionIdForLookup(sessionId) + ) { + return undefined; + } + if ( - metadata.parentSessionId === undefined && - (isCompatibleLiveSessionSource(metadata) || - isStandaloneConversationSessionSource(metadata)) + isReservedStandaloneSessionSource(metadata) && + metadata.sourceId === undefined ) { - return metadata; + return { kind: 'standalone', persistence: 'explicit', metadata }; + } + + if (metadata.sourceType !== undefined || metadata.sourceId !== undefined) { + return undefined; } + + const parent = await readExistingMetadata(parentSessionId, store); + if (!parent) return undefined; + const parentSource = classifyTopLevelConversationSource(parent); + if (!parentSource) return undefined; + return { + kind: parentSource.kind, + persistence: 'legacy', + metadata, + }; +} + +export async function readLoadableLiveConversationMetadata( + sessionId: string, + store: ConversationSessionMetadataStore, +): Promise { + const result = await readLoadableConversationSession(sessionId, store); if ( - metadata.parentSessionId === undefined || - metadata.sourceType !== undefined || - metadata.sourceId !== undefined + !result || + (result.kind === 'standalone' && result.persistence === 'explicit') ) { return undefined; } - const parent = await readMetadata(metadata.parentSessionId); - return parent.parentSessionId === undefined && - (isCompatibleLiveSessionSource(parent) || - isStandaloneConversationSessionSource(parent)) - ? metadata - : undefined; + if ( + result.kind === 'standalone' && + result.metadata.parentSessionId !== undefined + ) { + const parent = await readExistingMetadata( + result.metadata.parentSessionId, + store, + ); + const parentSource = parent + ? classifyTopLevelConversationSource(parent) + : undefined; + if ( + parentSource?.kind !== 'standalone' || + parentSource.persistence !== 'legacy' + ) { + return undefined; + } + } + return result.metadata; } diff --git a/packages/cli/src/serve/live/live-task-service.test.ts b/packages/cli/src/serve/live/live-task-service.test.ts index 65668667df0..bb5a3a4fce6 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -58,6 +58,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { ); } + async getSessionLocation(sessionId: string) { + return (await this.sessionExists(sessionId)) ? 'active' : undefined; + } + readParentSessionId(sessionId: string) { return Promise.resolve(parentSessions.get(sessionId)); } diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 0b15214d628..b7b093fcd55 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -1056,7 +1056,7 @@ export class LiveTaskService { task.runtime.provenance === 'live-conversation' ? await readLoadableLiveConversationMetadata( task.summary.sessionId, - (sessionId) => service.readCreationMetadata(sessionId), + service, ) : await service.readCreationMetadata(task.summary.sessionId); if (metadata === undefined) { diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 246b25f8010..56f1bb61e49 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -39,6 +39,7 @@ import { import { parseSessionSource } from '@qwen-code/acp-bridge'; import { isReservedLiveSessionSource, + isReservedStandaloneSessionSource, readLoadableLiveConversationMetadata, } from '../conversations/session-source.js'; import type { ConversationRuntimeActivityGate } from '../conversations/conversation-runtime-activity.js'; @@ -1052,7 +1053,7 @@ export function registerSessionRoutes( } const metadata = await readLoadableLiveConversationMetadata( sessionId, - (candidateId) => service.readCreationMetadata(candidateId), + service, ); if (!metadata) throw new SessionNotFoundError(sessionId); } @@ -1537,9 +1538,8 @@ export function registerSessionRoutes( if (!isInternalWorkspaceRuntime(runtime)) return true; const service = createWorkspaceRuntimeSessionService(runtime); return ( - (await readLoadableLiveConversationMetadata(sessionId, (candidateId) => - service.readCreationMetadata(candidateId), - )) !== undefined + (await readLoadableLiveConversationMetadata(sessionId, service)) !== + undefined ); }; const throwMissingActiveTranscript = (): never => { @@ -1776,7 +1776,7 @@ export function registerSessionRoutes( if (!exists) continue; const metadata = await readLoadableLiveConversationMetadata( sessionId, - (candidateId) => service.readCreationMetadata(candidateId), + service, ); if (!assertCurrentInternalGeneration(entry, generation, res)) { return undefined; @@ -1872,7 +1872,7 @@ export function registerSessionRoutes( if (!exists) continue; const metadata = await readLoadableLiveConversationMetadata( sessionId, - (candidateId) => service.readCreationMetadata(candidateId), + service, ); if (!assertCurrentInternalGeneration(entry, generation, res)) { return undefined; @@ -1927,7 +1927,7 @@ export function registerSessionRoutes( } const metadata = await readLoadableLiveConversationMetadata( sessionId, - (candidateId) => service.readCreationMetadata(candidateId), + service, ); if (!metadata) { throw new SessionNotFoundError(sessionId); @@ -2209,6 +2209,21 @@ export function registerSessionRoutes( } const approvalMode = parseOptionalApprovalMode(body, res); if (approvalMode === null) return; + if ( + isReservedStandaloneSessionSource({ + sourceType: + typeof body['sourceType'] === 'string' + ? body['sourceType'] + : undefined, + }) + ) { + res.status(400).json({ + error: + 'The requested session source is reserved for daemon-owned standalone sessions.', + code: 'reserved_session_source', + }); + return; + } const source = parseSessionSource(body['sourceType'], body['sourceId']); if ('error' in source) { res.status(400).json({ @@ -2979,13 +2994,24 @@ export function registerSessionRoutes( throw error; } } + let restoredStorageSessionId = sessionId; try { const session = await archiveCoordinator.runSharedMany( [sessionId], async () => { + const sessionService = + createWorkspaceRuntimeSessionService(runtime); + if (isInternalWorkspaceRuntime(runtime)) { + restoredStorageSessionId = + (await sessionService.findSessionIdIgnoringCase(sessionId)) ?? + ''; + if (!restoredStorageSessionId) { + throw new SessionNotFoundError(sessionId); + } + } const location = await assertSessionLoadable( workspaceCwd, - sessionId, + restoredStorageSessionId, runtime.sessionRuntimeBaseDir, ); if (location === undefined && isInternalWorkspaceRuntime(runtime)) { @@ -2994,17 +3020,19 @@ export function registerSessionRoutes( // Recover the persisted parent lineage so the restored live entry // reports it (the bridge otherwise creates the entry without it, and // status calls would show a restored sub-session as top-level). - const sessionService = - createWorkspaceRuntimeSessionService(runtime); const metadata = runtime.provenance === 'live-conversation' ? await readLoadableLiveConversationMetadata( - sessionId, - (candidateId) => - sessionService.readCreationMetadata(candidateId), + restoredStorageSessionId, + sessionService, ) - : await sessionService.readCreationMetadata(sessionId); - if (metadata === undefined) { + : await sessionService.readCreationMetadata( + restoredStorageSessionId, + ); + if ( + metadata === undefined || + isReservedStandaloneSessionSource(metadata) + ) { throw new SessionNotFoundError(sessionId); } assertRuntimeGenerationOpen?.(); @@ -3025,7 +3053,7 @@ export function registerSessionRoutes( if (!materialize) { throw new Error('Live conversation workspace is unavailable.'); } - liveConversationCwd = await materialize(sessionId); + liveConversationCwd = await materialize(restoredStorageSessionId); } assertRuntimeGenerationOpen?.(); const restored = @@ -3165,7 +3193,7 @@ export function registerSessionRoutes( const sidecar = await readWorktreeSession( createWorkspaceRuntimeSessionService( runtime, - ).getWorktreeSessionPath(sessionId), + ).getWorktreeSessionPath(restoredStorageSessionId), ).catch(() => null); if (sidecar) { // Defense-in-depth: resolve symlinks on both the target and diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 59056ec1c2b..a585ccd8e49 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -78,6 +78,7 @@ import { BTW_MAX_INPUT_LENGTH, ExtensionManager, ExtensionUpdateState, + SessionIdCaseConflictError, SessionService, Storage, TrustGateError, @@ -9934,9 +9935,9 @@ describe('createServeApp', () => { undefined, { workspaceRegistry }, ); - const scan = deferred(); - const locationSpy = vi - .spyOn(SessionService.prototype, 'getSessionLocation') + const scan = deferred(); + const resolverSpy = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') .mockReturnValue(scan.promise); try { @@ -9945,7 +9946,7 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ sessionId: '550e8400-e29b-41d4-a716-446655440004' }) .then((response) => response); - await vi.waitFor(() => expect(locationSpy).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(resolverSpy).toHaveBeenCalledOnce()); expect( workspaceRegistry.beginReplacement( workspaceRegistry.primaryEntry, @@ -9960,7 +9961,7 @@ describe('createServeApp', () => { expect(bridge.calls).toEqual([]); } finally { scan.resolve(undefined); - locationSpy.mockRestore(); + resolverSpy.mockRestore(); } }); @@ -10020,6 +10021,24 @@ describe('createServeApp', () => { expect(bridge.calls).toHaveLength(0); }); + it('rejects the reserved standalone source before validating sourceId', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sourceType: 'standalone', sourceId: 42 }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('reserved_session_source'); + expect(res.body.error).toContain('standalone'); + expect(bridge.calls).toHaveLength(0); + }); + it('forwards a valid UUID sessionId to the bridge', async () => { const bridge = fakeBridge(); const app = createServeApp( @@ -10122,9 +10141,9 @@ describe('createServeApp', () => { undefined, { bridge }, ); - const locationSpy = vi - .spyOn(SessionService.prototype, 'getSessionLocation') - .mockResolvedValue('active'); + const resolverSpy = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockImplementation(async (sessionId) => sessionId); try { const res = await request(app) .post('/session') @@ -10135,7 +10154,7 @@ describe('createServeApp', () => { expect(res.body.code).toBe('session_id_conflict'); expect(bridge.calls).toHaveLength(0); } finally { - locationSpy.mockRestore(); + resolverSpy.mockRestore(); } }); @@ -10146,8 +10165,8 @@ describe('createServeApp', () => { undefined, { bridge }, ); - const locationSpy = vi - .spyOn(SessionService.prototype, 'getSessionLocation') + const resolverSpy = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') .mockRejectedValue(new Error('EACCES: runtime directory unreadable')); try { const res = await request(app) @@ -10162,7 +10181,7 @@ describe('createServeApp', () => { }); expect(bridge.calls).toHaveLength(0); } finally { - locationSpy.mockRestore(); + resolverSpy.mockRestore(); } }); @@ -10173,9 +10192,9 @@ describe('createServeApp', () => { undefined, { bridge }, ); - const locationSpy = vi - .spyOn(SessionService.prototype, 'getSessionLocation') - .mockResolvedValue('active'); + const resolverSpy = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockImplementation(async (sessionId) => sessionId); const runWithSpy = vi.spyOn(Storage, 'runWithRuntimeBaseDir'); try { const res = await request(app) @@ -10188,7 +10207,7 @@ describe('createServeApp', () => { expect(runWithSpy).not.toHaveBeenCalled(); expect(bridge.calls).toHaveLength(0); } finally { - locationSpy.mockRestore(); + resolverSpy.mockRestore(); runWithSpy.mockRestore(); } }); @@ -11943,6 +11962,35 @@ describe('createServeApp', () => { } }); + it.each(['load', 'resume'] as const)( + 'hides explicit standalone transcripts from generic %s', + async (action) => { + const bridge = fakeBridge(); + const readCreationMetadata = vi + .spyOn(SessionService.prototype, 'readCreationMetadata') + .mockResolvedValue({ sourceType: 'standalone' }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + const res = await request(app) + .post(`/session/explicit-standalone/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.code).toBe('session_not_found'); + expect(bridge.loadCalls).toEqual([]); + expect(bridge.resumeCalls).toEqual([]); + } finally { + readCreationMetadata.mockRestore(); + } + }, + ); + it('releases restore ownership after invalid approvalMode', async () => { const bridge = fakeBridge(); const app = createServeApp( @@ -30625,8 +30673,8 @@ describe('Live conversation runtime lifecycle', () => { it('boots an exact Conversations restore target before source proof', async () => { const restoreLiveSettings = await disableLiveVoiceAtBoot(); const setup = setupLiveRuntime(); - const getLocation = vi - .spyOn(SessionService.prototype, 'getSessionLocation') + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') .mockResolvedValue(undefined); mockWt.realpath = (candidate) => candidate; try { @@ -30644,10 +30692,10 @@ describe('Live conversation runtime lifecycle', () => { expect(response.status).toBe(404); expect(response.body.code).toBe('session_not_found'); expect(setup.liveBridge.loadCalls).toHaveLength(0); - expect(getLocation).toHaveBeenCalled(); + expect(findSessionId).toHaveBeenCalled(); } finally { mockWt.realpath = undefined; - getLocation.mockRestore(); + findSessionId.mockRestore(); await restoreLiveSettings(); } }); @@ -30917,7 +30965,18 @@ describe('Live conversation runtime lifecycle', () => { .spyOn(SessionService.prototype, 'readCreationMetadata') .mockImplementation(async (sessionId) => { if (sessionId === 'worker-session') { - return { parentSessionId: 'live-load' }; + return { + parentSessionId: '550e8400-e29b-41d4-a716-446655440001', + }; + } + if (sessionId === '550e8400-e29b-41d4-a716-446655440001') { + return { + sourceType: 'default', + sourceId: 'realtime_voice:p1:h1:a1:live-load', + }; + } + if (sessionId === 'explicit-standalone') { + return { sourceType: 'standalone' }; } return sessionId.startsWith('live-') ? { @@ -30929,6 +30988,9 @@ describe('Live conversation runtime lifecycle', () => { const getLocation = vi .spyOn(SessionService.prototype, 'getSessionLocation') .mockResolvedValue('active'); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockImplementation(async (sessionId) => sessionId); try { const rejectedNew = await request(setup.app) .post('/session') @@ -30959,6 +31021,21 @@ describe('Live conversation runtime lifecycle', () => { path: `${setup.root.canonicalRoot}/conversation-generic-session`, }); + const loadCountBeforeExplicit = setup.liveBridge.loadCalls.length; + const materializeCountBeforeExplicit = + setup.conversationWorkspace.materializeConversationDirectory.mock.calls + .length; + const explicitStandaloneRestore = await request(setup.app) + .post('/session/explicit-standalone/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: setup.root.canonicalRoot }); + expect(explicitStandaloneRestore.status).toBe(404); + expect(explicitStandaloneRestore.body.code).toBe('session_not_found'); + expect(setup.liveBridge.loadCalls).toHaveLength(loadCountBeforeExplicit); + expect( + setup.conversationWorkspace.materializeConversationDirectory, + ).toHaveBeenCalledTimes(materializeCountBeforeExplicit); + for (const action of ['load', 'resume'] as const) { const sessionId = `live-${action}`; const restored = await request(setup.app) @@ -31006,6 +31083,7 @@ describe('Live conversation runtime lifecycle', () => { expect(setup.liveBridge.loadCalls).toHaveLength(5); expect(setup.liveBridge.resumeCalls).toHaveLength(1); } finally { + findSessionId.mockRestore(); getLocation.mockRestore(); readCreationMetadata.mockRestore(); await ( @@ -31014,6 +31092,66 @@ describe('Live conversation runtime lifecycle', () => { } }); + it('uses authoritative persisted spelling for internal restore and rejects case conflicts', async () => { + const setup = setupLiveRuntime(); + setup.registry.add(setup.liveRuntime); + const canonicalSessionId = '550e8400-e29b-41d4-a716-446655440000'; + const storageSessionId = canonicalSessionId.toUpperCase(); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockImplementation(async (sessionId) => + sessionId === canonicalSessionId ? storageSessionId : sessionId, + ); + const getLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockResolvedValue('active'); + const readCreationMetadata = vi + .spyOn(SessionService.prototype, 'readCreationMetadata') + .mockResolvedValue({ + sourceType: 'default', + sourceId: `realtime_voice:p1:h1:a1:${canonicalSessionId}`, + }); + try { + const restored = await request(setup.app) + .post(`/session/${canonicalSessionId}/load`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: setup.root.canonicalRoot }); + + expect(restored.status).toBe(200); + expect(setup.liveBridge.loadCalls).toContainEqual( + expect.objectContaining({ sessionId: canonicalSessionId }), + ); + expect( + setup.conversationWorkspace.materializeConversationDirectory, + ).toHaveBeenCalledWith(storageSessionId); + expect(setup.liveBridge.changeSessionCwdCalls).toContainEqual({ + sessionId: canonicalSessionId, + path: `${setup.root.canonicalRoot}/conversation-${storageSessionId}`, + }); + + findSessionId.mockRejectedValueOnce( + new SessionIdCaseConflictError(canonicalSessionId), + ); + const conflict = await request(setup.app) + .post(`/session/${canonicalSessionId}/resume`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: setup.root.canonicalRoot }); + expect(conflict.status).toBe(409); + expect(conflict.body).toMatchObject({ + code: 'session_conflict', + sessionId: canonicalSessionId, + }); + expect(setup.liveBridge.resumeCalls).toHaveLength(0); + } finally { + readCreationMetadata.mockRestore(); + getLocation.mockRestore(); + findSessionId.mockRestore(); + await ( + setup.app.locals['sealAndWaitLiveCoordinator'] as () => Promise + )(); + } + }); + it('rejects canonical aliases of the configured Live root before publication', async () => { const tmp = await fsp.mkdtemp( path.join(os.tmpdir(), 'qwen-live-reserved-root-'), diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index 8a16f99e093..7fc59af7c64 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -8,6 +8,7 @@ import type { Response } from 'express'; import { describe, expect, it, vi } from 'vitest'; import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors'; import { + SessionIdCaseConflictError, SessionTranscriptChangedError, SessionWriterConflictError, SessionWriterLostError, @@ -64,6 +65,20 @@ describe('sendBridgeError session writer errors', () => { }); }); + it('maps case-only persisted conflicts without active/archive guidance', () => { + const { response, status, json } = responseMock(); + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + + sendBridgeError(response, new SessionIdCaseConflictError(sessionId)); + + expect(status).toHaveBeenCalledWith(409); + expect(json).toHaveBeenCalledWith({ + error: `Multiple persisted sessions match "${sessionId}" by case.`, + code: 'session_conflict', + sessionId, + }); + }); + it.each([ { error: new SessionWriterConflictError(), diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 11e79ac405f..6c4146af533 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -9,6 +9,7 @@ import { InvalidSessionTranscriptCursorError, recordDaemonBridgeError, recordDaemonError, + SessionIdCaseConflictError, SessionTranscriptPageTooLargeError, SessionTranscriptSnapshotUnavailableError, SessionTranscriptTooLargeError, @@ -419,6 +420,14 @@ export function sendBridgeError( }); return; } + if (err instanceof SessionIdCaseConflictError) { + res.status(409).json({ + error: err.message, + code: 'session_conflict', + sessionId: err.sessionId, + }); + return; + } if (err instanceof SessionArchivingError) { res.set('Retry-After', '5'); res.status(409).json({ diff --git a/packages/cli/src/serve/session-id-admission.test.ts b/packages/cli/src/serve/session-id-admission.test.ts index 9f18dd6adb0..fffba06d5b2 100644 --- a/packages/cli/src/serve/session-id-admission.test.ts +++ b/packages/cli/src/serve/session-id-admission.test.ts @@ -8,6 +8,7 @@ import { promises as fsp } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SessionIdCaseConflictError } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge } from './acp-session-bridge.js'; import { SessionNotFoundError } from './acp-session-bridge.js'; import { SessionArchiveCoordinator } from './server/session-archive.js'; @@ -47,15 +48,15 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { private readonly options: { runtimeBaseDir: string }, ) {} - async getSessionLocation( + async findSessionIdIgnoringCase( sessionId: string, - ): Promise<'active' | undefined> { + ): Promise { return (await sessionServiceMock.exists( this.cwd, this.options.runtimeBaseDir, sessionId, )) - ? 'active' + ? sessionId : undefined; } @@ -223,6 +224,30 @@ describe('RequestedSessionIdAdmission', () => { }, ); + it('treats a case-only transcript conflict as persisted occupancy', async () => { + const bridge = fakeBridge(); + const admission = createRequestedSessionIdAdmission({ + archiveCoordinator: new SessionArchiveCoordinator(), + getBridges: () => [bridge], + getPersistenceTargets: () => [ + { workspaceCwd: '/one', runtimeBaseDir: '/runtime-one' }, + ], + }); + sessionServiceMock.exists.mockRejectedValueOnce( + new SessionIdCaseConflictError(SESSION_ID), + ); + + await expect( + admission.reserveCreate(SESSION_ID, { + bridge, + workspaceCwd: '/one', + }), + ).rejects.toMatchObject({ + code: 'session_id_conflict', + details: { conflict: 'persisted' }, + }); + }); + it('shares restore claims only on the same bridge generation', () => { const firstBridge = fakeBridge(); const secondBridge = fakeBridge(); diff --git a/packages/cli/src/serve/session-id-admission.ts b/packages/cli/src/serve/session-id-admission.ts index 72c15c66557..a3bf7589c25 100644 --- a/packages/cli/src/serve/session-id-admission.ts +++ b/packages/cli/src/serve/session-id-admission.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SessionService } from '@qwen-code/qwen-code-core'; +import { + SessionIdCaseConflictError, + SessionService, +} from '@qwen-code/qwen-code-core'; import { access } from 'node:fs/promises'; import { SessionNotFoundError, @@ -70,13 +73,15 @@ async function persistedSessionExists( sessionService: SessionService, sessionId: string, ): Promise { - if ((await sessionService.getSessionLocation(sessionId)) !== undefined) { - return true; - } - if ( - (await sessionService.findSessionIdIgnoringCase?.(sessionId)) !== undefined - ) { - return true; + try { + if ( + (await sessionService.findSessionIdIgnoringCase(sessionId)) !== undefined + ) { + return true; + } + } catch (error) { + if (error instanceof SessionIdCaseConflictError) return true; + throw error; } for (const state of ['active', 'archived'] as const) { diff --git a/packages/cli/src/utils/conversation-directory-identity.test.ts b/packages/cli/src/utils/conversation-directory-identity.test.ts new file mode 100644 index 00000000000..87c7e74c750 --- /dev/null +++ b/packages/cli/src/utils/conversation-directory-identity.test.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { realpathSync } from 'node:fs'; +import { + chmod, + lstat, + mkdir, + mkdtemp, + rename, + rm, + symlink, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + ConversationDirectoryIdentityError, + createConversationRootIdentity, + getConversationDirectoryName, + inspectConversationDirectoryIdentity, + materializeConversationDirectoryIdentity, + revalidateConversationRootIdentity, +} from './conversation-directory-identity.js'; + +const cleanup: string[] = []; + +afterEach(async () => { + await Promise.all( + cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function tempRoot() { + const base = await mkdtemp( + join(realpathSync.native(tmpdir()), 'qwen-conversation-identity-'), + ); + cleanup.push(base); + const configuredRoot = join(base, 'Conversations'); + return { + base, + root: await createConversationRootIdentity(configuredRoot), + }; +} + +describe('conversation directory identity', () => { + it('derives a deterministic case-sensitive child name', () => { + expect(getConversationDirectoryName('session-a')).toMatch( + /^conversation-[a-f0-9]{64}$/, + ); + expect(getConversationDirectoryName('session-a')).toBe( + getConversationDirectoryName('session-a'), + ); + expect(getConversationDirectoryName('SESSION-A')).not.toBe( + getConversationDirectoryName('session-a'), + ); + expect(() => getConversationDirectoryName('')).toThrowError( + ConversationDirectoryIdentityError, + ); + }); + + it('pins root and direct-child device and inode identity', async () => { + const { root } = await tempRoot(); + const result = await materializeConversationDirectoryIdentity( + root, + 'session-a', + ); + const stats = await lstat(result.identity.canonicalPath); + + expect(result.created).toBe(true); + expect(result.identity).toMatchObject({ + root, + storageSessionId: 'session-a', + name: getConversationDirectoryName('session-a'), + device: stats.dev, + inode: stats.ino, + }); + await expect( + inspectConversationDirectoryIdentity(root, 'session-a', result.identity), + ).resolves.toEqual(result.identity); + }); + + it('reports a missing child without guessing an identity', async () => { + const { root } = await tempRoot(); + + await expect( + inspectConversationDirectoryIdentity(root, 'missing'), + ).resolves.toBeUndefined(); + }); + + it('rejects same-path replacement against an expected identity', async () => { + const { root } = await tempRoot(); + const original = await materializeConversationDirectoryIdentity( + root, + 'replace', + ); + await rm(original.identity.canonicalPath, { recursive: true }); + await mkdir(original.identity.canonicalPath, { mode: 0o700 }); + + await expect( + inspectConversationDirectoryIdentity(root, 'replace', original.identity), + ).rejects.toMatchObject({ + scope: 'child', + reason: 'unexpected_identity', + }); + }); + + it('does not follow a replacement symlink', async () => { + const { base, root } = await tempRoot(); + const original = await materializeConversationDirectoryIdentity( + root, + 'replace', + ); + const outside = join(base, 'outside'); + await mkdir(outside, { mode: 0o700 }); + await rm(original.identity.canonicalPath, { recursive: true }); + await symlink(outside, original.identity.canonicalPath); + + await expect( + inspectConversationDirectoryIdentity(root, 'replace'), + ).rejects.toMatchObject({ scope: 'child', reason: 'not_directory' }); + }); + + it('rejects permissive existing children without changing their mode', async () => { + if (process.platform === 'win32') return; + const { root } = await tempRoot(); + const original = await materializeConversationDirectoryIdentity( + root, + 'permissive', + ); + await chmod(original.identity.canonicalPath, 0o755); + + await expect( + inspectConversationDirectoryIdentity(root, 'permissive'), + ).rejects.toMatchObject({ scope: 'child', reason: 'wrong_mode' }); + expect((await lstat(original.identity.canonicalPath)).mode & 0o777).toBe( + 0o755, + ); + }); + + it('rejects a replaced root identity', async () => { + const { root } = await tempRoot(); + await rename(root.configuredRoot, `${root.configuredRoot}-old`); + await mkdir(root.configuredRoot, { mode: 0o700 }); + + await expect( + revalidateConversationRootIdentity(root), + ).rejects.toMatchObject({ scope: 'root', reason: 'identity_changed' }); + }); +}); diff --git a/packages/cli/src/utils/conversation-directory-identity.ts b/packages/cli/src/utils/conversation-directory-identity.ts new file mode 100644 index 00000000000..c7ea1685519 --- /dev/null +++ b/packages/cli/src/utils/conversation-directory-identity.ts @@ -0,0 +1,331 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { Stats } from 'node:fs'; +import { lstat, mkdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +export interface ConversationRootIdentity { + readonly configuredRoot: string; + readonly canonicalRoot: string; + readonly device: number; + readonly inode: number; +} + +export interface ConversationDirectoryIdentity { + readonly root: ConversationRootIdentity; + readonly storageSessionId: string; + readonly name: string; + readonly canonicalPath: string; + readonly device: number; + readonly inode: number; +} + +export type ConversationDirectoryIdentityScope = 'root' | 'child'; + +export type ConversationDirectoryIdentityFailureReason = + | 'invalid_session_id' + | 'not_directory' + | 'wrong_owner' + | 'wrong_mode' + | 'io_error' + | 'identity_changed' + | 'canonical_path_changed' + | 'not_direct_child' + | 'unexpected_identity' + | 'not_empty'; + +export class ConversationDirectoryIdentityError extends Error { + override readonly name = 'ConversationDirectoryIdentityError'; + override readonly cause?: unknown; + + constructor( + readonly scope: ConversationDirectoryIdentityScope, + readonly reason: ConversationDirectoryIdentityFailureReason, + cause?: unknown, + ) { + super(`Conversation ${scope} identity validation failed: ${reason}`); + if (cause !== undefined) { + Object.defineProperty(this, 'cause', { + configurable: true, + enumerable: false, + value: cause, + }); + } + } +} + +function throwIdentityIoError( + scope: ConversationDirectoryIdentityScope, + cause: unknown, +): never { + throw new ConversationDirectoryIdentityError(scope, 'io_error', cause); +} + +export const isSameConversationPath = (left: string, right: string): boolean => + process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; + +export function getConversationDirectoryName(storageSessionId: string): string { + if (storageSessionId.length === 0 || storageSessionId.length > 256) { + throw new ConversationDirectoryIdentityError('child', 'invalid_session_id'); + } + return `conversation-${createHash('sha256') + .update(storageSessionId) + .digest('hex')}`; +} + +function validateDirectoryStats( + stats: Stats, + scope: ConversationDirectoryIdentityScope, +): void { + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new ConversationDirectoryIdentityError(scope, 'not_directory'); + } + if ( + process.platform !== 'win32' && + typeof process.getuid === 'function' && + stats.uid !== process.getuid() + ) { + throw new ConversationDirectoryIdentityError(scope, 'wrong_owner'); + } + if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) { + throw new ConversationDirectoryIdentityError(scope, 'wrong_mode'); + } +} + +function hasRootIdentity( + stats: Stats, + root: ConversationRootIdentity, +): boolean { + return stats.dev === root.device && stats.ino === root.inode; +} + +function hasExpectedDirectoryIdentity( + identity: ConversationDirectoryIdentity, + expected: ConversationDirectoryIdentity, +): boolean { + return ( + identity.storageSessionId === expected.storageSessionId && + identity.name === expected.name && + isSameConversationPath(identity.canonicalPath, expected.canonicalPath) && + identity.device === expected.device && + identity.inode === expected.inode && + isSameConversationPath( + identity.root.configuredRoot, + expected.root.configuredRoot, + ) && + isSameConversationPath( + identity.root.canonicalRoot, + expected.root.canonicalRoot, + ) && + identity.root.device === expected.root.device && + identity.root.inode === expected.root.inode + ); +} + +export async function createConversationRootIdentity( + configuredRoot: string, +): Promise { + try { + await mkdir(configuredRoot, { recursive: true, mode: 0o700 }); + } catch (error) { + let existing: Stats; + try { + existing = await lstat(configuredRoot); + } catch { + throwIdentityIoError('root', error); + } + validateDirectoryStats(existing, 'root'); + throwIdentityIoError('root', error); + } + + let before: Stats; + try { + before = await lstat(configuredRoot); + } catch (error) { + throwIdentityIoError('root', error); + } + validateDirectoryStats(before, 'root'); + + let canonicalRoot: string; + let after: Stats; + try { + canonicalRoot = await realpath(configuredRoot); + after = await lstat(canonicalRoot); + } catch (error) { + throwIdentityIoError('root', error); + } + validateDirectoryStats(after, 'root'); + if (before.dev !== after.dev || before.ino !== after.ino) { + throw new ConversationDirectoryIdentityError('root', 'identity_changed'); + } + return { + configuredRoot, + canonicalRoot, + device: after.dev, + inode: after.ino, + }; +} + +export async function revalidateConversationRootIdentity( + root: ConversationRootIdentity, +): Promise { + let configuredStats: Stats; + try { + configuredStats = await lstat(root.configuredRoot); + } catch (error) { + throwIdentityIoError('root', error); + } + validateDirectoryStats(configuredStats, 'root'); + if (!hasRootIdentity(configuredStats, root)) { + throw new ConversationDirectoryIdentityError('root', 'identity_changed'); + } + + let canonical: string; + try { + canonical = await realpath(root.configuredRoot); + } catch (error) { + throwIdentityIoError('root', error); + } + if (!isSameConversationPath(canonical, root.canonicalRoot)) { + throw new ConversationDirectoryIdentityError( + 'root', + 'canonical_path_changed', + ); + } + + let canonicalStats: Stats; + try { + canonicalStats = await lstat(root.canonicalRoot); + } catch (error) { + throwIdentityIoError('root', error); + } + validateDirectoryStats(canonicalStats, 'root'); + if (!hasRootIdentity(canonicalStats, root)) { + throw new ConversationDirectoryIdentityError('root', 'identity_changed'); + } + return root; +} + +export async function assertExactConversationRootIdentity( + root: ConversationRootIdentity, + candidate: string, +): Promise { + await revalidateConversationRootIdentity(root); + const resolvedCandidate = resolve(candidate); + if ( + !isSameConversationPath(resolvedCandidate, root.configuredRoot) && + !isSameConversationPath(resolvedCandidate, root.canonicalRoot) + ) { + throw new ConversationDirectoryIdentityError('root', 'unexpected_identity'); + } + + let stats: Stats; + let canonical: string; + try { + stats = await lstat(resolvedCandidate); + validateDirectoryStats(stats, 'root'); + canonical = await realpath(resolvedCandidate); + } catch (error) { + if (error instanceof ConversationDirectoryIdentityError) throw error; + throwIdentityIoError('root', error); + } + if ( + !isSameConversationPath(canonical, root.canonicalRoot) || + !hasRootIdentity(stats, root) + ) { + throw new ConversationDirectoryIdentityError('root', 'unexpected_identity'); + } + return root; +} + +export async function inspectConversationDirectoryIdentity( + root: ConversationRootIdentity, + storageSessionId: string, + expected?: ConversationDirectoryIdentity, +): Promise { + await revalidateConversationRootIdentity(root); + const name = getConversationDirectoryName(storageSessionId); + const candidate = join(root.canonicalRoot, name); + let before: Stats; + try { + before = await lstat(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throwIdentityIoError('child', error); + } + validateDirectoryStats(before, 'child'); + + let canonical: string; + let after: Stats; + try { + canonical = await realpath(candidate); + after = await lstat(canonical); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + throwIdentityIoError('child', error); + } + validateDirectoryStats(after, 'child'); + const child = relative(root.canonicalRoot, canonical); + if ( + child !== name || + child.includes(sep) || + child.startsWith('..') || + isAbsolute(child) + ) { + throw new ConversationDirectoryIdentityError('child', 'not_direct_child'); + } + if (before.dev !== after.dev || before.ino !== after.ino) { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + await revalidateConversationRootIdentity(root); + const identity: ConversationDirectoryIdentity = { + root, + storageSessionId, + name, + canonicalPath: canonical, + device: after.dev, + inode: after.ino, + }; + if (expected && !hasExpectedDirectoryIdentity(identity, expected)) { + throw new ConversationDirectoryIdentityError( + 'child', + 'unexpected_identity', + ); + } + return identity; +} + +export async function materializeConversationDirectoryIdentity( + root: ConversationRootIdentity, + storageSessionId: string, +): Promise<{ identity: ConversationDirectoryIdentity; created: boolean }> { + await revalidateConversationRootIdentity(root); + const name = getConversationDirectoryName(storageSessionId); + const candidate = join(root.canonicalRoot, name); + let created = false; + try { + await mkdir(candidate, { mode: 0o700 }); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throwIdentityIoError('child', error); + } + } + const identity = await inspectConversationDirectoryIdentity( + root, + storageSessionId, + ); + if (!identity) { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + return { identity, created }; +} diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 0dd6a978434..0efb8a9a6eb 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -20,6 +20,7 @@ import { import { getProjectHash } from '../utils/paths.js'; import { readRuntimeStatus } from '../utils/runtimeStatus.js'; import { + SessionIdCaseConflictError, SessionService, buildApiHistoryFromConversation, getResumePromptTokenCount, @@ -2498,7 +2499,9 @@ describe('SessionService', () => { describe('findSessionIdIgnoringCase', () => { it('finds a legacy mixed-case transcript', async () => { const legacySessionId = sessionIdA.toUpperCase(); - readdirSyncSpy.mockReturnValue([`${legacySessionId}.jsonl`] as never); + readdirSyncSpy + .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never) + .mockReturnValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( async (sessionId) => sessionId === legacySessionId ? 'active' : undefined, @@ -2508,6 +2511,61 @@ describe('SessionService', () => { sessionService.findSessionIdIgnoringCase(sessionIdA), ).resolves.toBe(legacySessionId); }); + + it('returns the single authoritative spelling after scanning both states', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([] as never) + .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + 'archived', + ); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBe(legacySessionId); + }); + + it('rejects case-only duplicate spellings instead of choosing by enumeration order', async () => { + readdirSyncSpy + .mockReturnValueOnce([ + `${sessionIdA}.jsonl`, + `${sessionIdA.toUpperCase()}.jsonl`, + ] as never) + .mockReturnValueOnce([] as never); + const getLocation = vi.spyOn(sessionService, 'getSessionLocation'); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toBeInstanceOf(SessionIdCaseConflictError); + expect(getLocation).not.toHaveBeenCalled(); + }); + + it('rejects one spelling that exists in both active and archive state', async () => { + readdirSyncSpy.mockReturnValue([`${sessionIdA}.jsonl`] as never); + const getLocation = vi.spyOn(sessionService, 'getSessionLocation'); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + }); + expect(getLocation).not.toHaveBeenCalled(); + }); + + it('returns undefined when the matching transcript disappears during resolution', async () => { + readdirSyncSpy + .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) + .mockReturnValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + undefined, + ); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBeUndefined(); + }); }); describe('loadLastSession', () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 4e778e43864..8b11486533e 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -143,6 +143,14 @@ export type SessionArchiveState = 'active' | 'archived'; export type SessionLocation = SessionArchiveState | 'conflict' | undefined; +export class SessionIdCaseConflictError extends Error { + override readonly name = 'SessionIdCaseConflictError'; + + constructor(readonly sessionId: string) { + super(`Multiple persisted sessions match "${sessionId}" by case.`); + } +} + /** * Pagination options for listing sessions. */ @@ -688,6 +696,7 @@ export class SessionService { sessionId: string, ): Promise { const expectedFileName = `${sessionId}.jsonl`.toLowerCase(); + const candidates = new Map>(); for (const state of ['active', 'archived'] as const) { let fileNames: string[]; try { @@ -699,11 +708,25 @@ export class SessionService { for (const fileName of fileNames) { if (fileName.toLowerCase() !== expectedFileName) continue; const candidateSessionId = fileName.slice(0, -'.jsonl'.length); - const location = await this.getSessionLocation(candidateSessionId); - if (location !== undefined) return candidateSessionId; + const states = candidates.get(candidateSessionId) ?? new Set(); + states.add(state); + candidates.set(candidateSessionId, states); } } - return undefined; + if (candidates.size > 1) { + throw new SessionIdCaseConflictError(sessionId); + } + const candidate = candidates.entries().next().value; + if (candidate === undefined) return undefined; + const [candidateSessionId, states] = candidate; + if (states.size > 1) { + throw new SessionIdCaseConflictError(sessionId); + } + const location = await this.getSessionLocation(candidateSessionId); + if (location === 'conflict') { + throw new SessionIdCaseConflictError(sessionId); + } + return location === undefined ? undefined : candidateSessionId; } private removeFileIfExists(filePath: string): void { From 55e1ebcc674cd098676125b9cbaf40a17d327c2f Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Mon, 17 Aug 2026 14:37:02 +0800 Subject: [PATCH 03/29] fix(serve): block mixed-case standalone restore Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acp-http/dispatch.ts | 26 +++-- .../cli/src/serve/acp-http/transport.test.ts | 109 +++++++++++++++++- packages/cli/src/serve/routes/session.ts | 24 +++- packages/cli/src/serve/server.test.ts | 74 ++++++++++++ 4 files changed, 219 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 44c682ffbfa..fe5476deb83 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -12,6 +12,7 @@ import { GROUP_COLOR_OPTIONS, Storage, SessionService, + SessionIdCaseConflictError, SessionOrganizationError, SESSION_WRITER_RPC_CODES, type SessionGroupColor, @@ -34,6 +35,7 @@ import { PermissionForbiddenError, PermissionPolicyNotImplementedError, SessionArchivingError, + SessionConflictError, } from '../acp-session-bridge.js'; import type { BridgeChannelQuarantinedError, @@ -1831,14 +1833,24 @@ export class AcpDispatcher { runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, }); let storageSessionId = sessionId; - if (this.liveSessionIsolation) { - storageSessionId = - (await sessionService.findSessionIdIgnoringCase( - sessionId, - )) ?? ''; - if (!storageSessionId) { - throw new SessionNotFoundError(sessionId); + let persistedSessionId: string | undefined; + try { + persistedSessionId = + await sessionService.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if ( + error instanceof SessionIdCaseConflictError && + (await sessionService.getSessionLocation(sessionId)) === + 'conflict' + ) { + throw new SessionConflictError(sessionId); } + throw error; + } + if (persistedSessionId) { + storageSessionId = persistedSessionId; + } else if (this.liveSessionIsolation) { + throw new SessionNotFoundError(sessionId); } await assertSessionLoadable( cwd, diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 135937638aa..37d35efcc9b 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4575,10 +4575,17 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { const [frame] = (await got) as Array<{ id: number; - error: { code: number; data?: { errorKind?: string } }; + error: { + code: number; + message: string; + data?: { errorKind?: string }; + }; }>; expect(frame.id).toBe(212); expect(frame.error.code).toBe(-32603); + expect(frame.error.message).toContain( + 'Delete the session with POST /sessions/delete', + ); expect(frame.error.data?.errorKind).toBe('session_conflict'); }); }); @@ -4839,6 +4846,106 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); + it.each(['session/load', 'session/resume'] as const)( + '%s hides mixed-case explicit standalone transcripts from generic restore', + async (method) => { + await withRuntimeDir(async () => { + const sessionId = + method === 'session/load' + ? '550e8400-e29b-41d4-a716-446655440133' + : '550e8400-e29b-41d4-a716-446655440134'; + const storageSessionId = sessionId.toUpperCase(); + await writeStoredSession( + storageSessionId, + 'active', + undefined, + 'standalone', + ); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockResolvedValue(storageSessionId); + const readCreationMetadata = vi + .spyOn(SessionService.prototype, 'readCreationMetadata') + .mockImplementation(async (candidateId) => + candidateId === storageSessionId + ? { sourceType: 'standalone' } + : {}, + ); + const loadCount = bridge.loadRequests.length; + const resumeCount = bridge.resumeRequests.length; + + try { + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 219, + method, + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ + id: 219, + error: { message: expect.stringContaining('No session with id') }, + }); + reader.close(); + + expect(findSessionId).toHaveBeenCalledWith(sessionId); + expect(readCreationMetadata).toHaveBeenCalledWith(storageSessionId); + expect(bridge.loadRequests).toHaveLength(loadCount); + expect(bridge.resumeRequests).toHaveLength(resumeCount); + } finally { + readCreationMetadata.mockRestore(); + findSessionId.mockRestore(); + } + }); + }, + ); + + it.each(['session/load', 'session/resume'] as const)( + '%s rejects ordinary case conflicts before bridge dispatch', + async (method) => { + await withRuntimeDir(async () => { + const sessionId = + method === 'session/load' + ? '550e8400-e29b-41d4-a716-446655440142' + : '550e8400-e29b-41d4-a716-446655440143'; + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue(new SessionIdCaseConflictError(sessionId)); + const loadCount = bridge.loadRequests.length; + const resumeCount = bridge.resumeRequests.length; + + try { + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 220, + method, + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ + id: 220, + error: { + data: expect.objectContaining({ + errorKind: 'session_conflict', + sessionId, + }), + }, + }); + reader.close(); + + expect(bridge.loadRequests).toHaveLength(loadCount); + expect(bridge.resumeRequests).toHaveLength(resumeCount); + } finally { + findSessionId.mockRestore(); + } + }); + }, + ); + it('keeps the bridge key canonical while isolating mixed-case storage', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440135'; const storageSessionId = sessionId.toUpperCase(); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 56f1bb61e49..089a88eee1b 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -13,6 +13,7 @@ import { GROUP_COLOR_OPTIONS, GitWorktreeService, SessionOrganizationError, + SessionIdCaseConflictError, SESSION_TRANSCRIPT_MAX_LIMIT, SESSION_TRANSCRIPT_MAX_EXPANDED_PAGE_BYTES, SESSION_TRANSCRIPT_MAX_PAGE_BYTES, @@ -3001,13 +3002,24 @@ export function registerSessionRoutes( async () => { const sessionService = createWorkspaceRuntimeSessionService(runtime); - if (isInternalWorkspaceRuntime(runtime)) { - restoredStorageSessionId = - (await sessionService.findSessionIdIgnoringCase(sessionId)) ?? - ''; - if (!restoredStorageSessionId) { - throw new SessionNotFoundError(sessionId); + let persistedSessionId: string | undefined; + try { + persistedSessionId = + await sessionService.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if ( + error instanceof SessionIdCaseConflictError && + (await sessionService.getSessionLocation(sessionId)) === + 'conflict' + ) { + throw new SessionConflictError(sessionId); } + throw error; + } + if (persistedSessionId) { + restoredStorageSessionId = persistedSessionId; + } else if (isInternalWorkspaceRuntime(runtime)) { + throw new SessionNotFoundError(sessionId); } const location = await assertSessionLoadable( workspaceCwd, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a585ccd8e49..a7ca755b4c7 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -11991,6 +11991,80 @@ describe('createServeApp', () => { }, ); + it.each(['load', 'resume'] as const)( + 'hides mixed-case explicit standalone transcripts from generic %s', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440140'; + const storageSessionId = sessionId.toUpperCase(); + const bridge = fakeBridge(); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockResolvedValue(storageSessionId); + const readCreationMetadata = vi + .spyOn(SessionService.prototype, 'readCreationMetadata') + .mockImplementation(async (candidateId) => + candidateId === storageSessionId + ? { sourceType: 'standalone' } + : {}, + ); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + const res = await request(app) + .post(`/session/${sessionId}/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.code).toBe('session_not_found'); + expect(findSessionId).toHaveBeenCalledWith(sessionId); + expect(readCreationMetadata).toHaveBeenCalledWith(storageSessionId); + expect(bridge.loadCalls).toEqual([]); + expect(bridge.resumeCalls).toEqual([]); + } finally { + findSessionId.mockRestore(); + readCreationMetadata.mockRestore(); + } + }, + ); + + it.each(['load', 'resume'] as const)( + 'rejects ordinary %s case conflicts before bridge dispatch', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440141'; + const bridge = fakeBridge(); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue(new SessionIdCaseConflictError(sessionId)); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + const res = await request(app) + .post(`/session/${sessionId}/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + code: 'session_conflict', + sessionId, + }); + expect(bridge.loadCalls).toEqual([]); + expect(bridge.resumeCalls).toEqual([]); + } finally { + findSessionId.mockRestore(); + } + }, + ); + it('releases restore ownership after invalid approvalMode', async () => { const bridge = fakeBridge(); const app = createServeApp( From e00ed9f9f6314b335ec18b7f3dbe67040c369da8 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Mon, 17 Aug 2026 15:44:37 +0800 Subject: [PATCH 04/29] fix(serve): fail closed on corrupt session metadata Co-authored-by: Qwen-Coder --- .../conversations/session-source.test.ts | 27 +++++- .../src/serve/conversations/session-source.ts | 11 ++- .../sessionService.corruption.test.ts | 84 +++++++++++++++++++ packages/core/src/services/sessionService.ts | 58 +++++++++++-- packages/core/src/utils/jsonl-utils.test.ts | 35 ++++++++ packages/core/src/utils/jsonl-utils.ts | 75 +++++++++++++---- 6 files changed, 265 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/serve/conversations/session-source.test.ts index 94109f3e285..0c88f2c8df0 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/serve/conversations/session-source.test.ts @@ -38,7 +38,7 @@ function createStore( async getSessionLocation(sessionId) { return records.has(sessionId) ? 'active' : undefined; }, - async readCreationMetadata(sessionId) { + async readCreationMetadataIfReadable(sessionId) { return records.get(sessionId) ?? {}; }, }; @@ -157,7 +157,7 @@ describe('conversation session source classification', () => { async getSessionLocation(sessionId) { return sessionId === LEGACY_ID ? 'conflict' : undefined; }, - async readCreationMetadata() { + async readCreationMetadataIfReadable() { reads++; return {}; }, @@ -179,7 +179,7 @@ describe('conversation session source classification', () => { locationReads++; return locationReads === 1 ? 'active' : undefined; }, - async readCreationMetadata() { + async readCreationMetadataIfReadable() { return {}; }, }; @@ -188,4 +188,25 @@ describe('conversation session source classification', () => { readLoadableConversationSession(LEGACY_ID, disappearingStore), ).resolves.toBeUndefined(); }); + + it('rejects a transcript whose creation metadata is unreadable', async () => { + const states: Array<'active' | 'archived'> = []; + const unreadableStore: ConversationSessionMetadataStore = { + async getSessionLocation() { + return 'active'; + }, + async readCreationMetadataIfReadable(_sessionId, state) { + states.push(state); + return undefined; + }, + }; + + await expect( + readLoadableConversationSession(LEGACY_ID, unreadableStore), + ).resolves.toBeUndefined(); + await expect( + readLoadableLiveConversationMetadata(LEGACY_ID, unreadableStore), + ).resolves.toBeUndefined(); + expect(states).toEqual(['active', 'active']); + }); }); diff --git a/packages/cli/src/serve/conversations/session-source.ts b/packages/cli/src/serve/conversations/session-source.ts index ec05ead58b1..0a1d1e9dad1 100644 --- a/packages/cli/src/serve/conversations/session-source.ts +++ b/packages/cli/src/serve/conversations/session-source.ts @@ -22,7 +22,10 @@ export interface ConversationSessionMetadataStore { getSessionLocation( sessionId: string, ): Promise<'active' | 'archived' | 'conflict' | undefined>; - readCreationMetadata(sessionId: string): Promise; + readCreationMetadataIfReadable( + sessionId: string, + state: 'active' | 'archived', + ): Promise; } export type ConversationSessionKind = 'live' | 'standalone'; @@ -96,7 +99,11 @@ async function readExistingMetadata( ): Promise { const location = await store.getSessionLocation(sessionId); if (location !== 'active' && location !== 'archived') return undefined; - const metadata = await store.readCreationMetadata(sessionId); + const metadata = await store.readCreationMetadataIfReadable( + sessionId, + location, + ); + if (!metadata) return undefined; const confirmedLocation = await store.getSessionLocation(sessionId); return confirmedLocation === location ? metadata : undefined; } diff --git a/packages/core/src/services/sessionService.corruption.test.ts b/packages/core/src/services/sessionService.corruption.test.ts index 45ea362bfda..b2bf3440532 100644 --- a/packages/core/src/services/sessionService.corruption.test.ts +++ b/packages/core/src/services/sessionService.corruption.test.ts @@ -60,6 +60,90 @@ function writeJsonl(name: string, content: string): string { return p; } +function createCreationMetadataHarness() { + const runtimeBaseDir = fs.mkdtempSync(path.join(tmpRoot, 'metadata-')); + const cwd = path.join(runtimeBaseDir, 'workspace'); + fs.mkdirSync(cwd, { recursive: true }); + const service = new SessionService(cwd, { runtimeBaseDir }); + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + type Privates = { + getSessionFilePath: (id: string, state: 'active' | 'archived') => string; + }; + const filePath = (service as unknown as Privates).getSessionFilePath( + sessionId, + 'active', + ); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + + const baseRecord = { + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-08-17T00:00:00.000Z', + cwd, + version: 'test', + }; + const user = { + ...baseRecord, + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }; + + return { service, sessionId, filePath, baseRecord, user }; +} + +describe('SessionService.readCreationMetadataIfReadable', () => { + it('distinguishes clean legacy metadata from an unreadable transcript head', async () => { + const { service, filePath, user } = createCreationMetadataHarness(); + fs.writeFileSync(filePath, `${JSON.stringify(user)}\n`, 'utf8'); + + await expect( + service.readCreationMetadataIfReadable(user.sessionId, 'active'), + ).resolves.toEqual({}); + + fs.writeFileSync( + filePath, + `${JSON.stringify(user)}\n{"type":"system","subtype":"session_source","systemPayload":{"sourceType":"default","sourceId":"realtime_voice:call-1"}\n`, + 'utf8', + ); + + await expect( + service.readCreationMetadataIfReadable(user.sessionId, 'active'), + ).resolves.toBeUndefined(); + await expect(service.readCreationMetadata(user.sessionId)).resolves.toEqual( + {}, + ); + }); + + it('accepts fully recovered glued creation records', async () => { + const { service, sessionId, filePath, baseRecord, user } = + createCreationMetadataHarness(); + const source = { + ...baseRecord, + uuid: 'u2', + parentUuid: 'u1', + type: 'system', + subtype: 'session_source', + systemPayload: { + sourceType: 'default', + sourceId: 'realtime_voice:call-1', + }, + }; + fs.writeFileSync( + filePath, + `${JSON.stringify(user)}${JSON.stringify(source)}\n`, + 'utf8', + ); + + await expect( + service.readCreationMetadataIfReadable(sessionId, 'active'), + ).resolves.toEqual({ + sourceType: 'default', + sourceId: 'realtime_voice:call-1', + }); + }); +}); + describe('SessionService.countSessionMessagesFromPath (corruption recovery)', () => { // The method is private; cast is the cheapest way to test the unit // without exposing it on the public surface. The public diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 8b11486533e..178768424ae 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -638,13 +638,59 @@ export class SessionService { sourceType?: string; sourceId?: string; }> { - for (const state of ['active', 'archived'] as const) { + return ( + (await this.readCreationMetadataInternal( + sessionId, + ['active', 'archived'], + false, + )) ?? {} + ); + } + + /** Reads one location, returning undefined unless its head is fully readable. */ + async readCreationMetadataIfReadable( + sessionId: string, + state: SessionArchiveState, + ): Promise< + | { + parentSessionId?: string; + sourceType?: string; + sourceId?: string; + } + | undefined + > { + return this.readCreationMetadataInternal(sessionId, [state], true); + } + + private async readCreationMetadataInternal( + sessionId: string, + states: readonly SessionArchiveState[], + requireCompleteLines: boolean, + ): Promise< + | { + parentSessionId?: string; + sourceType?: string; + sourceId?: string; + } + | undefined + > { + for (const state of states) { const filePath = this.getSessionFilePath(sessionId, state); try { - const records = await jsonl.readLines( - filePath, - MAX_PROMPT_SCAN_LINES, - ); + let records: ChatRecord[]; + if (requireCompleteLines) { + const result = await jsonl.readLinesWithIntegrity( + filePath, + MAX_PROMPT_SCAN_LINES, + ); + if (!result.complete) continue; + records = result.records; + } else { + records = await jsonl.readLines( + filePath, + MAX_PROMPT_SCAN_LINES, + ); + } if (records.length === 0) continue; if ( !(await this.sessionBelongsToCurrentProject( @@ -662,7 +708,7 @@ export class SessionService { ); } } - return {}; + return undefined; } async getSessionLocation(sessionId: string): Promise { diff --git a/packages/core/src/utils/jsonl-utils.test.ts b/packages/core/src/utils/jsonl-utils.test.ts index a4ad929626a..a949c8b72bf 100644 --- a/packages/core/src/utils/jsonl-utils.test.ts +++ b/packages/core/src/utils/jsonl-utils.test.ts @@ -24,6 +24,7 @@ import { parseLineTolerant, read, readLines, + readLinesWithIntegrity, write, writeLine, writeLineSync, @@ -241,6 +242,30 @@ describe('read() / readLines() with malformed lines', () => { ]); }); + it('reports complete recovery for glued object records', async () => { + const file = tmpFile('{"i":1}{"i":2}\n{"i":3}\n'); + + await expect( + readLinesWithIntegrity<{ i: number }>(file, 5), + ).resolves.toEqual({ + records: [{ i: 1 }, { i: 2 }, { i: 3 }], + complete: true, + }); + }); + + it.each([ + ['a truncated record', '{"i":1}{"i":\n{"i":3}\n'], + ['trailing garbage', '{"i":1}garbage\n{"i":3}\n'], + ['an invalid middle fragment', '{"i":1}{"invalid":}{"i":2}\n{"i":3}\n'], + ['a non-object value', '{"i":1}\nnull\n{"i":3}\n'], + ])('reports incomplete recovery for %s', async (_name, content) => { + const file = tmpFile(content); + + await expect( + readLinesWithIntegrity<{ i: number }>(file, 5), + ).resolves.toMatchObject({ complete: false }); + }); + it('skips blank lines', async () => { const file = tmpFile('{"a":1}\n\n{"a":2}\n'); expect(await read<{ a: number }>(file)).toEqual([{ a: 1 }, { a: 2 }]); @@ -325,6 +350,16 @@ describe('reader resource cleanup', () => { expect(result).toEqual([{ i: 1 }]); }); + it('closes the file stream after an integrity-aware read', async () => { + const file = tmpFile('{"i":1}{"i":2}\n{"i":3}\n'); + + const result = await withCapturedReadStream(() => + readLinesWithIntegrity<{ i: number }>(file, 1), + ); + + expect(result).toEqual({ records: [{ i: 1 }], complete: true }); + }); + it('closes the file stream after read consumes all lines', async () => { const file = tmpFile('{"i":1}\n{"i":2}\n'); diff --git a/packages/core/src/utils/jsonl-utils.ts b/packages/core/src/utils/jsonl-utils.ts index 1f2d7e0ced3..a12b457c102 100644 --- a/packages/core/src/utils/jsonl-utils.ts +++ b/packages/core/src/utils/jsonl-utils.ts @@ -39,6 +39,11 @@ type JsonlReadLinesOptions = { signal?: AbortSignal; }; +interface ParsedJsonlLine { + records: T[]; + complete: boolean; +} + /** * A map of file paths to mutexes for preventing concurrent writes. */ @@ -70,12 +75,13 @@ function getFileLock(filePath: string): Mutex { * * Exported for unit tests; not part of the module's stable surface. */ -export function _recoverObjectsFromLine(line: string): T[] { +function recoverObjectsFromLine(line: string): ParsedJsonlLine { const out: T[] = []; let depth = 0; let inString = false; let escape = false; let start = -1; + let complete = true; for (let i = 0; i < line.length; i++) { const c = line[i]; if (escape) { @@ -88,6 +94,7 @@ export function _recoverObjectsFromLine(line: string): T[] { continue; } if (c === '"') { + if (depth === 0) complete = false; inString = true; continue; } @@ -101,18 +108,30 @@ export function _recoverObjectsFromLine(line: string): T[] { try { out.push(JSON.parse(fragment) as T); } catch { + complete = false; // Skip un-parseable fragment; caller may still recover others. } start = -1; } else if (depth < 0) { + complete = false; // Unbalanced close brace — reset and keep scanning for the next // well-formed object rather than giving up on the whole line. depth = 0; start = -1; } + } else if (depth === 0 && !/\s/.test(c)) { + complete = false; } } - return out; + return { + records: out, + complete: + complete && out.length > 0 && depth === 0 && !inString && start === -1, + }; +} + +export function _recoverObjectsFromLine(line: string): T[] { + return recoverObjectsFromLine(line).records; } /** @@ -127,7 +146,10 @@ export function _recoverObjectsFromLine(line: string): T[] { * forwarding scalars or arrays would trip property accesses in callers * (`record.type`, `record.uuid`). */ -export function parseLineTolerant(line: string, filePath: string): T[] { +function parseLineTolerantWithIntegrity( + line: string, + filePath: string, +): ParsedJsonlLine { try { const parsed = JSON.parse(line); if ( @@ -135,23 +157,27 @@ export function parseLineTolerant(line: string, filePath: string): T[] { typeof parsed === 'object' && !Array.isArray(parsed) ) { - return [parsed as T]; + return { records: [parsed as T], complete: true }; } debugLogger.warn(`Skipping non-object JSONL value in ${filePath}`); - return []; + return { records: [], complete: false }; } catch { - const fragments = _recoverObjectsFromLine(line); - if (fragments.length === 0) { + const recovered = recoverObjectsFromLine(line); + if (recovered.records.length === 0) { debugLogger.warn(`Failed to parse line in ${filePath}`); } else { debugLogger.warn( - `Recovered ${fragments.length} record(s) from malformed line in ${filePath}`, + `Recovered ${recovered.records.length} record(s) from malformed line in ${filePath}`, ); } - return fragments; + return recovered; } } +export function parseLineTolerant(line: string, filePath: string): T[] { + return parseLineTolerantWithIntegrity(line, filePath).records; +} + async function closeLineReader( rl: readline.Interface | undefined, fileStream: fs.ReadStream | undefined, @@ -172,11 +198,11 @@ async function closeLineReader( * Reads the first N lines from a JSONL file efficiently. * Returns an array of parsed objects. */ -export async function readLines( +async function readLinesWithIntegrityInternal( filePath: string, count: number, options: JsonlReadLinesOptions = {}, -): Promise { +): Promise<{ records: T[]; complete: boolean }> { let fileStream: fs.ReadStream | undefined; let rl: readline.Interface | undefined; try { @@ -190,18 +216,21 @@ export async function readLines( }); const results: T[] = []; + let complete = true; for await (const line of rl) { if (results.length >= count) break; const trimmed = line.trim(); if (trimmed.length === 0) continue; - for (const obj of parseLineTolerant(trimmed, filePath)) { + const parsed = parseLineTolerantWithIntegrity(trimmed, filePath); + complete &&= parsed.complete; + for (const obj of parsed.records) { if (results.length >= count) break; results.push(obj); } } options.signal?.throwIfAborted(); - return results; + return { records: results, complete }; } catch (error) { options.signal?.throwIfAborted(); if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { @@ -210,13 +239,31 @@ export async function readLines( error, ); } - return []; + return { records: [], complete: false }; } finally { await closeLineReader(rl, fileStream); options.signal?.throwIfAborted(); } } +export async function readLines( + filePath: string, + count: number, + options: JsonlReadLinesOptions = {}, +): Promise { + return (await readLinesWithIntegrityInternal(filePath, count, options)) + .records; +} + +/** Reports whether every scanned non-empty line was fully recoverable. */ +export async function readLinesWithIntegrity( + filePath: string, + count: number, + options: JsonlReadLinesOptions = {}, +): Promise<{ records: T[]; complete: boolean }> { + return readLinesWithIntegrityInternal(filePath, count, options); +} + /** * Reads all lines from a JSONL file. * Returns an array of parsed objects. From 97362119bd897ec066dd503c65584356da7f7408 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Mon, 17 Aug 2026 17:12:28 +0800 Subject: [PATCH 05/29] test(cli): repair PR2A CI doubles and identity replacement cases The ubuntu Test job failed on ten PR2A cases that pass on macOS: - Export SessionIdCaseConflictError from the worktree test's core mock and give its SessionService double findSessionIdIgnoringCase, since loadSession now resolves persisted spelling before reading metadata. - Add readCreationMetadataIfReadable to the Live task fake and mirror it onto the three server lifecycle spies so the fail-closed store interface sees the same fixture metadata as the legacy tolerant readCreationMetadata path it replaced. - Pin the original inode via rename in the two same-path replacement cases. ext4/overlayfs recycle a freed inode immediately, so rm+mkdir at the same path could satisfy the recorded device+inode identity on Linux runners and make a real replacement look valid. --- .../src/acp-integration/acpAgent.worktree.test.ts | 4 ++++ .../conversations/conversation-workspace.test.ts | 5 ++++- .../cli/src/serve/live/live-task-service.test.ts | 8 ++++++++ packages/cli/src/serve/server.test.ts | 12 ++++++++++++ .../utils/conversation-directory-identity.test.ts | 5 ++++- 5 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 27edbebfa6e..89bcf378fd7 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -173,6 +173,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ _args: args, })), SessionService: vi.fn(), + SessionIdCaseConflictError: class SessionIdCaseConflictError extends Error { + override readonly name = 'SessionIdCaseConflictError'; + }, Storage: { getRuntimeBaseDir: vi.fn(() => '/tmp/qwen-runtime-test'), }, @@ -325,6 +328,7 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { function makeInnerConfig() { const mockSessionService = { sessionExists: vi.fn().mockResolvedValue(true), + findSessionIdIgnoringCase: vi.fn().mockResolvedValue(SESSION_ID), getWorktreeSessionPath: vi.fn().mockReturnValue(SIDECAR_PATH), }; vi.mocked(SessionService).mockImplementation( diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index 16009458257..fb7e5f9caef 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -306,7 +306,10 @@ describe('Live conversation workspace root', () => { workspace.inspectStandaloneDirectory('standalone', recreated.identity), ).resolves.toMatchObject({ status: 'ready' }); - await rm(recreated.identity.canonicalPath, { recursive: true }); + // Keep the original inode alive under a sibling name so the replacement + // cannot reuse it (ext4/overlayfs recycle freed inodes immediately). + const preserved = `${recreated.identity.canonicalPath}.preserved`; + await rename(recreated.identity.canonicalPath, preserved); await mkdir(recreated.identity.canonicalPath, { mode: 0o700 }); const compromised = await workspace.inspectStandaloneDirectory( 'standalone', diff --git a/packages/cli/src/serve/live/live-task-service.test.ts b/packages/cli/src/serve/live/live-task-service.test.ts index bb5a3a4fce6..bf7c5670de7 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -76,6 +76,14 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { ); } + async readCreationMetadataIfReadable( + sessionId: string, + _state: 'active' | 'archived', + ) { + if (!(await this.sessionExists(sessionId))) return undefined; + return this.readCreationMetadata(sessionId); + } + removeSession(sessionId: string) { removeSessionRuntimeBaseDirs.push(actual.Storage.getRuntimeBaseDir()); return removeSessionMock(sessionId); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a7ca755b4c7..1aac49e2018 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -30823,6 +30823,9 @@ describe('Live conversation runtime lifecycle', () => { sourceType: 'default', sourceId: `realtime_voice:p1:h1:a1:${sessionId}`, }); + const readMetadataIfReadable = vi + .spyOn(SessionService.prototype, 'readCreationMetadataIfReadable') + .mockImplementation(async (candidateId) => readMetadata(candidateId)); const updateOrganization = vi .spyOn( qwenCore.SessionOrganizationService.prototype, @@ -30915,6 +30918,7 @@ describe('Live conversation runtime lifecycle', () => { getLocation.mockRestore(); sessionExists.mockRestore(); readMetadata.mockRestore(); + readMetadataIfReadable.mockRestore(); updateOrganization.mockRestore(); } }); @@ -31059,6 +31063,9 @@ describe('Live conversation runtime lifecycle', () => { } : {}; }); + const readCreationMetadataIfReadable = vi + .spyOn(SessionService.prototype, 'readCreationMetadataIfReadable') + .mockImplementation(async (sessionId) => readCreationMetadata(sessionId)); const getLocation = vi .spyOn(SessionService.prototype, 'getSessionLocation') .mockResolvedValue('active'); @@ -31160,6 +31167,7 @@ describe('Live conversation runtime lifecycle', () => { findSessionId.mockRestore(); getLocation.mockRestore(); readCreationMetadata.mockRestore(); + readCreationMetadataIfReadable.mockRestore(); await ( setup.app.locals['sealAndWaitLiveCoordinator'] as () => Promise )(); @@ -31185,6 +31193,9 @@ describe('Live conversation runtime lifecycle', () => { sourceType: 'default', sourceId: `realtime_voice:p1:h1:a1:${canonicalSessionId}`, }); + const readCreationMetadataIfReadable = vi + .spyOn(SessionService.prototype, 'readCreationMetadataIfReadable') + .mockImplementation(async (sessionId) => readCreationMetadata(sessionId)); try { const restored = await request(setup.app) .post(`/session/${canonicalSessionId}/load`) @@ -31218,6 +31229,7 @@ describe('Live conversation runtime lifecycle', () => { expect(setup.liveBridge.resumeCalls).toHaveLength(0); } finally { readCreationMetadata.mockRestore(); + readCreationMetadataIfReadable.mockRestore(); getLocation.mockRestore(); findSessionId.mockRestore(); await ( diff --git a/packages/cli/src/utils/conversation-directory-identity.test.ts b/packages/cli/src/utils/conversation-directory-identity.test.ts index 87c7e74c750..5cfc8d06605 100644 --- a/packages/cli/src/utils/conversation-directory-identity.test.ts +++ b/packages/cli/src/utils/conversation-directory-identity.test.ts @@ -97,7 +97,10 @@ describe('conversation directory identity', () => { root, 'replace', ); - await rm(original.identity.canonicalPath, { recursive: true }); + // Keep the original inode alive under a sibling name so the replacement + // cannot reuse it (ext4/overlayfs recycle freed inodes immediately). + const preserved = `${original.identity.canonicalPath}.preserved`; + await rename(original.identity.canonicalPath, preserved); await mkdir(original.identity.canonicalPath, { mode: 0o700 }); await expect( From c3745da726373ba7424ba96f3bde0ad2184f8b88 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Mon, 17 Aug 2026 23:39:11 +0800 Subject: [PATCH 06/29] fix(serve): key restore shared guard on persisted session id spelling The restore handlers resolved the persisted (possibly uppercase) spelling of a session id only inside the shared coordinator guard, while batch delete locks its exclusive guard on the raw caller ids. A restore of the normalized request id therefore raced a concurrent batch delete of the persisted-spelled id on case-sensitive volumes. Resolve the persisted spelling before acquiring the shared guard and key runSharedMany on the resolved id so both sides contend on the same key, in both the REST and ACP restore handlers. Regression tests assert the guard key at both transports. --- packages/cli/src/serve/acp-http/dispatch.ts | 26 ++++++++++- .../cli/src/serve/acp-http/transport.test.ts | 46 +++++++++++++++++++ packages/cli/src/serve/routes/session.ts | 22 ++++++++- packages/cli/src/serve/server.test.ts | 45 ++++++++++++++++++ 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index fe5476deb83..929672836b3 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -1825,14 +1825,36 @@ export class AcpDispatcher { }, ); try { + // Batch delete locks its exclusive guard on the raw caller ids, + // so key this shared guard on the resolved persisted spelling + // too (parity with the REST restore handler) — otherwise a + // delete of the uppercase-spelled file proceeds while restore + // still holds the normalized request id. + const guardSessionService = new SessionService(cwd, { + runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, + }); + let persistedGuardId: string | undefined; + try { + persistedGuardId = + await guardSessionService.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if ( + error instanceof SessionIdCaseConflictError && + (await guardSessionService.getSessionLocation(sessionId)) === + 'conflict' + ) { + throw new SessionConflictError(sessionId); + } + throw error; + } const restored = await this.archiveCoordinator.runSharedMany( - [sessionId], + [persistedGuardId ?? sessionId], async () => { assertGenerationOpen?.(); const sessionService = new SessionService(cwd, { runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, }); - let storageSessionId = sessionId; + let storageSessionId = persistedGuardId ?? sessionId; let persistedSessionId: string | undefined; try { persistedSessionId = diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 37d35efcc9b..f942b3d2113 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4946,6 +4946,52 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); + it.each(['session/load', 'session/resume'] as const)( + '%s keys the shared restore guard on the persisted session id spelling', + async (method) => { + await withRuntimeDir(async () => { + const sessionId = + method === 'session/load' + ? '550e8400-e29b-41d4-a716-446655440145' + : '550e8400-e29b-41d4-a716-446655440146'; + const storageSessionId = sessionId.toUpperCase(); + await writeStoredSession(storageSessionId); + const runSharedMany = vi.spyOn( + SessionArchiveCoordinator.prototype, + 'runSharedMany', + ); + + try { + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 230, + method, + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ + id: 230, + result: expect.any(Object), + }); + reader.close(); + + // Parity with the REST restore handler: the exclusive batch + // delete locks the raw caller ids, so the shared restore guard + // must key on the persisted spelling, not the normalized request + // id. + expect(runSharedMany).toHaveBeenCalledWith( + [storageSessionId], + expect.any(Function), + ); + } finally { + runSharedMany.mockRestore(); + } + }); + }, + ); + it('keeps the bridge key canonical while isolating mixed-case storage', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440135'; const storageSessionId = sessionId.toUpperCase(); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 089a88eee1b..e2fcd19ac47 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -2997,8 +2997,28 @@ export function registerSessionRoutes( } let restoredStorageSessionId = sessionId; try { + // Batch delete locks its exclusive guard on the raw caller ids, so + // key this shared guard on the resolved persisted spelling too — + // otherwise a delete of the uppercase-spelled file proceeds while + // restore still holds the normalized request id. + const guardSessionService = + createWorkspaceRuntimeSessionService(runtime); + let persistedGuardId: string | undefined; + try { + persistedGuardId = + await guardSessionService.findSessionIdIgnoringCase(sessionId); + } catch (error) { + if ( + error instanceof SessionIdCaseConflictError && + (await guardSessionService.getSessionLocation(sessionId)) === + 'conflict' + ) { + throw new SessionConflictError(sessionId); + } + throw error; + } const session = await archiveCoordinator.runSharedMany( - [sessionId], + [persistedGuardId ?? sessionId], async () => { const sessionService = createWorkspaceRuntimeSessionService(runtime); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 1aac49e2018..1f677a1a809 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -197,6 +197,7 @@ import { } from './live/types.js'; import { WorkspaceVoiceCoordinator } from './voice/workspace-voice-coordinator.js'; import { getActiveSseCount } from './routes/sse-events.js'; +import { SessionArchiveCoordinator } from './server/session-archive.js'; // ── Worktree mock infrastructure ──────────────────────────────────── // GitWorktreeService's constructor calls simpleGit() which validates @@ -12032,6 +12033,50 @@ describe('createServeApp', () => { }, ); + it.each(['load', 'resume'] as const)( + 'keys the %s shared guard on the persisted session id spelling', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440144'; + const storageSessionId = sessionId.toUpperCase(); + const bridge = fakeBridge(); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockResolvedValue(storageSessionId); + const readCreationMetadata = vi + .spyOn(SessionService.prototype, 'readCreationMetadata') + .mockResolvedValue({}); + const runSharedMany = vi.spyOn( + SessionArchiveCoordinator.prototype, + 'runSharedMany', + ); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + const res = await request(app) + .post(`/session/${sessionId}/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(200); + // The exclusive batch delete locks the raw caller ids, so the + // restore guard must contend on the persisted spelling (uppercase + // here) rather than the normalized request id. + expect(runSharedMany).toHaveBeenCalledWith( + [storageSessionId], + expect.any(Function), + ); + } finally { + runSharedMany.mockRestore(); + findSessionId.mockRestore(); + readCreationMetadata.mockRestore(); + } + }, + ); + it.each(['load', 'resume'] as const)( 'rejects ordinary %s case conflicts before bridge dispatch', async (action) => { From 9b9feb189a9b3be7d319f5cf03a982cb19055c13 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Tue, 18 Aug 2026 00:26:32 +0800 Subject: [PATCH 07/29] fix(serve): unify persisted-session conflict contract across restore surfaces - SessionIdCaseConflictError now carries an optional candidateSessionId with a shape-aware message, so a same-spelling active+archived conflict names the persisted spelling instead of blaming the request-case id. - REST/ACP-HTTP conversion re-checks the candidate spelling before mapping to SessionConflictError; ACP child surfaces both shapes as INTERNAL_ERROR + errorKind 'session_conflict', and the reserved-source rejection carries errorKind 'reserved_session_source'. - Pin the previously ungated guards from review: lineage validity conjuncts and archived-state reads (session-source), persisted-spelling adoption in ACP load/resume, ensureStandaloneDirectory EEXIST raced re-inspection, and the trailing root revalidation inside inspectConversationDirectoryIdentity (fs-interception seam). --- .../cli/src/acp-integration/acpAgent.test.ts | 71 +++++++++++++- packages/cli/src/acp-integration/acpAgent.ts | 22 +++-- packages/cli/src/serve/acp-http/dispatch.ts | 11 ++- .../cli/src/serve/acp-http/transport.test.ts | 96 ++++++++++++++++++- .../conversation-workspace.test.ts | 45 ++++++++- .../conversations/session-source.test.ts | 35 +++++++ packages/cli/src/serve/routes/session.ts | 10 +- packages/cli/src/serve/server.test.ts | 95 ++++++++++++++++++ .../conversation-directory-identity.test.ts | 39 +++++++- .../core/src/services/sessionService.test.ts | 10 +- packages/core/src/services/sessionService.ts | 18 +++- 11 files changed, 424 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 45f12de33a7..1b8dd684369 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -16117,6 +16117,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { function bindRestoreMocks(opts: { sessionExists: boolean; + persistedSpelling?: string; resolverError?: Error; resumedConversation?: { messages: unknown[] }; replayHistoryImpl?: (...args: unknown[]) => Promise; @@ -16219,7 +16220,9 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { : vi .fn() .mockImplementation(async (sessionId: string) => - opts.sessionExists ? sessionId : undefined, + opts.sessionExists + ? (opts.persistedSpelling ?? sessionId) + : undefined, ), loadSession, readRestoreProjection, @@ -16488,6 +16491,36 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }, ); + it.each(['load', 'resume'] as const)( + '%s adopts the persisted session id spelling before restore', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const storageSessionId = sessionId.toUpperCase(); + bindRestoreMocks({ + sessionExists: true, + persistedSpelling: storageSessionId, + }); + const { agent, agentPromise } = await spawnAgent(); + + try { + const params = { cwd: '/tmp', sessionId, mcpServers: [] }; + if (action === 'load') { + await agent.loadSession(params); + } else { + await agent.unstable_resumeSession(params); + } + + const argv = vi.mocked(loadCliConfig).mock.calls.at(-1)?.[1] as + | CliArgs + | undefined; + expect(argv?.resume).toBe(storageSessionId); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }, + ); + it.each(['load', 'resume'] as const)( '%s rejects case-only persisted session conflicts', async (action) => { @@ -16505,7 +16538,41 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { ? agent.loadSession(request) : agent.unstable_resumeSession(request); await expect(result).rejects.toMatchObject({ - data: { errorKind: 'session_id_conflict', sessionId }, + code: -32603, + message: `Multiple persisted sessions match "${sessionId}" by case.`, + data: { errorKind: 'session_conflict', sessionId }, + }); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }, + ); + + it.each(['load', 'resume'] as const)( + '%s surfaces the both-states conflict message as session_conflict', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + const storageSessionId = sessionId.toUpperCase(); + bindRestoreMocks({ + sessionExists: false, + resolverError: new SessionIdCaseConflictError( + sessionId, + storageSessionId, + ), + }); + const { agent, agentPromise } = await spawnAgent(); + + try { + const request = { cwd: '/tmp', sessionId, mcpServers: [] }; + const result = + action === 'load' + ? agent.loadSession(request) + : agent.unstable_resumeSession(request); + await expect(result).rejects.toMatchObject({ + code: -32603, + message: `Session "${storageSessionId}" is persisted in both active and archived states.`, + data: { errorKind: 'session_conflict', sessionId }, }); } finally { mockConnectionState.resolve(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index b979c51729e..9f070b700f1 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -5284,10 +5284,13 @@ class QwenAgent implements Agent { return await sessionService.findSessionIdIgnoringCase(sessionId); } catch (error) { if (error instanceof SessionIdCaseConflictError) { - throw new RequestError( - ACP_ERROR_CODES.INVALID_PARAMS, - `Multiple persisted sessions match ${sessionId} by case.`, - { errorKind: 'session_id_conflict', sessionId }, + // Parity with the daemon surfaces (toRpcError / REST 409): + // persisted-storage conflicts use `session_conflict`; + // `session_id_conflict` is reserved for live-id admission + // occupancy. + throw RequestError.internalError( + { errorKind: 'session_conflict', sessionId }, + error.message, ); } throw error; @@ -5608,10 +5611,13 @@ class QwenAgent implements Agent { return await sessionService.findSessionIdIgnoringCase(sessionId); } catch (error) { if (error instanceof SessionIdCaseConflictError) { - throw new RequestError( - ACP_ERROR_CODES.INVALID_PARAMS, - `Multiple persisted sessions match ${sessionId} by case.`, - { errorKind: 'session_id_conflict', sessionId }, + // Parity with the daemon surfaces (toRpcError / REST 409): + // persisted-storage conflicts use `session_conflict`; + // `session_id_conflict` is reserved for live-id admission + // occupancy. + throw RequestError.internalError( + { errorKind: 'session_conflict', sessionId }, + error.message, ); } throw error; diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 929672836b3..3eeb3f618a3 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -1658,6 +1658,7 @@ export class AcpDispatcher { id, RPC.INVALID_PARAMS, 'The requested session source is reserved for daemon-owned standalone sessions.', + { errorKind: 'reserved_session_source' }, ), ); return; @@ -1840,8 +1841,9 @@ export class AcpDispatcher { } catch (error) { if ( error instanceof SessionIdCaseConflictError && - (await guardSessionService.getSessionLocation(sessionId)) === - 'conflict' + (await guardSessionService.getSessionLocation( + error.candidateSessionId ?? sessionId, + )) === 'conflict' ) { throw new SessionConflictError(sessionId); } @@ -1862,8 +1864,9 @@ export class AcpDispatcher { } catch (error) { if ( error instanceof SessionIdCaseConflictError && - (await sessionService.getSessionLocation(sessionId)) === - 'conflict' + (await sessionService.getSessionLocation( + error.candidateSessionId ?? sessionId, + )) === 'conflict' ) { throw new SessionConflictError(sessionId); } diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index f942b3d2113..2bbb58e4f55 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -1589,13 +1589,18 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(ack.status).toBe(202); const [frame] = (await got) as Array<{ id: number; - error: { code: number; message: string }; + error: { + code: number; + message: string; + data?: { errorKind?: string }; + }; }>; expect(frame).toMatchObject({ id: 90, error: { code: -32602, message: expect.stringContaining('standalone'), + data: { errorKind: 'reserved_session_source' }, }, }); }); @@ -4992,6 +4997,95 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); + it.each(['session/load', 'session/resume'] as const)( + '%s converts a pre-guard both-states conflict to actionable session conflict', + async (method) => { + await withRuntimeDir(async () => { + const sessionId = + method === 'session/load' + ? '550e8400-e29b-41d4-a716-446655440149' + : '550e8400-e29b-41d4-a716-44665544014a'; + const storageSessionId = sessionId.toUpperCase(); + await writeStoredSession(storageSessionId, 'active'); + await writeStoredSession(storageSessionId, 'archived'); + + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 231, + method, + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ + id: 231, + error: { + message: expect.stringContaining( + 'Delete the session with POST /sessions/delete', + ), + data: expect.objectContaining({ errorKind: 'session_conflict' }), + }, + }); + reader.close(); + }); + }, + ); + + it.each(['session/load', 'session/resume'] as const)( + '%s converts an in-guard both-states conflict to actionable session conflict', + async (method) => { + await withRuntimeDir(async () => { + const sessionId = + method === 'session/load' + ? '550e8400-e29b-41d4-a716-44665544014b' + : '550e8400-e29b-41d4-a716-44665544014c'; + const storageSessionId = sessionId.toUpperCase(); + const conflict = new SessionIdCaseConflictError( + sessionId, + storageSessionId, + ); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockResolvedValueOnce(storageSessionId) + .mockRejectedValue(conflict); + const getSessionLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockImplementation(async (candidateId) => + candidateId === storageSessionId ? 'conflict' : undefined, + ); + + try { + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 232, + method, + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ + id: 232, + error: { + message: expect.stringContaining( + 'Delete the session with POST /sessions/delete', + ), + data: expect.objectContaining({ errorKind: 'session_conflict' }), + }, + }); + reader.close(); + // The conversion must re-check the resolver's candidate spelling: + // the request-case id finds nothing on a case-sensitive filesystem. + expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + } finally { + getSessionLocation.mockRestore(); + findSessionId.mockRestore(); + } + }); + }, + ); + it('keeps the bridge key canonical while isolating mixed-case storage', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440135'; const storageSessionId = sessionId.toUpperCase(); diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index fb7e5f9caef..c4c831205b6 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -17,7 +17,7 @@ import { } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { assertExactConversationRoot, ConversationWorkspace, @@ -267,6 +267,9 @@ describe('Live conversation workspace root', () => { it('sanitizes standalone child filesystem errors', async () => { if (process.platform === 'win32') return; + // Root bypasses the 0o000 chmod below via CAP_DAC_OVERRIDE, so the + // EACCES guard this test provokes never fires (e.g. CI in a container). + if (process.getuid && process.getuid() === 0) return; const home = await tempHome(); const workspace = new ConversationWorkspace({ homeDir: home }); const prepared = await workspace.prepareStandaloneDirectory('standalone'); @@ -324,4 +327,44 @@ describe('Live conversation workspace root', () => { ); expect(compromised.error.reason).toBe('unexpected_identity'); }); + + it('returns the raced inspection when a concurrent creator wins the ensure race', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + + const inspect = vi.spyOn(workspace, 'inspectStandaloneDirectory'); + inspect.mockResolvedValueOnce({ status: 'missing' }); + + const ensured = await workspace.ensureStandaloneDirectory( + 'standalone', + prepared.identity, + ); + expect(ensured).toMatchObject({ + status: 'ready', + identity: prepared.identity, + }); + expect(inspect).toHaveBeenCalledTimes(2); + }); + + it('reports compromised when the raced ensure inspection still finds nothing', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + + vi.spyOn(workspace, 'inspectStandaloneDirectory').mockResolvedValue({ + status: 'missing', + }); + + const ensured = await workspace.ensureStandaloneDirectory( + 'standalone', + prepared.identity, + ); + expect(ensured.status).toBe('compromised'); + if (ensured.status !== 'compromised') { + throw new Error('expected compromised'); + } + expect(ensured.error).toBeInstanceOf(ConversationDirectoryIdentityError); + expect(ensured.error.reason).toBe('identity_changed'); + }); }); diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/serve/conversations/session-source.test.ts index 0c88f2c8df0..8c085be516a 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/serve/conversations/session-source.test.ts @@ -30,6 +30,8 @@ const SELF_ID = '550e8400-e29b-41d4-a716-44665544000b'; const CYCLE_A_ID = '550e8400-e29b-41d4-a716-44665544000c'; const CYCLE_B_ID = '550e8400-e29b-41d4-a716-44665544000d'; const LEGACY_CHILD_OF_EXPLICIT_ID = '550e8400-e29b-41d4-a716-44665544000e'; +const SELF_STANDALONE_ID = '550e8400-e29b-41d4-a716-44665544000f'; +const MALFORMED_PARENT_STANDALONE_ID = '550e8400-e29b-41d4-a716-446655440010'; function createStore( records: ReadonlyMap, @@ -70,6 +72,16 @@ describe('conversation session source classification', () => { [CYCLE_A_ID, { parentSessionId: CYCLE_B_ID }], [CYCLE_B_ID, { parentSessionId: CYCLE_A_ID }], [LEGACY_CHILD_OF_EXPLICIT_ID, { parentSessionId: EXPLICIT_ID }], + [ + SELF_STANDALONE_ID, + { sourceType: 'standalone', parentSessionId: SELF_STANDALONE_ID }, + ], + // Without the `!isValidSessionId` conjunct the explicit-standalone + // shortcut below the parent guard would accept this record. + [ + MALFORMED_PARENT_STANDALONE_ID, + { sourceType: 'standalone', parentSessionId: 'not-a-session-id' }, + ], ]); const store = createStore(records); @@ -126,6 +138,8 @@ describe('conversation session source classification', () => { ATTRIBUTED_CHILD_ID, MALFORMED_LIVE_ID, SELF_ID, + SELF_STANDALONE_ID, + MALFORMED_PARENT_STANDALONE_ID, CYCLE_A_ID, ])('rejects malformed or ambiguous lineage for %s', async (sessionId) => { await expect( @@ -140,6 +154,9 @@ describe('conversation session source classification', () => { await expect( readLoadableLiveConversationMetadata(LEGACY_ID, store), ).resolves.toEqual(records.get(LEGACY_ID)); + await expect( + readLoadableLiveConversationMetadata(LEGACY_CHILD_ID, store), + ).resolves.toEqual(records.get(LEGACY_CHILD_ID)); await expect( readLoadableLiveConversationMetadata(EXPLICIT_ID, store), ).resolves.toBeUndefined(); @@ -209,4 +226,22 @@ describe('conversation session source classification', () => { ).resolves.toBeUndefined(); expect(states).toEqual(['active', 'active']); }); + + it('reads archived transcripts with the archived state', async () => { + const states: Array<'active' | 'archived'> = []; + const archivedStore: ConversationSessionMetadataStore = { + async getSessionLocation(sessionId) { + return records.has(sessionId) ? 'archived' : undefined; + }, + async readCreationMetadataIfReadable(sessionId, state) { + states.push(state); + return records.get(sessionId); + }, + }; + + await expect( + readLoadableConversationSession(LEGACY_ID, archivedStore), + ).resolves.toMatchObject({ kind: 'standalone', persistence: 'legacy' }); + expect(states).toEqual(['archived']); + }); }); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index e2fcd19ac47..38108e29c57 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -3010,8 +3010,9 @@ export function registerSessionRoutes( } catch (error) { if ( error instanceof SessionIdCaseConflictError && - (await guardSessionService.getSessionLocation(sessionId)) === - 'conflict' + (await guardSessionService.getSessionLocation( + error.candidateSessionId ?? sessionId, + )) === 'conflict' ) { throw new SessionConflictError(sessionId); } @@ -3029,8 +3030,9 @@ export function registerSessionRoutes( } catch (error) { if ( error instanceof SessionIdCaseConflictError && - (await sessionService.getSessionLocation(sessionId)) === - 'conflict' + (await sessionService.getSessionLocation( + error.candidateSessionId ?? sessionId, + )) === 'conflict' ) { throw new SessionConflictError(sessionId); } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 1f677a1a809..a805729f242 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -12077,6 +12077,101 @@ describe('createServeApp', () => { }, ); + it.each(['load', 'resume'] as const)( + 'converts a pre-guard both-states %s conflict to actionable session conflict', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440147'; + const storageSessionId = sessionId.toUpperCase(); + const bridge = fakeBridge(); + // Same spelling persisted in both active and archived states: the + // resolver carries the candidate spelling, so the conversion must + // re-check THAT spelling — the request-case id finds nothing on a + // case-sensitive filesystem and would skip SessionConflictError. + const conflict = new SessionIdCaseConflictError( + sessionId, + storageSessionId, + ); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockRejectedValue(conflict); + const getSessionLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockImplementation(async (candidateId) => + candidateId === storageSessionId ? 'conflict' : undefined, + ); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + const res = await request(app) + .post(`/session/${sessionId}/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('session_conflict'); + expect(res.body.error).toContain( + 'Delete the session with POST /sessions/delete', + ); + expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + expect(bridge.loadCalls).toEqual([]); + expect(bridge.resumeCalls).toEqual([]); + } finally { + getSessionLocation.mockRestore(); + findSessionId.mockRestore(); + } + }, + ); + + it.each(['load', 'resume'] as const)( + 'converts an in-guard both-states %s conflict to actionable session conflict', + async (action) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440148'; + const storageSessionId = sessionId.toUpperCase(); + const bridge = fakeBridge(); + const conflict = new SessionIdCaseConflictError( + sessionId, + storageSessionId, + ); + const findSessionId = vi + .spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockResolvedValueOnce(storageSessionId) + .mockRejectedValue(conflict); + const getSessionLocation = vi + .spyOn(SessionService.prototype, 'getSessionLocation') + .mockImplementation(async (candidateId) => + candidateId === storageSessionId ? 'conflict' : undefined, + ); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + const res = await request(app) + .post(`/session/${sessionId}/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('session_conflict'); + expect(res.body.error).toContain( + 'Delete the session with POST /sessions/delete', + ); + expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + expect(bridge.loadCalls).toEqual([]); + expect(bridge.resumeCalls).toEqual([]); + } finally { + getSessionLocation.mockRestore(); + findSessionId.mockRestore(); + } + }, + ); + it.each(['load', 'resume'] as const)( 'rejects ordinary %s case conflicts before bridge dispatch', async (action) => { diff --git a/packages/cli/src/utils/conversation-directory-identity.test.ts b/packages/cli/src/utils/conversation-directory-identity.test.ts index 5cfc8d06605..a49fe212682 100644 --- a/packages/cli/src/utils/conversation-directory-identity.test.ts +++ b/packages/cli/src/utils/conversation-directory-identity.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { realpathSync } from 'node:fs'; +import { realpathSync, type Stats } from 'node:fs'; import { chmod, lstat, @@ -16,7 +16,7 @@ import { } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConversationDirectoryIdentityError, createConversationRootIdentity, @@ -26,6 +26,13 @@ import { revalidateConversationRootIdentity, } from './conversation-directory-identity.js'; +// fs-interception seam: lets a test commit a rename exactly between two fs +// calls inside the module under test. +vi.mock('node:fs/promises', { spy: true }); + +const realFsPromises = + await vi.importActual('node:fs/promises'); + const cleanup: string[] = []; afterEach(async () => { @@ -153,4 +160,32 @@ describe('conversation directory identity', () => { revalidateConversationRootIdentity(root), ).rejects.toMatchObject({ scope: 'root', reason: 'identity_changed' }); }); + + it('rejects a root swap committed during child inspection', async () => { + const { root } = await tempRoot(); + const created = await materializeConversationDirectoryIdentity( + root, + 'swapme', + ); + + // Fire after the child's post-realpath stat, before the trailing root + // revalidation: rename the validated root aside and recreate it empty. + const realLstat = realFsPromises.lstat; + let hits = 0; + vi.mocked(lstat).mockImplementation((async (path: string) => { + const stats = (await realLstat(path)) as Stats; + if (path.endsWith(created.identity.name) && ++hits === 2) { + await rename(root.configuredRoot, `${root.configuredRoot}-old`); + await mkdir(root.configuredRoot, { mode: 0o700 }); + } + return stats; + }) as unknown as typeof lstat); + try { + await expect( + inspectConversationDirectoryIdentity(root, 'swapme'), + ).rejects.toMatchObject({ scope: 'root', reason: 'identity_changed' }); + } finally { + vi.mocked(lstat).mockRestore(); + } + }); }); diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 0efb8a9a6eb..b0209d2f452 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -20,7 +20,6 @@ import { import { getProjectHash } from '../utils/paths.js'; import { readRuntimeStatus } from '../utils/runtimeStatus.js'; import { - SessionIdCaseConflictError, SessionService, buildApiHistoryFromConversation, getResumePromptTokenCount, @@ -2537,7 +2536,12 @@ describe('SessionService', () => { await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), - ).rejects.toBeInstanceOf(SessionIdCaseConflictError); + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + candidateSessionId: undefined, + message: `Multiple persisted sessions match "${sessionIdA}" by case.`, + }); expect(getLocation).not.toHaveBeenCalled(); }); @@ -2550,6 +2554,8 @@ describe('SessionService', () => { ).rejects.toMatchObject({ name: 'SessionIdCaseConflictError', sessionId: sessionIdA, + candidateSessionId: sessionIdA, + message: `Session "${sessionIdA}" is persisted in both active and archived states.`, }); expect(getLocation).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 178768424ae..060660d058d 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -146,8 +146,18 @@ export type SessionLocation = SessionArchiveState | 'conflict' | undefined; export class SessionIdCaseConflictError extends Error { override readonly name = 'SessionIdCaseConflictError'; - constructor(readonly sessionId: string) { - super(`Multiple persisted sessions match "${sessionId}" by case.`); + // `candidateSessionId` is set only when one exact spelling was found + // persisted in both active and archived states, so callers can re-check + // the persisted spelling instead of the request-case id. + constructor( + readonly sessionId: string, + readonly candidateSessionId?: string, + ) { + super( + candidateSessionId === undefined + ? `Multiple persisted sessions match "${sessionId}" by case.` + : `Session "${candidateSessionId}" is persisted in both active and archived states.`, + ); } } @@ -766,11 +776,11 @@ export class SessionService { if (candidate === undefined) return undefined; const [candidateSessionId, states] = candidate; if (states.size > 1) { - throw new SessionIdCaseConflictError(sessionId); + throw new SessionIdCaseConflictError(sessionId, candidateSessionId); } const location = await this.getSessionLocation(candidateSessionId); if (location === 'conflict') { - throw new SessionIdCaseConflictError(sessionId); + throw new SessionIdCaseConflictError(sessionId, candidateSessionId); } return location === undefined ? undefined : candidateSessionId; } From 3b8869d6a83eb4cb1f66d37dbdb45a722d419444 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Tue, 18 Aug 2026 00:27:40 +0800 Subject: [PATCH 08/29] docs: sync standalone PR2 plan and design docs with R1 review - Plan doc: ship the readCreationMetadataIfReadable signature in the store snippet, record jsonl-utils/error-response/readCreationMetadata in the PR2A checklist with the second-core-file re-audit outcome, name the launcher as child-UUID generator in both sections, extend the PR2A vitest and prettier gates to every PR-touched file, and stop claiming junction/Windows coverage the matrix cannot run. - Design doc: reconcile the initial-prompt ordering clause with the strict create schema (prompt admitted by createWithInitialPrompt after the create transaction commits), replace the stale "before the UUID can be released" wording with the terminal-reservation model, and enumerate all seven deny categories in the acceptance matrix. --- docs/design/standalone-daemon-sessions.md | 22 +++++++++++------ docs/plans/2026-08-14-standalone-pr2-core.md | 26 ++++++++++++++------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/docs/design/standalone-daemon-sessions.md b/docs/design/standalone-daemon-sessions.md index 7fb0a2f6979..bfa1c4bd62d 100644 --- a/docs/design/standalone-daemon-sessions.md +++ b/docs/design/standalone-daemon-sessions.md @@ -680,8 +680,14 @@ logical transaction: release. Only a confirmed release promotes the record to reusable `agentBound` and permits prompts or automatic work. 9. Commit process-local creation state and invalidate the catalog cache before - attempting to write the HTTP response; no further fallible durable or - workspace operation occurs after an initial prompt is admitted. + attempting to write the HTTP response. The wire create itself carries no + prompt: the strict `CreateStandaloneSessionRequest` schema admits only + `sessionId`, `modelServiceId?`, and `approvalMode?`. When a launcher + supplies an initial prompt, `createWithInitialPrompt(request, prompt)` + admits it as a separate step only after the transaction above commits — + inside the same still-held exclusive, via an "exclusive already held" + internal dispatch helper — and no further fallible durable or workspace + operation occurs after an initial prompt is admitted. Before source persistence, failure closes any owned ACP session and releases the UUID after closure succeeds. The deterministic empty child is retained and may @@ -691,8 +697,8 @@ atomically bind deletion to the inode validated earlier, so a same-path replacement race would make an “exact identity” cleanup claim false. If ACP-session closure fails before a durable standalone marker exists, the UUID remains reserved as `creating`, the Conversations runtime is quarantined, and -its shared ACP child is torn down to eliminate the unpersisted orphan before the -UUID can be released. Quarantine is +its shared ACP child is torn down to eliminate the unpersisted orphan; the UUID +reservation is held, not released, until daemon shutdown. Quarantine is terminal for the current daemon: the triggering transaction performs no further private-directory or transcript cleanup after quarantine begins, every creation already in flight remains frozen, and exact lookup for those UUIDs returns @@ -1352,9 +1358,11 @@ the daemon contract. - Primary project settings, memory, Git state, trust, and cwd do not leak; shared user and Conversations configuration follows the documented boundary. - Standalone ACP command projection and dispatch use one canonical deny predicate: - workspace/session-reset, Git diff, project-skill management, and cwd-derived - transcript commands are absent and fail before their actions; supported - child-local and user-global commands retain their documented behavior. + workspace directory management, session/workspace-reset, Git diff, + project-skill management, project-scoped language or config import, explicit + model persistence, and cwd-derived transcript commands are absent and fail + before their actions; supported child-local and user-global commands retain + their documented behavior. - Standalone permission prompts, including nested sub-agents, cannot persist a project rule into the Conversations root; user-global permission persistence remains available and does not mutate another session's in-memory rule set. diff --git a/docs/plans/2026-08-14-standalone-pr2-core.md b/docs/plans/2026-08-14-standalone-pr2-core.md index 9096740f7f9..ee396cd3e38 100644 --- a/docs/plans/2026-08-14-standalone-pr2-core.md +++ b/docs/plans/2026-08-14-standalone-pr2-core.md @@ -105,7 +105,10 @@ interface ConversationSessionMetadataStore { getSessionLocation( sessionId: string, ): Promise<'active' | 'archived' | 'conflict' | undefined>; - readCreationMetadata(sessionId: string): Promise; + readCreationMetadataIfReadable( + sessionId: string, + state: 'active' | 'archived', + ): Promise; } ``` @@ -432,7 +435,7 @@ ACP child guard再次检查所有真正开始的turn,覆盖HTTP route之外的 `create-sub-session` launcher增加一个窄 conversation hook,由 server assembly注入: -- caller是explicit或legacy standalone时,调用service的`createChildWithInitialPrompt()`;该方法预生成child UUID、做global reservation、directory pin、spawn/relocation/durable reread/prompt admission与统一rollback。Launcher不得保留第二套standalone spawn/cleanup状态机。Live caller保持现有auto-ID、无child source与materialize流程。 +- caller是explicit或legacy standalone时,调用service的`createChildWithInitialPrompt()`;child UUID由launcher预生成canonical UUID v4并经request传入,service先在任何lock/reservation前拒绝parent/child相同,再做global reservation、directory pin、spawn/relocation/durable reread/prompt admission与统一rollback。Launcher不得保留第二套standalone spawn/cleanup状态机。Live caller保持现有auto-ID、无child source与materialize流程。 - standalone parent的sent-completion/background follow-up也通过 admission;Live路径保持现有逻辑。 - standalone child只有在`sourcePersisted === true`且`parentSessionPersisted === true`时才可dispatch首个prompt;任一false/absent都按fresh child rollback。只验证source不足以证明重启后仍能恢复lineage。失败关闭不确定时复用service quarantine policy。 @@ -448,9 +451,11 @@ ACP child guard再次检查所有真正开始的turn,覆盖HTTP route之外的 - Modify: `packages/cli/src/acp-integration/acpAgent.ts`及load/resume tests,移除exact-lowercase `sessionExists()` fast path;ACP child必须直接调用唯一case-insensitive resolver,才能在exact与case-only twin并存时于Config/filesystem初始化前fail closed。 - Modify: `packages/cli/src/serve/live/live-task-service.ts`及现有caller tests,只把旧source adapter调用改为传入existence-aware SessionService store;不在PR2A迁移Live task的创建或restore语义。 - Modify: `packages/cli/src/serve/session-id-admission.ts`及test,让case-only duplicate resolver结果按persisted UUID conflict处理,而不是被外层catch误映射为临时`session_id_admission_unavailable`;该适配只改变重复持久化ID的fail-closed分类,不改变I/O失败的retryable unavailable语义。 -- Modify: `packages/core/src/services/sessionService.ts`及test,让case-insensitive persisted-ID resolver无论exact lowercase文件是否存在都扫描active/archived候选;单一candidate返回authoritative spelling,仅大小写不同的多个candidate抛typed conflict。 +- Modify: `packages/core/src/services/sessionService.ts`及test,让case-insensitive persisted-ID resolver无论exact lowercase文件是否存在都扫描active/archived候选;单一candidate返回authoritative spelling,仅大小写不同的多个candidate抛typed conflict。同一文件新增`readCreationMetadataIfReadable()`,把creation metadata读取与existence state绑定,corrupt metadata fail closed。 +- Create: `packages/core/src/utils/jsonl-utils.ts`及test,新增`readLinesWithIntegrity()` fail-closed reader,供`readCreationMetadataIfReadable()`区分missing与corrupt transcript;不新增其他core util。 +- Modify: `packages/cli/src/serve/server/error-response.ts`及test,把core `SessionIdCaseConflictError`映射为与`SessionConflictError`相同的无path 409 `session_conflict`形状,作为routes/dispatch翻译之后的defense-in-depth。 -PR2A跨到`packages/core`的生产改动只允许`SessionService`既有case-insensitive resolver的唯一性收紧。不增加core field、setter或新service。若实现需要第二个core文件,先停下重新审计是否应留给PR2B containment或由CLI admission完成。 +PR2A跨到`packages/core`的生产改动只允许`SessionService`既有case-insensitive resolver的唯一性收紧及`readCreationMetadataIfReadable()`,外加`jsonl-utils.ts`的`readLinesWithIntegrity()`。第二个core文件的重审计结论:creation metadata的integrity判定属于core fail-closed边界,CLI routes/dispatch在classify前无法用空读区分missing与corrupt,因此与resolver同属PR2A而不是留给PR2B containment。除此之外不增加core field、setter或新service;PR2A的core生产改动止于这两个文件。 ### PR2B @@ -503,7 +508,7 @@ ACP relocation warning与filesystem error message也不能原样进入standalone - Source矩阵:explicit standalone、legacy none/default、exact Live、empty Live id、standalone with sourceId、other source、top-level/child/grandchild/self/cycle;explicit child在parent active/archived/deleted时仍独立分类,legacy orphan不猜测;新reader标记explicit/legacy,旧adapter允许Live与legacy但拒绝explicit standalone。 - Generic REST与ACP create/restore在任何bridge/admission调用前拒绝explicit standalone;legacy restore仍保持PR2前metadata shape和行为,Live reserved gate回归不变。 - Mixed-case restore:单一legacy storage ID在REST、ACP HTTP和ACP child load/resume中保留storage spelling用于metadata、ACP child持久化与directory hash,同时daemon bridge live key保持canonical;lowercase exact与uppercase twin并存时四个入口都在materialize/bridge前返回conflict;global admission仍视为persisted占用。 -- Root/child:new、valid empty reuse、non-empty conflict、missing recreate、symlink/junction、wrong owner/mode、file、nested、root replacement、child inode replacement、TOCTOU revalidation、Windows case/canonical behavior;standalone失败路径不调用目录删除,保留empty child可由同UUID重试复用,Live现有empty cleanup行为不变。 +- Root/child:new、valid empty reuse、non-empty conflict、missing recreate、symlink、wrong owner/mode、file、nested、root replacement、child inode replacement、TOCTOU revalidation(含child捕获后root swap窗口的fs-interception pin)与并发create EEXIST raced re-inspection;junction与Windows case/canonical行为在PR2A的可运行平台矩阵下无法验证,该项作为已知未覆盖项推迟,不在本PR宣称覆盖(libuv在lstat下把junction报告为symlink,风险主要剩win32 case-fold比较分支);standalone失败路径不调用目录删除,保留empty child可由同UUID重试复用,Live现有empty cleanup行为不变。 ### PR2B service tests @@ -563,10 +568,17 @@ npx vitest run \ src/serve/session-id-admission.test.ts \ src/serve/acp-http/transport.test.ts \ src/serve/multi-workspace-sessions.test.ts \ + src/serve/server/error-response.test.ts \ + src/serve/live/live-task-service.test.ts \ + src/acp-integration/acpAgent.test.ts \ + src/acp-integration/acpAgent.worktree.test.ts \ src/serve/server.test.ts cd ../core -npx vitest run src/services/sessionService.test.ts +npx vitest run \ + src/services/sessionService.test.ts \ + src/services/sessionService.corruption.test.ts \ + src/utils/jsonl-utils.test.ts ``` PR2B: @@ -601,7 +613,7 @@ npx vitest run src/tools/cron-create.test.ts src/config/config.test.ts 每个实施 PR 的最终验证: ```bash -npx prettier --check packages/acp-bridge/src packages/cli/src/serve packages/cli/src/acp-integration packages/cli/src/config/config.ts packages/core/src/config/config.ts packages/core/src/tools/cron-create.ts packages/core/src/services/sessionService.ts docs/design/standalone-daemon-sessions.md docs/plans/2026-08-14-standalone-pr2-core.md +npx prettier --check packages/acp-bridge/src packages/cli/src packages/core/src docs/design/standalone-daemon-sessions.md docs/plans/2026-08-14-standalone-pr2-core.md npm run lint --workspace @qwen-code/acp-bridge npm run lint --workspace @qwen-code/qwen-code npm run lint --workspace @qwen-code/qwen-code-core From 89e7463f26c5ee2ab1af976aa22a513f26051243 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Tue, 18 Aug 2026 00:46:58 +0800 Subject: [PATCH 09/29] fix(cli): normalize session-map lookups in restore failure cleanup guards The sessions map is keyed by normalizeSessionIdForLookup-folded ids, but the three cleanupAfterRequestFailure guards read it with the raw config.getSessionId(). After a restore adopts a non-canonical persisted spelling (uppercase legacy transcript), those reads always miss and the guard would treat a still-stored session as unstored, shutting its config down in the double-cleanup-failure window. Normalize at the read sites; no behavior change for canonical or non-UUID ids. The adoption test now also pins that caller-case follow-up operations (cancel) still reach the adopted session through the normalized key. --- packages/cli/src/acp-integration/acpAgent.test.ts | 8 +++++++- packages/cli/src/acp-integration/acpAgent.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 1b8dd684369..4436690aa25 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -16496,10 +16496,11 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { async (action) => { const sessionId = '550e8400-e29b-41d4-a716-446655440000'; const storageSessionId = sessionId.toUpperCase(); - bindRestoreMocks({ + const innerConfig = bindRestoreMocks({ sessionExists: true, persistedSpelling: storageSessionId, }); + innerConfig.getSessionId.mockReturnValue(storageSessionId); const { agent, agentPromise } = await spawnAgent(); try { @@ -16514,6 +16515,11 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { | CliArgs | undefined; expect(argv?.resume).toBe(storageSessionId); + + // The in-memory session map key is normalized, so caller-case + // follow-up operations still reach the adopted session. + await agent.cancel({ sessionId }); + expect(lastSessionMock?.cancelPendingPrompt).toHaveBeenCalledOnce(); } finally { mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9f070b700f1..e8499feb5af 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -5119,7 +5119,9 @@ class QwenAgent implements Agent { } catch (error) { return this.cleanupAfterRequestFailure(error, async () => { if ( - this.sessions.get(config.getSessionId())?.getConfig() !== config + this.sessions + .get(normalizeSessionIdForLookup(config.getSessionId())) + ?.getConfig() !== config ) { await this.cleanupUnstoredConfig(config); } @@ -5527,7 +5529,9 @@ class QwenAgent implements Agent { error, async () => { if ( - this.sessions.get(config.getSessionId())?.getConfig() !== config + this.sessions + .get(normalizeSessionIdForLookup(config.getSessionId())) + ?.getConfig() !== config ) { await this.cleanupUnstoredConfig(config); } @@ -5693,7 +5697,9 @@ class QwenAgent implements Agent { error, async () => { if ( - this.sessions.get(config.getSessionId())?.getConfig() !== config + this.sessions + .get(normalizeSessionIdForLookup(config.getSessionId())) + ?.getConfig() !== config ) { await this.cleanupUnstoredConfig(config); } From bc86702f9011e6efbef89ac5c50688861fa98002 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Tue, 18 Aug 2026 02:20:07 +0800 Subject: [PATCH 10/29] fix(cli): snapshot standalone dir entries after final identity inspect prepareStandaloneDirectory read the child entries before the trailing identity re-inspection, so a same-uid entry appearing between the two steps would not flip the not_empty verdict. Read entries after the final inspect so the emptiness check runs on the freshest snapshot the identity machinery can guarantee. Addresses yiliang114's review on PR #9341. --- .../conversations/conversation-workspace.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/serve/conversations/conversation-workspace.ts b/packages/cli/src/serve/conversations/conversation-workspace.ts index 730b4605bee..babf6065700 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.ts @@ -208,9 +208,20 @@ export class ConversationWorkspace { root, storageSessionId, ); + const identity = await inspectConversationDirectoryIdentity( + root, + storageSessionId, + prepared.identity, + ); + if (!identity) { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + // Entries are read after the final identity re-inspection so a same-uid + // entry appearing across the inspect cannot slip past the emptiness + // verdict on a stale snapshot. let entries: string[]; try { - entries = await readdir(prepared.identity.canonicalPath); + entries = await readdir(identity.canonicalPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { throw new ConversationDirectoryIdentityError( @@ -220,14 +231,6 @@ export class ConversationWorkspace { } throw new ConversationDirectoryIdentityError('child', 'io_error', error); } - const identity = await inspectConversationDirectoryIdentity( - root, - storageSessionId, - prepared.identity, - ); - if (!identity) { - throw new ConversationDirectoryIdentityError('child', 'identity_changed'); - } if (entries.length > 0) { throw new ConversationDirectoryIdentityError('child', 'not_empty'); } From dcdc1aef06515cbbd2c62f2e7d25d7df0e815681 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Tue, 18 Aug 2026 13:27:08 +0800 Subject: [PATCH 11/29] fix(cli): lock restore guard on both request and persisted session id spellings Restore keyed its shared SessionArchiveCoordinator guard only on the resolved persisted spelling while batch delete/archive/unarchive lock on raw caller ids, so on a case-insensitive filesystem a delete carrying the request-case spelling never collided and could unlink the transcript mid-restore (R2-1). Lock both spellings on the REST and ACP-HTTP restore surfaces. Also from R2 review: - Pin the pre-guard/in-guard conflict-conversion stages in the both-states restore tests (R2-8: call-count + guard-not-entered assertions; mutation witness supplied by the reviewer). - Add the toRpcError SessionIdCaseConflictError producer case to dispatch-error.test.ts and list the suite in the PR2A verification block; extend the PR2B block with the collocated suites its checklist modifies (R2-5). - Correct the plan checklist label for the pre-existing shared jsonl-utils module from Create to Modify (R2-7). --- docs/plans/2026-08-14-standalone-pr2-core.md | 14 +++++++++++- .../src/serve/acp-http/dispatch-error.test.ts | 16 ++++++++++++++ packages/cli/src/serve/acp-http/dispatch.ts | 13 ++++++----- .../cli/src/serve/acp-http/transport.test.ts | 10 ++++----- packages/cli/src/serve/routes/session.ts | 11 +++++----- packages/cli/src/serve/server.test.ts | 22 ++++++++++++++----- 6 files changed, 64 insertions(+), 22 deletions(-) diff --git a/docs/plans/2026-08-14-standalone-pr2-core.md b/docs/plans/2026-08-14-standalone-pr2-core.md index ee396cd3e38..04011b36a91 100644 --- a/docs/plans/2026-08-14-standalone-pr2-core.md +++ b/docs/plans/2026-08-14-standalone-pr2-core.md @@ -452,7 +452,7 @@ ACP child guard再次检查所有真正开始的turn,覆盖HTTP route之外的 - Modify: `packages/cli/src/serve/live/live-task-service.ts`及现有caller tests,只把旧source adapter调用改为传入existence-aware SessionService store;不在PR2A迁移Live task的创建或restore语义。 - Modify: `packages/cli/src/serve/session-id-admission.ts`及test,让case-only duplicate resolver结果按persisted UUID conflict处理,而不是被外层catch误映射为临时`session_id_admission_unavailable`;该适配只改变重复持久化ID的fail-closed分类,不改变I/O失败的retryable unavailable语义。 - Modify: `packages/core/src/services/sessionService.ts`及test,让case-insensitive persisted-ID resolver无论exact lowercase文件是否存在都扫描active/archived候选;单一candidate返回authoritative spelling,仅大小写不同的多个candidate抛typed conflict。同一文件新增`readCreationMetadataIfReadable()`,把creation metadata读取与existence state绑定,corrupt metadata fail closed。 -- Create: `packages/core/src/utils/jsonl-utils.ts`及test,新增`readLinesWithIntegrity()` fail-closed reader,供`readCreationMetadataIfReadable()`区分missing与corrupt transcript;不新增其他core util。 +- Modify: `packages/core/src/utils/jsonl-utils.ts`及test,新增`readLinesWithIntegrity()` fail-closed reader,供`readCreationMetadataIfReadable()`区分missing与corrupt transcript;不新增其他core util。 - Modify: `packages/cli/src/serve/server/error-response.ts`及test,把core `SessionIdCaseConflictError`映射为与`SessionConflictError`相同的无path 409 `session_conflict`形状,作为routes/dispatch翻译之后的defense-in-depth。 PR2A跨到`packages/core`的生产改动只允许`SessionService`既有case-insensitive resolver的唯一性收紧及`readCreationMetadataIfReadable()`,外加`jsonl-utils.ts`的`readLinesWithIntegrity()`。第二个core文件的重审计结论:creation metadata的integrity判定属于core fail-closed边界,CLI routes/dispatch在classify前无法用空读区分missing与corrupt,因此与resolver同属PR2A而不是留给PR2B containment。除此之外不增加core field、setter或新service;PR2A的core生产改动止于这两个文件。 @@ -567,6 +567,7 @@ npx vitest run \ src/utils/conversation-directory-identity.test.ts \ src/serve/session-id-admission.test.ts \ src/serve/acp-http/transport.test.ts \ + src/serve/acp-http/dispatch-error.test.ts \ src/serve/multi-workspace-sessions.test.ts \ src/serve/server/error-response.test.ts \ src/serve/live/live-task-service.test.ts \ @@ -593,17 +594,28 @@ npx vitest run \ src/serve/conversations/conversation-runtime-manager.test.ts \ src/serve/conversations/session-source.test.ts \ src/serve/conversations/conversation-workspace.test.ts \ + src/serve/conversations/conversation-runtime-activity.test.ts \ src/serve/server/error-response.test.ts \ src/config/config.test.ts \ src/acp-integration/acpAgent.test.ts \ + src/acp-integration/acpAgent.worktree.test.ts \ src/acp-integration/session/Session.test.ts \ + src/acp-integration/session/Session.worktree.test.ts \ src/acp-integration/session/SubAgentTracker.test.ts \ src/acp-integration/session/permissionUtils.test.ts \ src/serve/server/session-archive.test.ts \ + src/serve/acp-http/transport.test.ts \ src/serve/routes/workspace-management.test.ts \ src/serve/live/live-task-service.test.ts \ src/serve/create-sub-session.test.ts \ src/serve/multi-workspace-sessions.test.ts \ + src/nonInteractiveCliCommands.test.ts \ + src/ui/commands/clearCommand.test.ts \ + src/ui/commands/directoryCommand.test.tsx \ + src/ui/commands/languageCommand.test.ts \ + src/ui/commands/importConfigCommand.test.ts \ + src/ui/commands/modelCommand.test.ts \ + src/ui/commands/effort-command.test.ts \ src/serve/server.test.ts cd ../core diff --git a/packages/cli/src/serve/acp-http/dispatch-error.test.ts b/packages/cli/src/serve/acp-http/dispatch-error.test.ts index 991e04e6e9c..adc43c5d109 100644 --- a/packages/cli/src/serve/acp-http/dispatch-error.test.ts +++ b/packages/cli/src/serve/acp-http/dispatch-error.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it } from 'vitest'; +import { SessionIdCaseConflictError } from '@qwen-code/qwen-code-core'; import { DaemonDrainingError } from '../server/session-archive.js'; import { BridgeChannelQuarantinedError, @@ -103,4 +104,19 @@ describe('toRpcError', () => { }, }); }); + + it('maps persisted case conflicts to the session_conflict contract', () => { + const error = new SessionIdCaseConflictError( + '550e8400-e29b-41d4-a716-446655440149', + '550E8400-E29B-41D4-A716-446655440149', + ); + expect(toRpcError(error)).toEqual({ + code: RPC.INTERNAL_ERROR, + message: error.message, + data: { + errorKind: 'session_conflict', + sessionId: '550e8400-e29b-41d4-a716-446655440149', + }, + }); + }); }); diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 3eeb3f618a3..ccaa5a88376 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -1826,11 +1826,12 @@ export class AcpDispatcher { }, ); try { - // Batch delete locks its exclusive guard on the raw caller ids, - // so key this shared guard on the resolved persisted spelling - // too (parity with the REST restore handler) — otherwise a - // delete of the uppercase-spelled file proceeds while restore - // still holds the normalized request id. + // Batch delete locks its exclusive guard on the raw caller + // ids, whose spelling may be either the request id or the + // persisted one — so this shared guard locks both (parity + // with the REST restore handler), otherwise a delete under + // the other spelling never collides and can unlink the + // transcript mid-restore on a case-insensitive filesystem. const guardSessionService = new SessionService(cwd, { runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, }); @@ -1850,7 +1851,7 @@ export class AcpDispatcher { throw error; } const restored = await this.archiveCoordinator.runSharedMany( - [persistedGuardId ?? sessionId], + [...new Set([sessionId, persistedGuardId ?? sessionId])], async () => { assertGenerationOpen?.(); const sessionService = new SessionService(cwd, { diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 2bbb58e4f55..3e456c3cdfe 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4952,7 +4952,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); it.each(['session/load', 'session/resume'] as const)( - '%s keys the shared restore guard on the persisted session id spelling', + '%s keys the shared restore guard on both the request and persisted session id spellings', async (method) => { await withRuntimeDir(async () => { const sessionId = @@ -4983,11 +4983,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { reader.close(); // Parity with the REST restore handler: the exclusive batch - // delete locks the raw caller ids, so the shared restore guard - // must key on the persisted spelling, not the normalized request - // id. + // delete locks the raw caller ids, whose spelling may be either + // the request id or the persisted one — the shared restore + // guard must contend on both. expect(runSharedMany).toHaveBeenCalledWith( - [storageSessionId], + [sessionId, storageSessionId], expect.any(Function), ); } finally { diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 38108e29c57..dd12fca12e1 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -2997,10 +2997,11 @@ export function registerSessionRoutes( } let restoredStorageSessionId = sessionId; try { - // Batch delete locks its exclusive guard on the raw caller ids, so - // key this shared guard on the resolved persisted spelling too — - // otherwise a delete of the uppercase-spelled file proceeds while - // restore still holds the normalized request id. + // Batch delete locks its exclusive guard on the raw caller ids, + // whose spelling may be either the request id or the persisted + // one — so this shared guard locks both, otherwise a delete under + // the other spelling never collides and can unlink the transcript + // mid-restore on a case-insensitive filesystem. const guardSessionService = createWorkspaceRuntimeSessionService(runtime); let persistedGuardId: string | undefined; @@ -3019,7 +3020,7 @@ export function registerSessionRoutes( throw error; } const session = await archiveCoordinator.runSharedMany( - [persistedGuardId ?? sessionId], + [...new Set([sessionId, persistedGuardId ?? sessionId])], async () => { const sessionService = createWorkspaceRuntimeSessionService(runtime); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a805729f242..b42780e7450 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -12034,7 +12034,7 @@ describe('createServeApp', () => { ); it.each(['load', 'resume'] as const)( - 'keys the %s shared guard on the persisted session id spelling', + 'keys the %s shared guard on both the request and persisted session id spellings', async (action) => { const sessionId = '550e8400-e29b-41d4-a716-446655440144'; const storageSessionId = sessionId.toUpperCase(); @@ -12062,11 +12062,11 @@ describe('createServeApp', () => { .send({}); expect(res.status).toBe(200); - // The exclusive batch delete locks the raw caller ids, so the - // restore guard must contend on the persisted spelling (uppercase - // here) rather than the normalized request id. + // The exclusive batch delete locks the raw caller ids, whose + // spelling may be either the request id or the persisted one — + // the restore guard must contend on both. expect(runSharedMany).toHaveBeenCalledWith( - [storageSessionId], + [sessionId, storageSessionId], expect.any(Function), ); } finally { @@ -12099,6 +12099,10 @@ describe('createServeApp', () => { .mockImplementation(async (candidateId) => candidateId === storageSessionId ? 'conflict' : undefined, ); + const runSharedMany = vi.spyOn( + SessionArchiveCoordinator.prototype, + 'runSharedMany', + ); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, @@ -12117,9 +12121,13 @@ describe('createServeApp', () => { 'Delete the session with POST /sessions/delete', ); expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + // The pre-guard conversion must fire before the guard is entered, + // so the shared guard never runs. + expect(runSharedMany).not.toHaveBeenCalled(); expect(bridge.loadCalls).toEqual([]); expect(bridge.resumeCalls).toEqual([]); } finally { + runSharedMany.mockRestore(); getSessionLocation.mockRestore(); findSessionId.mockRestore(); } @@ -12163,6 +12171,10 @@ describe('createServeApp', () => { 'Delete the session with POST /sessions/delete', ); expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); + // The in-guard conversion requires the TOCTOU re-resolve inside + // the guard: pre-guard resolution succeeds, the in-guard one + // rejects — dropping the re-resolve would leave this green. + expect(findSessionId).toHaveBeenCalledTimes(2); expect(bridge.loadCalls).toEqual([]); expect(bridge.resumeCalls).toEqual([]); } finally { From d8d9e592094c151c5ea72a11f7a75b701d8b03b7 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Tue, 18 Aug 2026 18:21:51 +0800 Subject: [PATCH 12/29] fix(cli): canonicalize session-archive coordinator lock keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-spelling restore guard from the previous commit closed only the enumerated spellings: any third case variant of a caller id took an exclusive key that collided with no held guard, and on a case-insensitive filesystem could unlink the transcript mid-restore — including the common case where request and persisted spellings coincide and the lock set collapses to one key (R3 review, probe- verified on a case-insensitive mount). Canonicalize lock keys with normalizeSessionIdForLookup at the coordinator boundary (runSharedMany / runExclusiveMany / assertNotTransitioning) so every case variant of a session id contends on one key, and revert the restore-side spelling enumeration it makes redundant. Add a coordinator-level regression test for the case-fold collision, and fix a misleading comment above the in-guard call-count assertion (R3-1). --- packages/cli/src/serve/acp-http/dispatch.ts | 12 ++++------ .../cli/src/serve/acp-http/transport.test.ts | 12 +++++----- packages/cli/src/serve/routes/session.ts | 14 ++++------- packages/cli/src/serve/server.test.ts | 17 ++++++------- .../src/serve/server/session-archive.test.ts | 24 +++++++++++++++++++ .../cli/src/serve/server/session-archive.ts | 16 ++++++++++--- 6 files changed, 62 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index ccaa5a88376..1ad45a56ae4 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -1826,12 +1826,10 @@ export class AcpDispatcher { }, ); try { - // Batch delete locks its exclusive guard on the raw caller - // ids, whose spelling may be either the request id or the - // persisted one — so this shared guard locks both (parity - // with the REST restore handler), otherwise a delete under - // the other spelling never collides and can unlink the - // transcript mid-restore on a case-insensitive filesystem. + // The coordinator canonicalizes lock keys (every case variant + // of a caller id contends on one key), so the request spelling + // alone covers the raw-spelled batch delete/archive/unarchive + // locks (parity with the REST restore handler). const guardSessionService = new SessionService(cwd, { runtimeBaseDir: sessionRuntime.sessionRuntimeBaseDir, }); @@ -1851,7 +1849,7 @@ export class AcpDispatcher { throw error; } const restored = await this.archiveCoordinator.runSharedMany( - [...new Set([sessionId, persistedGuardId ?? sessionId])], + [sessionId], async () => { assertGenerationOpen?.(); const sessionService = new SessionService(cwd, { diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 3e456c3cdfe..84f751fa3ba 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4952,7 +4952,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); it.each(['session/load', 'session/resume'] as const)( - '%s keys the shared restore guard on both the request and persisted session id spellings', + '%s takes the shared restore guard on the request session id', async (method) => { await withRuntimeDir(async () => { const sessionId = @@ -4982,12 +4982,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); reader.close(); - // Parity with the REST restore handler: the exclusive batch - // delete locks the raw caller ids, whose spelling may be either - // the request id or the persisted one — the shared restore - // guard must contend on both. + // Parity with the REST restore handler: the coordinator + // canonicalizes lock keys, so holding the request spelling + // alone contends with the raw-spelled exclusive batch locks + // (pinned in session-archive.test.ts). expect(runSharedMany).toHaveBeenCalledWith( - [sessionId, storageSessionId], + [sessionId], expect.any(Function), ); } finally { diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index dd12fca12e1..0a4e0dd2e7e 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -2997,17 +2997,13 @@ export function registerSessionRoutes( } let restoredStorageSessionId = sessionId; try { - // Batch delete locks its exclusive guard on the raw caller ids, - // whose spelling may be either the request id or the persisted - // one — so this shared guard locks both, otherwise a delete under - // the other spelling never collides and can unlink the transcript - // mid-restore on a case-insensitive filesystem. + // The coordinator canonicalizes lock keys (every case variant of a + // caller id contends on one key), so the request spelling alone + // covers the raw-spelled batch delete/archive/unarchive locks. const guardSessionService = createWorkspaceRuntimeSessionService(runtime); - let persistedGuardId: string | undefined; try { - persistedGuardId = - await guardSessionService.findSessionIdIgnoringCase(sessionId); + await guardSessionService.findSessionIdIgnoringCase(sessionId); } catch (error) { if ( error instanceof SessionIdCaseConflictError && @@ -3020,7 +3016,7 @@ export function registerSessionRoutes( throw error; } const session = await archiveCoordinator.runSharedMany( - [...new Set([sessionId, persistedGuardId ?? sessionId])], + [sessionId], async () => { const sessionService = createWorkspaceRuntimeSessionService(runtime); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index b42780e7450..3288843f69d 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -12034,7 +12034,7 @@ describe('createServeApp', () => { ); it.each(['load', 'resume'] as const)( - 'keys the %s shared guard on both the request and persisted session id spellings', + 'takes the %s shared restore guard on the request session id', async (action) => { const sessionId = '550e8400-e29b-41d4-a716-446655440144'; const storageSessionId = sessionId.toUpperCase(); @@ -12062,11 +12062,11 @@ describe('createServeApp', () => { .send({}); expect(res.status).toBe(200); - // The exclusive batch delete locks the raw caller ids, whose - // spelling may be either the request id or the persisted one — - // the restore guard must contend on both. + // The coordinator canonicalizes lock keys, so holding the + // request spelling alone contends with the raw-spelled + // exclusive batch locks (pinned in session-archive.test.ts). expect(runSharedMany).toHaveBeenCalledWith( - [sessionId, storageSessionId], + [sessionId], expect.any(Function), ); } finally { @@ -12171,9 +12171,10 @@ describe('createServeApp', () => { 'Delete the session with POST /sessions/delete', ); expect(getSessionLocation).toHaveBeenCalledWith(storageSessionId); - // The in-guard conversion requires the TOCTOU re-resolve inside - // the guard: pre-guard resolution succeeds, the in-guard one - // rejects — dropping the re-resolve would leave this green. + // Without the call-count assertion below, replacing the in-guard + // re-resolve with the pre-guard result still yields the same 409 + // via assertSessionLoadable's mocked 'conflict' location — the + // count is what pins the second resolution. expect(findSessionId).toHaveBeenCalledTimes(2); expect(bridge.loadCalls).toEqual([]); expect(bridge.resumeCalls).toEqual([]); diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index 5b489ca8f21..875e97a510e 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -145,6 +145,30 @@ describe('SessionArchiveCoordinator', () => { }); }); + it('collapses case-variant spellings of a caller session id to one lock key', async () => { + const coordinator = new SessionArchiveCoordinator(); + const sessionId = '550e8400-e29b-41d4-a716-446655440024'; + const upper = sessionId.toUpperCase(); + + // Batch delete/archive/unarchive lock raw caller spellings while + // restore locks the request spelling; on a case-insensitive filesystem + // both reach the same transcript, so the two spellings must contend. + await coordinator.runSharedMany([sessionId], async () => { + await expect( + coordinator.runExclusiveMany([upper], async () => 'exclusive'), + ).rejects.toThrow(SessionArchivingError); + }); + + await coordinator.runExclusiveMany([sessionId], async () => { + await expect( + coordinator.runSharedMany([upper], async () => 'shared'), + ).rejects.toThrow(SessionArchivingError); + expect(() => coordinator.assertNotTransitioning(upper)).toThrow( + SessionArchivingError, + ); + }); + }); + it('allows concurrent shared access and reference-counts release', async () => { const coordinator = new SessionArchiveCoordinator(); const sessionId = '550e8400-e29b-41d4-a716-446655440021'; diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index 6dd59f3d1d1..e1742f36b0c 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -18,6 +18,7 @@ import { } from '../acp-session-bridge.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { safeLogValue } from './request-helpers.js'; +import { normalizeSessionIdForLookup } from '../../config/session-id.js'; import { disableTasksForSessions, enableTasksForSessions, @@ -64,8 +65,13 @@ export class SessionArchiveCoordinator { | { promise: Promise; resolve: () => void } | undefined; + // Lock keys are canonicalized like every other session-id lookup: batch + // delete/archive/unarchive lock raw caller spellings while restore locks + // the request spelling, and on a case-insensitive filesystem both reach + // the same transcript file — uncanonicalized keys would let a differently + // cased caller id slip past a held guard and unlink it mid-restore. assertNotTransitioning(sessionId: string): void { - if (this.exclusive.has(sessionId)) { + if (this.exclusive.has(normalizeSessionIdForLookup(sessionId))) { throw new SessionArchivingError(sessionId); } } @@ -77,7 +83,9 @@ export class SessionArchiveCoordinator { if (this.maintenanceSealed) { throw new DaemonDrainingError(); } - const uniqueSessionIds = [...new Set(sessionIds)]; + const uniqueSessionIds = [ + ...new Set(sessionIds.map(normalizeSessionIdForLookup)), + ]; for (const sessionId of uniqueSessionIds) { this.assertNotTransitioning(sessionId); if ((this.shared.get(sessionId) ?? 0) > 0) { @@ -124,7 +132,9 @@ export class SessionArchiveCoordinator { if (this.maintenanceSealed) { throw new DaemonDrainingError(); } - const uniqueSessionIds = [...new Set(sessionIds)]; + const uniqueSessionIds = [ + ...new Set(sessionIds.map(normalizeSessionIdForLookup)), + ]; for (const sessionId of uniqueSessionIds) { this.assertNotTransitioning(sessionId); } From f0319ee3edb68f8b2cb2a93bd2eb5215add7bf93 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 00:26:05 +0800 Subject: [PATCH 13/29] test(cli): pin workspace ordering/race propagation and align PR2 plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the R4 review: - Pin that prepareStandaloneDirectory reads entries after the final identity re-inspection, via an interposed inspect that plants an entry mid-sequence (R4-3; mutant-verified to flip). - Pin that ensureStandaloneDirectory propagates a raced 'compromised' inspection verbatim instead of collapsing it to identity_changed (R4-7; mutant-verified to flip). - Plan: daemon bridge live-entry lookups (including getSessionEventEpoch) use the canonical ID — acp-bridge byId.get is exact-match with no id normalization — while the storage spelling is confined to SessionService filename/directory-hash/ACP-child storage operations (R4-1). - Plan: declare the session-archive coordinator lock-key canonicalization in the PR2A inventory and run session-archive.test.ts in the PR2A block (R4-2); add dispatch-error.test.ts to the PR2B block (R4-5). --- docs/plans/2026-08-14-standalone-pr2-core.md | 11 ++- .../conversation-workspace.test.ts | 78 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-08-14-standalone-pr2-core.md b/docs/plans/2026-08-14-standalone-pr2-core.md index 04011b36a91..99b2664ebff 100644 --- a/docs/plans/2026-08-14-standalone-pr2-core.md +++ b/docs/plans/2026-08-14-standalone-pr2-core.md @@ -342,9 +342,9 @@ Service维护process-local `creating: Map`,state只区 `creating` insert与service terminal flag检查必须是同一个无`await`同步临界区。已经拿到runtime但尚未insert的请求若在此之前观察到terminal,直接返回runtime unavailable且不得取得global reservation;observer不需要追踪一个尚未拥有任何资源的request。若insert先发生,随后observer必定看到并冻结该entry。Insert后、每个下一次异步边界前仍执行`assertRuntimeCurrent()`;finally同时按entry object identity和service terminal/frozen状态决定是否移除,不能因该transaction本地还未看到quarantine completion而释放reservation。 -Service同时维护`directoryStates: Map`。`pinned`是本daemon ownership lifetime的child identity,合法写入只有三处:new create materialization、daemon启动后该session第一次load/repair安全观察、以及exclusive load/repair证明old child absent后创建的新identity。普通load遇到已有pin必须传给workspace检查;同路径不同inode不能被当成“重新发现”。`agentBound`包含pinned identity、bridge session event epoch和`released` phase:同一runtime generation的managed relocation完成、daemon再次inspect得到同一pinned identity后先写`released: false`,只有child release确认成功才原子提升为`released: true`。所有reuse和cwd preflight只接受true;failure/unknown在close/quarantine前先清record。复用时重读`getSessionEventEpoch(storageSessionId)`,因此ACP channel/session重建不会误用旧bound。Cold session、epoch变化或pin替换都使它无效。PR3接入archive时保留pin但清除agentBound,clean rollback或PR3 delete确认child absent后才清除整个state;PR3也负责deletion journal恢复时的更新。ACP Session内的turn guard保留独立副本作为child-side defense,不能替代daemon state。 +Service同时维护`directoryStates: Map`。`pinned`是本daemon ownership lifetime的child identity,合法写入只有三处:new create materialization、daemon启动后该session第一次load/repair安全观察、以及exclusive load/repair证明old child absent后创建的新identity。普通load遇到已有pin必须传给workspace检查;同路径不同inode不能被当成“重新发现”。`agentBound`包含pinned identity、bridge session event epoch和`released` phase:同一runtime generation的managed relocation完成、daemon再次inspect得到同一pinned identity后先写`released: false`,只有child release确认成功才原子提升为`released: true`。所有reuse和cwd preflight只接受true;failure/unknown在close/quarantine前先清record。复用时重读`getSessionEventEpoch(canonicalSessionId)`,因此ACP channel/session重建不会误用旧bound。Cold session、epoch变化或pin替换都使它无效。PR3接入archive时保留pin但清除agentBound,clean rollback或PR3 delete确认child absent后才清除整个state;PR3也负责deletion journal恢复时的更新。ACP Session内的turn guard保留独立副本作为child-side defense,不能替代daemon state。 -所有接受session identity的service方法都先执行同一个UUID v1-v5 parser并得到lowercase `canonicalSessionId`;malformed id返回`invalid_request`,map、reservation、lifecycle lock和wire DTO只使用canonical value。新建session的`storageSessionId`和canonical value相同。恢复历史mixed-case transcript时,service通过SessionService的case-insensitive resolver得到文件名中的authoritative `storageSessionId`,并且只在SessionService、bridge和directory hash操作中使用该原始拼写。这保持现有ACP mixed-case load语义,也不会把老transcript绑定到lowercase重算后的另一个child目录。若active/archived namespace中存在两个仅大小写不同的持久化ID,resolver返回conflict,service fail closed;不依赖`readdir`顺序选择其中一个。 +所有接受session identity的service方法都先执行同一个UUID v1-v5 parser并得到lowercase `canonicalSessionId`;malformed id返回`invalid_request`,map、reservation、lifecycle lock和wire DTO只使用canonical value。新建session的`storageSessionId`和canonical value相同。恢复历史mixed-case transcript时,service通过SessionService的case-insensitive resolver得到文件名中的authoritative `storageSessionId`,并且只在SessionService filename、directory hash和ACP-child Config/session storage操作中使用该原始拼写;daemon bridge的live entry lookup(包括`getSessionEventEpoch`)一律使用canonical ID——`packages/acp-bridge`的`byId.get`是精确匹配且无id归一化,storage拼写会错过canonical key的live entry。这保持现有ACP mixed-case load语义,也不会把老transcript绑定到lowercase重算后的另一个child目录。若active/archived namespace中存在两个仅大小写不同的持久化ID,resolver返回conflict,service fail closed;不依赖`readdir`顺序选择其中一个。 创建步骤: @@ -387,7 +387,7 @@ Reservation仅在success、已证明pre-persistence clean rollback、或未发 Listing复用 `server/session-list.ts` 的全量 persisted snapshot/cache和 live merge,不在 page之后过滤。新增 internal standalone predicate path:先筛选 compatible top-level standalone、排除所有 child/Live/other,再按 `(activityTime, sessionId)`排序分页。Cursor绑定 `archiveState + catalogKind: "standalone"`,不能与 generic metadata cursor互换。`truncated`/abort/liveMergeFailed语义保持现有实现。 -列表对外返回canonical UUID,但service内部record保留storage ID供bridge/child路由;storage ID是non-DTO字段,不能被object spread或error serialization带到响应。同一canonical UUID出现多个storage spelling时不选择或合并,而是记录bounded conflict并从列表排除;exact lookup仍返回409。列表不 probe child directory;工作目录状态只在 create/load/resume/repair/prompt中检查。 +列表对外返回canonical UUID,但service内部record保留storage ID供SessionService filename/directory-hash/ACP-child路由使用(daemon bridge entry lookup使用canonical ID,bridge包内无id归一化);storage ID是non-DTO字段,不能被object spread或error serialization带到响应。同一canonical UUID出现多个storage spelling时不选择或合并,而是记录bounded conflict并从列表排除;exact lookup仍返回409。列表不 probe child directory;工作目录状态只在 create/load/resume/repair/prompt中检查。 ### 7. Load、resume 与 repair @@ -451,6 +451,7 @@ ACP child guard再次检查所有真正开始的turn,覆盖HTTP route之外的 - Modify: `packages/cli/src/acp-integration/acpAgent.ts`及load/resume tests,移除exact-lowercase `sessionExists()` fast path;ACP child必须直接调用唯一case-insensitive resolver,才能在exact与case-only twin并存时于Config/filesystem初始化前fail closed。 - Modify: `packages/cli/src/serve/live/live-task-service.ts`及现有caller tests,只把旧source adapter调用改为传入existence-aware SessionService store;不在PR2A迁移Live task的创建或restore语义。 - Modify: `packages/cli/src/serve/session-id-admission.ts`及test,让case-only duplicate resolver结果按persisted UUID conflict处理,而不是被外层catch误映射为临时`session_id_admission_unavailable`;该适配只改变重复持久化ID的fail-closed分类,不改变I/O失败的retryable unavailable语义。 +- Modify: `packages/cli/src/serve/server/session-archive.ts`及test,把coordinator锁key(`exclusive`/`shared` map与`assertNotTransitioning`)经`normalizeSessionIdForLookup`归一化,使caller id的任意大小写变体竞争同一把锁,关闭大小写不敏感文件系统上跨拼写batch delete/archive/unarchive在restore mid-section去链transcript的窗口;batch helper的raw-spelling去重保持原样(归一化去重会让Linux上case-distinct legacy twin的exact-path lookup失配)。 - Modify: `packages/core/src/services/sessionService.ts`及test,让case-insensitive persisted-ID resolver无论exact lowercase文件是否存在都扫描active/archived候选;单一candidate返回authoritative spelling,仅大小写不同的多个candidate抛typed conflict。同一文件新增`readCreationMetadataIfReadable()`,把creation metadata读取与existence state绑定,corrupt metadata fail closed。 - Modify: `packages/core/src/utils/jsonl-utils.ts`及test,新增`readLinesWithIntegrity()` fail-closed reader,供`readCreationMetadataIfReadable()`区分missing与corrupt transcript;不新增其他core util。 - Modify: `packages/cli/src/serve/server/error-response.ts`及test,把core `SessionIdCaseConflictError`映射为与`SessionConflictError`相同的无path 409 `session_conflict`形状,作为routes/dispatch翻译之后的defense-in-depth。 @@ -477,7 +478,7 @@ PR2A跨到`packages/core`的生产改动只允许`SessionService`既有case-inse 若实现需要修改清单外production文件,先说明对应不变量;无法对应则视为scope leakage。特别是SDK/WebShell/capabilities/scheduled-task routes和archive/delete helpers不属于PR2。 -`SessionService.findSessionIdIgnoringCase()`当前生产consumer只有ACP child `loadSession`、ACP child `resumeSession`和`RequestedSessionIdAdmission`,其中三个入口目前都存在exact lookup bypass。PR2A还会让REST internal restore与ACP HTTP internal restore调用它。修改冲突语义时必须回归这五个consumer:单一mixed-case transcript仍返回authoritative spelling并用同一spelling做bridge/directory操作;case-only duplicate在四个restore入口都fail closed;global create/restore admission显式识别resolver的duplicate结果并把它视为persisted占用,不能让现有通用catch把它降成retryable unavailable,且错误不泄露路径。所有consumer都必须直接调用唯一resolver,不能先用exact lowercase fast path绕过duplicate检测。若实现新增返回类型而不是typed exception,同一轮必须更新全部consumer,不保留旧的“任选第一个”入口。 +`SessionService.findSessionIdIgnoringCase()`当前生产consumer只有ACP child `loadSession`、ACP child `resumeSession`和`RequestedSessionIdAdmission`,其中三个入口目前都存在exact lookup bypass。PR2A还会让REST internal restore与ACP HTTP internal restore调用它。修改冲突语义时必须回归这五个consumer:单一mixed-case transcript仍返回authoritative spelling并用同一spelling做SessionService filename/directory-hash/ACP-child操作(daemon bridge entry lookup保持canonical ID);case-only duplicate在四个restore入口都fail closed;global create/restore admission显式识别resolver的duplicate结果并把它视为persisted占用,不能让现有通用catch把它降成retryable unavailable,且错误不泄露路径。所有consumer都必须直接调用唯一resolver,不能先用exact lowercase fast path绕过duplicate检测。若实现新增返回类型而不是typed exception,同一轮必须更新全部consumer,不保留旧的“任选第一个”入口。 ## Structured errors @@ -566,6 +567,7 @@ npx vitest run \ src/serve/conversations/conversation-workspace.test.ts \ src/utils/conversation-directory-identity.test.ts \ src/serve/session-id-admission.test.ts \ + src/serve/server/session-archive.test.ts \ src/serve/acp-http/transport.test.ts \ src/serve/acp-http/dispatch-error.test.ts \ src/serve/multi-workspace-sessions.test.ts \ @@ -605,6 +607,7 @@ npx vitest run \ src/acp-integration/session/permissionUtils.test.ts \ src/serve/server/session-archive.test.ts \ src/serve/acp-http/transport.test.ts \ + src/serve/acp-http/dispatch-error.test.ts \ src/serve/routes/workspace-management.test.ts \ src/serve/live/live-task-service.test.ts \ src/serve/create-sub-session.test.ts \ diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index c4c831205b6..f896ac7fb5c 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -26,6 +26,36 @@ import { } from './conversation-workspace.js'; import { ConversationDirectoryIdentityError } from '../../utils/conversation-directory-identity.js'; +const plantOnExpectedInspect = vi.hoisted(() => ({ armed: false })); + +vi.mock( + '../../utils/conversation-directory-identity.js', + async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../utils/conversation-directory-identity.js') + >(); + return { + ...actual, + inspectConversationDirectoryIdentity: async ( + ...args: Parameters + ) => { + const identity = await actual.inspectConversationDirectoryIdentity( + ...args, + ); + // Arms only on the final re-inspection (the sole caller passing + // `expected`): plants an entry into the child before the caller's + // emptiness snapshot, so a stale readdir ordering is observable. + if (plantOnExpectedInspect.armed && args[2] !== undefined && identity) { + plantOnExpectedInspect.armed = false; + await writeFile(join(identity.canonicalPath, 'planted.txt'), 'x'); + } + return identity; + }, + }; + }, +); + const cleanup: string[] = []; afterEach(async () => { @@ -265,6 +295,27 @@ describe('Live conversation workspace root', () => { ); }); + it('rejects as not_empty when an entry appears during the final identity re-inspection', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + + // The interposed inspect plants an entry after the identity verdict but + // before the caller's readdir; only a post-inspect entries snapshot can + // see it — the pre-inspect ordering resolves as empty here. + plantOnExpectedInspect.armed = true; + try { + await expect( + workspace.prepareStandaloneDirectory('standalone'), + ).rejects.toMatchObject({ + name: 'ConversationDirectoryIdentityError', + scope: 'child', + reason: 'not_empty', + }); + } finally { + plantOnExpectedInspect.armed = false; + } + }); + it('sanitizes standalone child filesystem errors', async () => { if (process.platform === 'win32') return; // Root bypasses the 0o000 chmod below via CAP_DAC_OVERRIDE, so the @@ -367,4 +418,31 @@ describe('Live conversation workspace root', () => { expect(ensured.error).toBeInstanceOf(ConversationDirectoryIdentityError); expect(ensured.error.reason).toBe('identity_changed'); }); + + it('propagates a raced compromised inspection verbatim from the ensure race', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + + const racedError = new ConversationDirectoryIdentityError( + 'child', + 'unexpected_identity', + ); + const inspect = vi.spyOn(workspace, 'inspectStandaloneDirectory'); + inspect + .mockResolvedValueOnce({ status: 'missing' }) + .mockResolvedValueOnce({ status: 'compromised', error: racedError }); + + const ensured = await workspace.ensureStandaloneDirectory( + 'standalone', + prepared.identity, + ); + expect(ensured.status).toBe('compromised'); + if (ensured.status !== 'compromised') { + throw new Error('expected compromised'); + } + // A narrowed `raced.status === 'ready'` pass-through would instead + // surface a fresh identity_changed here; the raced reason must survive. + expect(ensured.error).toBe(racedError); + }); }); From ec60a33d75e5e6c438f197945a8bf095d3414450 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 05:50:53 +0800 Subject: [PATCH 14/29] fix(core): make case-insensitive resolver conflict decisions content-based MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver threw SessionIdCaseConflictError on filename enumeration alone, before any content validation, and silently dropped a single candidate whose head recovered no records. Two probe-verified failure modes from the R5 review: - A present-but-unreadable case-variant transcript (torn/empty/ foreign-project head) resolved to undefined, so create admission admitted the canonical spelling and materialized a case-only twin; every later resolve then threw on the duplicate, permanently locking out the just-created session (R5-1). - A valid session with an unreadable same-spelling twin in the other state directory threw on enumeration while getSessionLocation cleanly reported one readable copy — listed as loadable, but every restore 409'd (R5-2). Conflict arms now consult getSessionLocation: exactly one readable spelling wins; conflict is thrown when two or more are genuinely readable (or a single candidate is conflicted across states); a candidate whose head fails validation still occupies the id when its file is on disk, while one that raced away mid-resolution resolves to undefined. Admission already maps the thrown conflict to persisted-true, so no admission change is needed. --- .../core/src/services/sessionService.test.ts | 62 ++++++++++++++++++- packages/core/src/services/sessionService.ts | 44 +++++++++++-- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index b0209d2f452..a67a832abe8 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2532,6 +2532,9 @@ describe('SessionService', () => { `${sessionIdA.toUpperCase()}.jsonl`, ] as never) .mockReturnValueOnce([] as never); + // Both heads fail content validation but the files are still on + // disk — occupancy, not absence. + existsSyncSpy.mockReturnValue(true); const getLocation = vi.spyOn(sessionService, 'getSessionLocation'); await expect( @@ -2542,12 +2545,14 @@ describe('SessionService', () => { candidateSessionId: undefined, message: `Multiple persisted sessions match "${sessionIdA}" by case.`, }); - expect(getLocation).not.toHaveBeenCalled(); + expect(getLocation).toHaveBeenCalledTimes(2); }); it('rejects one spelling that exists in both active and archive state', async () => { readdirSyncSpy.mockReturnValue([`${sessionIdA}.jsonl`] as never); - const getLocation = vi.spyOn(sessionService, 'getSessionLocation'); + const getLocation = vi + .spyOn(sessionService, 'getSessionLocation') + .mockResolvedValue('conflict'); await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), @@ -2557,7 +2562,58 @@ describe('SessionService', () => { candidateSessionId: sessionIdA, message: `Session "${sessionIdA}" is persisted in both active and archived states.`, }); - expect(getLocation).not.toHaveBeenCalled(); + expect(getLocation).toHaveBeenCalledTimes(1); + }); + + it('rejects a present-but-unreadable single candidate as occupying the id', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never) + .mockReturnValueOnce([] as never); + // The head recovers no records (torn/empty/foreign), but the file + // still occupies the id — admission must not mint a case-only twin. + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + undefined, + ); + existsSyncSpy.mockReturnValue(true); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + candidateSessionId: legacySessionId, + }); + }); + + it('returns the sole readable spelling when a case twin is unreadable', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([ + `${sessionIdA}.jsonl`, + `${legacySessionId}.jsonl`, + ] as never) + .mockReturnValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === legacySessionId ? 'active' : undefined), + ); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBe(legacySessionId); + }); + + it('returns the spelling when one of its two state copies is unreadable', async () => { + readdirSyncSpy.mockReturnValue([`${sessionIdA}.jsonl`] as never); + // getSessionLocation counts only readable copies, so one garbage + // twin still resolves to the surviving state. + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + 'active', + ); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBe(sessionIdA); }); it('returns undefined when the matching transcript disappears during resolution', async () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 060660d058d..3a108b7c8ff 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -770,19 +770,55 @@ export class SessionService { } } if (candidates.size > 1) { + // Conflict decisions are content-based, not filename-based: a file + // whose head recovers no records (crash-mid-append tear, foreign + // project) still occupies the id, but does not make a loadable + // session conflict with one. + const readable: string[] = []; + for (const candidateSessionId of candidates.keys()) { + const location = await this.getSessionLocation(candidateSessionId); + if (location === 'conflict') { + throw new SessionIdCaseConflictError(sessionId, candidateSessionId); + } + if (location !== undefined) readable.push(candidateSessionId); + } + if (readable.length === 1) return readable[0]; + if (readable.length === 0) { + // Every enumerated file failed content validation: files still on + // disk occupy the id (admission must not mint a case-only twin); + // files that raced away mid-resolution are genuinely absent. + let anyPresent = false; + for (const [candidateSessionId, states] of candidates) { + for (const state of states) { + anyPresent ||= fs.existsSync( + this.getSessionFilePath(candidateSessionId, state), + ); + } + } + if (!anyPresent) return undefined; + } throw new SessionIdCaseConflictError(sessionId); } const candidate = candidates.entries().next().value; if (candidate === undefined) return undefined; const [candidateSessionId, states] = candidate; - if (states.size > 1) { - throw new SessionIdCaseConflictError(sessionId, candidateSessionId); - } + // Content first: one readable copy of a spelling present in both state + // directories resolves to that copy (getSessionLocation counts only + // readable transcripts), not a conflict. const location = await this.getSessionLocation(candidateSessionId); if (location === 'conflict') { throw new SessionIdCaseConflictError(sessionId, candidateSessionId); } - return location === undefined ? undefined : candidateSessionId; + if (location !== undefined) return candidateSessionId; + // The head recovered no records: a file still on disk occupies the id + // (admission must not mint a case-only twin of it); one that raced + // away mid-resolution is genuinely absent. + for (const state of states) { + if (fs.existsSync(this.getSessionFilePath(candidateSessionId, state))) { + throw new SessionIdCaseConflictError(sessionId, candidateSessionId); + } + } + return undefined; } private removeFileIfExists(filePath: string): void { From 526bf15195048a512cdfb41a70346ff1cd1f3ab9 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 05:59:25 +0800 Subject: [PATCH 15/29] fix(cli): make caller-supplied sessionId create admission case-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The argv['sessionId'] branch (reached by raw stdio ACP session/new with a requestedSessionId, without any daemon reserveCreate) checked occupancy with exact-spelled sessionExistsInAnyState only, so a legacy mixed-case transcript did not block the create and the daemon persisted a case-only twin — which the resolver's conflict semantics then make permanently unrestorable on every surface (R5-2). Route the check through the case-insensitive resolver, treating its conflict throw as occupancy. Also pin that the ACP restore path hands the resolver-adopted storage spelling to assertSessionLoadable: archived uppercase transcript restored via the canonical lowercase id must surface errorKind 'session_archived' (R5-3; the request-spelling mutant skips the error on case-sensitive filesystems). --- packages/cli/src/config/config.test.ts | 51 +++++++++++++++++-- packages/cli/src/config/config.ts | 22 ++++++-- .../cli/src/serve/acp-http/transport.test.ts | 34 +++++++++++++ 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 2022b3156ef..19eef772b3f 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -13,6 +13,7 @@ import { OutputFormat, NativeLspService, Storage, + SessionIdCaseConflictError, } from '@qwen-code/qwen-code-core'; import { isValidSessionId, @@ -35,6 +36,7 @@ const mockSessionServiceInstance = vi.hoisted(() => ({ forkSession: vi.fn(), sessionExists: vi.fn(), sessionExistsInAnyState: vi.fn(), + findSessionIdIgnoringCase: vi.fn(), })); const mockSessionServiceCtor = vi.hoisted(() => vi.fn(() => mockSessionServiceInstance), @@ -1123,6 +1125,9 @@ describe('loadCliConfig', () => { }); mockSessionServiceInstance.sessionExists.mockResolvedValue(false); mockSessionServiceInstance.sessionExistsInAnyState.mockResolvedValue(false); + mockSessionServiceInstance.findSessionIdIgnoringCase.mockResolvedValue( + undefined, + ); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); resetMcpApprovalsForTesting(); @@ -1890,7 +1895,43 @@ describe('loadCliConfig', () => { it('should exit when a caller-supplied sessionId already exists (default CLI behavior)', async () => { const sessionId = '123e4567-e89b-12d3-a456-426614174000'; - mockSessionServiceInstance.sessionExistsInAnyState.mockResolvedValue(true); + mockSessionServiceInstance.findSessionIdIgnoringCase.mockResolvedValue( + sessionId, + ); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(loadCliConfig({}, { sessionId } as CliArgs)).rejects.toThrow( + 'process.exit called', + ); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should exit when a caller-supplied sessionId matches a legacy case-variant transcript', async () => { + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + // The id is free in its exact spelling, but a legacy uppercase twin + // occupies it — creating would mint a permanently unrestorable pair. + mockSessionServiceInstance.findSessionIdIgnoringCase.mockResolvedValue( + sessionId.toUpperCase(), + ); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(loadCliConfig({}, { sessionId } as CliArgs)).rejects.toThrow( + 'process.exit called', + ); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should exit when the case-insensitive occupancy check reports a conflict', async () => { + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + mockSessionServiceInstance.findSessionIdIgnoringCase.mockRejectedValue( + new SessionIdCaseConflictError(sessionId), + ); const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); @@ -1904,7 +1945,9 @@ describe('loadCliConfig', () => { it('should throw SessionIdConflictError instead of exiting when throwOnSessionIdConflict is set', async () => { const sessionId = '123e4567-e89b-12d3-a456-426614174000'; - mockSessionServiceInstance.sessionExistsInAnyState.mockResolvedValue(true); + mockSessionServiceInstance.findSessionIdIgnoringCase.mockResolvedValue( + sessionId, + ); const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); @@ -1928,7 +1971,9 @@ describe('loadCliConfig', () => { it('should not throw for a fresh caller-supplied sessionId when throwOnSessionIdConflict is set', async () => { const sessionId = '123e4567-e89b-12d3-a456-426614174000'; - mockSessionServiceInstance.sessionExistsInAnyState.mockResolvedValue(false); + mockSessionServiceInstance.findSessionIdIgnoringCase.mockResolvedValue( + undefined, + ); const config = await loadCliConfig( {}, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bf538d25955..4d45c747347 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -44,6 +44,7 @@ import { type WebSearchSettings, MAX_SUBAGENT_DEPTH_LIMIT, addDaemonRequestAttribute, + SessionIdCaseConflictError, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { hooksCommand } from '../commands/hooks.js'; @@ -2068,12 +2069,23 @@ export async function loadCliConfig( sessionId = argv.sandboxSessionId; } else if (argv['sessionId']) { // Use provided session ID without session resumption - // Check if session ID is already in use + // Check if session ID is already in use — case-insensitively: a legacy + // mixed-case transcript still occupies the id, and creating a + // case-only twin would make both spellings permanently unrestorable. const sessionService = new SessionService(cwd); - const exists = await sessionService.sessionExistsInAnyState( - argv['sessionId'], - ); - if (exists) { + let occupied: boolean; + try { + occupied = + (await sessionService.findSessionIdIgnoringCase(argv['sessionId'])) !== + undefined; + } catch (error) { + if (error instanceof SessionIdCaseConflictError) { + occupied = true; + } else { + throw error; + } + } + if (occupied) { const message = `Error: Session Id ${argv['sessionId']} already exists (active or archived). Delete or unarchive it first.`; if (throwOnSessionIdConflict) { throw new SessionIdConflictError(argv['sessionId'], message); diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 84f751fa3ba..c2143c355aa 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4561,6 +4561,40 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); + it.each(['session/load', 'session/resume'])( + '%s rejects an archived mixed-case transcript restored by its canonical id', + async (method) => { + const sessionId = '550e8400-e29b-41d4-a716-446655440124'; + const storageSessionId = sessionId.toUpperCase(); + await withRuntimeDir(async () => { + await writeStoredSession(storageSessionId, 'archived'); + + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 212, + method, + params: { sessionId }, + }); + + const [frame] = (await got) as Array<{ + id: number; + error: { code: number; data?: { errorKind?: string } }; + }>; + expect(frame.id).toBe(212); + expect(frame.error.code).toBe(-32603); + // The resolver-adopted storage spelling must reach + // assertSessionLoadable — the request spelling would miss the + // archived uppercase file on a case-sensitive filesystem and skip + // this error entirely. + expect(frame.error.data?.errorKind).toBe('session_archived'); + }); + }, + ); + it('session/load rejects active/archive conflicts', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440321'; From 153856befa3556dbcd599a0026029bdd65c213d0 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 09:45:13 +0800 Subject: [PATCH 16/29] fix(cli): narrow reserved-source restore gate to internal runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two items from the maintainer's round-2 live verification: - N1: the restore-side reserved-standalone-source gate fired for every runtime, so a transcript persisted on main with the client-supplied sourceType "standalone" became permanently unloadable while still listed. The create side already blocks new reserved-source transcripts, so any such file on an ordinary store predates the gate — keep it loadable there and hide only on the internal Conversations runtime (REST) / isolated ACP surface, where genuine standalone sessions will live. Generic-arm tests on both surfaces flipped to pin the compat restore; the internal-arm 404 pin is unchanged. - N2: the R5 occupancy throw reused the both-states message for a single unreadable transcript. SessionIdCaseConflictError gains a 'unreadable_transcript' reason with a truthful message, used by both occupancy arms; the case_conflict shape is unchanged. --- packages/cli/src/serve/acp-http/dispatch.ts | 7 +++- .../cli/src/serve/acp-http/transport.test.ts | 14 +++++--- packages/cli/src/serve/routes/session.ts | 9 ++++- packages/cli/src/serve/server.test.ts | 24 ++++++++------ .../core/src/services/sessionService.test.ts | 33 ++++++++++++++++--- packages/core/src/services/sessionService.ts | 26 ++++++++++++--- 6 files changed, 88 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 1ad45a56ae4..c4b08d0ea76 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -1890,9 +1890,14 @@ export class AcpDispatcher { sessionService, ) : await sessionService.readCreationMetadata(storageSessionId); + // The reserved standalone source is hidden only on the + // isolated Conversations surface (parity with the REST + // restore handler); generic restores keep loading legacy + // transcripts that carry the reserved source string. if ( metadata === undefined || - isReservedStandaloneSessionSource(metadata) + (this.liveSessionIsolation !== undefined && + isReservedStandaloneSessionSource(metadata)) ) { throw new SessionNotFoundError(sessionId); } diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index c2143c355aa..7593f6491e3 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4886,7 +4886,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); it.each(['session/load', 'session/resume'] as const)( - '%s hides mixed-case explicit standalone transcripts from generic restore', + '%s restores mixed-case reserved-source transcripts on the generic surface', async (method) => { await withRuntimeDir(async () => { const sessionId = @@ -4923,16 +4923,22 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { method, params: { sessionId }, }); + // Generic (non-isolated) restore keeps loading legacy + // reserved-source transcripts; only the isolated Conversations + // surface hides them (parity with the REST restore handler). expect(await reader.next()).toMatchObject({ id: 219, - error: { message: expect.stringContaining('No session with id') }, + result: expect.any(Object), }); reader.close(); expect(findSessionId).toHaveBeenCalledWith(sessionId); expect(readCreationMetadata).toHaveBeenCalledWith(storageSessionId); - expect(bridge.loadRequests).toHaveLength(loadCount); - expect(bridge.resumeRequests).toHaveLength(resumeCount); + expect( + method === 'session/load' + ? bridge.loadRequests.length + : bridge.resumeRequests.length, + ).toBe((method === 'session/load' ? loadCount : resumeCount) + 1); } finally { readCreationMetadata.mockRestore(); findSessionId.mockRestore(); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 0a4e0dd2e7e..695df14729f 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -3060,9 +3060,16 @@ export function registerSessionRoutes( : await sessionService.readCreationMetadata( restoredStorageSessionId, ); + // The reserved standalone source is hidden only on the internal + // Conversations runtime. Ordinary workspace restores keep + // loading legacy transcripts that happen to carry the reserved + // source string — create-side admission already blocks new ones, + // so every such transcript on an ordinary store predates the + // gate and must not become unreachable. if ( metadata === undefined || - isReservedStandaloneSessionSource(metadata) + (isInternalWorkspaceRuntime(runtime) && + isReservedStandaloneSessionSource(metadata)) ) { throw new SessionNotFoundError(sessionId); } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 3288843f69d..29cf0ed1dbe 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -11964,7 +11964,7 @@ describe('createServeApp', () => { }); it.each(['load', 'resume'] as const)( - 'hides explicit standalone transcripts from generic %s', + 'restores legacy reserved-source transcripts on ordinary workspace runtimes (%s)', async (action) => { const bridge = fakeBridge(); const readCreationMetadata = vi @@ -11982,10 +11982,14 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`) .send({}); - expect(res.status).toBe(404); - expect(res.body.code).toBe('session_not_found'); - expect(bridge.loadCalls).toEqual([]); - expect(bridge.resumeCalls).toEqual([]); + // Create-side admission already blocks new reserved-source + // transcripts, so one found on an ordinary store predates the + // gate and stays loadable; only the internal Conversations + // runtime hides it. + expect(res.status).toBe(200); + const calls = + action === 'load' ? bridge.loadCalls : bridge.resumeCalls; + expect(calls).toHaveLength(1); } finally { readCreationMetadata.mockRestore(); } @@ -11993,7 +11997,7 @@ describe('createServeApp', () => { ); it.each(['load', 'resume'] as const)( - 'hides mixed-case explicit standalone transcripts from generic %s', + 'restores mixed-case reserved-source transcripts on ordinary workspace runtimes (%s)', async (action) => { const sessionId = '550e8400-e29b-41d4-a716-446655440140'; const storageSessionId = sessionId.toUpperCase(); @@ -12020,12 +12024,12 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`) .send({}); - expect(res.status).toBe(404); - expect(res.body.code).toBe('session_not_found'); + expect(res.status).toBe(200); expect(findSessionId).toHaveBeenCalledWith(sessionId); expect(readCreationMetadata).toHaveBeenCalledWith(storageSessionId); - expect(bridge.loadCalls).toEqual([]); - expect(bridge.resumeCalls).toEqual([]); + const calls = + action === 'load' ? bridge.loadCalls : bridge.resumeCalls; + expect(calls).toHaveLength(1); } finally { findSessionId.mockRestore(); readCreationMetadata.mockRestore(); diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index a67a832abe8..71687b65ce8 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2532,10 +2532,10 @@ describe('SessionService', () => { `${sessionIdA.toUpperCase()}.jsonl`, ] as never) .mockReturnValueOnce([] as never); - // Both heads fail content validation but the files are still on - // disk — occupancy, not absence. - existsSyncSpy.mockReturnValue(true); - const getLocation = vi.spyOn(sessionService, 'getSessionLocation'); + // Both candidates are genuinely readable — a true conflict. + const getLocation = vi + .spyOn(sessionService, 'getSessionLocation') + .mockResolvedValue('active'); await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), @@ -2548,6 +2548,29 @@ describe('SessionService', () => { expect(getLocation).toHaveBeenCalledTimes(2); }); + it('rejects case-only duplicates whose heads are all unreadable as occupying the id', async () => { + readdirSyncSpy + .mockReturnValueOnce([ + `${sessionIdA}.jsonl`, + `${sessionIdA.toUpperCase()}.jsonl`, + ] as never) + .mockReturnValueOnce([] as never); + // Neither head recovers records, but both files persist on disk. + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + undefined, + ); + existsSyncSpy.mockReturnValue(true); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + candidateSessionId: undefined, + reason: 'unreadable_transcript', + }); + }); + it('rejects one spelling that exists in both active and archive state', async () => { readdirSyncSpy.mockReturnValue([`${sessionIdA}.jsonl`] as never); const getLocation = vi @@ -2583,6 +2606,8 @@ describe('SessionService', () => { name: 'SessionIdCaseConflictError', sessionId: sessionIdA, candidateSessionId: legacySessionId, + reason: 'unreadable_transcript', + message: `Session "${legacySessionId}" is persisted but its transcript head is unreadable.`, }); }); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 3a108b7c8ff..22bde1ba4ac 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -148,15 +148,22 @@ export class SessionIdCaseConflictError extends Error { // `candidateSessionId` is set only when one exact spelling was found // persisted in both active and archived states, so callers can re-check - // the persisted spelling instead of the request-case id. + // the persisted spelling instead of the request-case id. `reason` + // separates a genuinely conflicted pair from a single transcript whose + // head is unreadable yet still occupies the id. constructor( readonly sessionId: string, readonly candidateSessionId?: string, + readonly reason: + | 'case_conflict' + | 'unreadable_transcript' = 'case_conflict', ) { super( - candidateSessionId === undefined - ? `Multiple persisted sessions match "${sessionId}" by case.` - : `Session "${candidateSessionId}" is persisted in both active and archived states.`, + reason === 'unreadable_transcript' + ? `Session "${candidateSessionId ?? sessionId}" is persisted but its transcript head is unreadable.` + : candidateSessionId === undefined + ? `Multiple persisted sessions match "${sessionId}" by case.` + : `Session "${candidateSessionId}" is persisted in both active and archived states.`, ); } } @@ -796,6 +803,11 @@ export class SessionService { } } if (!anyPresent) return undefined; + throw new SessionIdCaseConflictError( + sessionId, + undefined, + 'unreadable_transcript', + ); } throw new SessionIdCaseConflictError(sessionId); } @@ -815,7 +827,11 @@ export class SessionService { // away mid-resolution is genuinely absent. for (const state of states) { if (fs.existsSync(this.getSessionFilePath(candidateSessionId, state))) { - throw new SessionIdCaseConflictError(sessionId, candidateSessionId); + throw new SessionIdCaseConflictError( + sessionId, + candidateSessionId, + 'unreadable_transcript', + ); } } return undefined; From 5fe4f7cdd3610d1d77bffa4ef139f37369b4ea89 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 10:13:34 +0800 Subject: [PATCH 17/29] test(cli): flip the remaining generic-surface reserved-source test The exact-spelling variant was missed in the N1 narrowing commit and failed CI on ubuntu (session/load + session/resume expected the old generic-surface hide). Flip it to pin the compat restore like its mixed-case sibling. --- packages/cli/src/serve/acp-http/transport.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 7593f6491e3..aa51c973262 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4853,7 +4853,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); it.each(['session/load', 'session/resume'] as const)( - '%s hides explicit standalone transcripts from generic restore', + '%s restores reserved-source transcripts on the generic surface', async (method) => { await withRuntimeDir(async () => { const sessionId = @@ -4873,14 +4873,20 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { method, params: { sessionId }, }); + // Generic (non-isolated) restore keeps loading legacy + // reserved-source transcripts; only the isolated Conversations + // surface hides them (parity with the REST restore handler). expect(await reader.next()).toMatchObject({ id: 218, - error: { message: expect.stringContaining('No session with id') }, + result: expect.any(Object), }); reader.close(); - expect(bridge.loadRequests).toHaveLength(loadCount); - expect(bridge.resumeRequests).toHaveLength(resumeCount); + expect( + method === 'session/load' + ? bridge.loadRequests.length + : bridge.resumeRequests.length, + ).toBe((method === 'session/load' ? loadCount : resumeCount) + 1); }); }, ); From ab0b0a42178ec0373f72b2851a37fd77e9ab8b95 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 15:43:19 +0800 Subject: [PATCH 18/29] docs: sync PR2A plan with the shipped create-admission resolver consumer The round-6 triage deferred note flagged the plan as desynced: the R5-2 fix made loadCliConfig's caller-supplied sessionId branch a sixth findSessionIdIgnoringCase consumer. Declare it in the per-file checklist, count it in the consumer inventory, and add config.test.ts to the PR2A vitest block. --- docs/plans/2026-08-14-standalone-pr2-core.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-14-standalone-pr2-core.md b/docs/plans/2026-08-14-standalone-pr2-core.md index 99b2664ebff..cd62d8709f8 100644 --- a/docs/plans/2026-08-14-standalone-pr2-core.md +++ b/docs/plans/2026-08-14-standalone-pr2-core.md @@ -451,6 +451,7 @@ ACP child guard再次检查所有真正开始的turn,覆盖HTTP route之外的 - Modify: `packages/cli/src/acp-integration/acpAgent.ts`及load/resume tests,移除exact-lowercase `sessionExists()` fast path;ACP child必须直接调用唯一case-insensitive resolver,才能在exact与case-only twin并存时于Config/filesystem初始化前fail closed。 - Modify: `packages/cli/src/serve/live/live-task-service.ts`及现有caller tests,只把旧source adapter调用改为传入existence-aware SessionService store;不在PR2A迁移Live task的创建或restore语义。 - Modify: `packages/cli/src/serve/session-id-admission.ts`及test,让case-only duplicate resolver结果按persisted UUID conflict处理,而不是被外层catch误映射为临时`session_id_admission_unavailable`;该适配只改变重复持久化ID的fail-closed分类,不改变I/O失败的retryable unavailable语义。 +- Modify: `packages/cli/src/config/config.ts`及test,让caller-supplied `--session-id`/ACP requestedSessionId的create admission从exact `sessionExistsInAnyState`改走唯一case-insensitive resolver,resolver冲突即占用(R5-2);不改变正常创建路径。 - Modify: `packages/cli/src/serve/server/session-archive.ts`及test,把coordinator锁key(`exclusive`/`shared` map与`assertNotTransitioning`)经`normalizeSessionIdForLookup`归一化,使caller id的任意大小写变体竞争同一把锁,关闭大小写不敏感文件系统上跨拼写batch delete/archive/unarchive在restore mid-section去链transcript的窗口;batch helper的raw-spelling去重保持原样(归一化去重会让Linux上case-distinct legacy twin的exact-path lookup失配)。 - Modify: `packages/core/src/services/sessionService.ts`及test,让case-insensitive persisted-ID resolver无论exact lowercase文件是否存在都扫描active/archived候选;单一candidate返回authoritative spelling,仅大小写不同的多个candidate抛typed conflict。同一文件新增`readCreationMetadataIfReadable()`,把creation metadata读取与existence state绑定,corrupt metadata fail closed。 - Modify: `packages/core/src/utils/jsonl-utils.ts`及test,新增`readLinesWithIntegrity()` fail-closed reader,供`readCreationMetadataIfReadable()`区分missing与corrupt transcript;不新增其他core util。 @@ -478,7 +479,7 @@ PR2A跨到`packages/core`的生产改动只允许`SessionService`既有case-inse 若实现需要修改清单外production文件,先说明对应不变量;无法对应则视为scope leakage。特别是SDK/WebShell/capabilities/scheduled-task routes和archive/delete helpers不属于PR2。 -`SessionService.findSessionIdIgnoringCase()`当前生产consumer只有ACP child `loadSession`、ACP child `resumeSession`和`RequestedSessionIdAdmission`,其中三个入口目前都存在exact lookup bypass。PR2A还会让REST internal restore与ACP HTTP internal restore调用它。修改冲突语义时必须回归这五个consumer:单一mixed-case transcript仍返回authoritative spelling并用同一spelling做SessionService filename/directory-hash/ACP-child操作(daemon bridge entry lookup保持canonical ID);case-only duplicate在四个restore入口都fail closed;global create/restore admission显式识别resolver的duplicate结果并把它视为persisted占用,不能让现有通用catch把它降成retryable unavailable,且错误不泄露路径。所有consumer都必须直接调用唯一resolver,不能先用exact lowercase fast path绕过duplicate检测。若实现新增返回类型而不是typed exception,同一轮必须更新全部consumer,不保留旧的“任选第一个”入口。 +`SessionService.findSessionIdIgnoringCase()`当前生产consumer只有ACP child `loadSession`、ACP child `resumeSession`和`RequestedSessionIdAdmission`,其中三个入口目前都存在exact lookup bypass。PR2A还会让REST internal restore与ACP HTTP internal restore调用它,并让`loadCliConfig`的caller-supplied `--session-id`/ACP requestedSessionId create admission从exact `sessionExistsInAnyState`改走该resolver(R5-2:stdio ACP路径无daemon reserveCreate,exact检查会漏掉legacy mixed-case占用而物化case-only twin)。修改冲突语义时必须回归这六个consumer:单一mixed-case transcript仍返回authoritative spelling并用同一spelling做SessionService filename/directory-hash/ACP-child操作(daemon bridge entry lookup保持canonical ID);case-only duplicate在四个restore入口都fail closed;global create/restore admission显式识别resolver的duplicate结果并把它视为persisted占用,不能让现有通用catch把它降成retryable unavailable,且错误不泄露路径。所有consumer都必须直接调用唯一resolver,不能先用exact lowercase fast path绕过duplicate检测。若实现新增返回类型而不是typed exception,同一轮必须更新全部consumer,不保留旧的“任选第一个”入口。 ## Structured errors @@ -570,6 +571,7 @@ npx vitest run \ src/serve/server/session-archive.test.ts \ src/serve/acp-http/transport.test.ts \ src/serve/acp-http/dispatch-error.test.ts \ + src/config/config.test.ts \ src/serve/multi-workspace-sessions.test.ts \ src/serve/server/error-response.test.ts \ src/serve/live/live-task-service.test.ts \ From 7b35a296d759c34c0ca02dbf4ec4334c68ecf1f3 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 20:29:30 +0800 Subject: [PATCH 19/29] fix(core): narrow the session id case resolver's occupancy arms Three shapes were classified as permanent occupancy, regressing paths that loaded or created fine before the resolver replaced the exact-existence check: - Candidates were enumerated by case-insensitive filename match without the pattern gate that getSessionLocation applies, so an agent-suffixed id (which the CLI admits and writes under the raw session id) resolved to an unreadable-head conflict. Skip names the classifier would reject. - On a case-insensitive filesystem every spelling opens the same physical transcript, so a readable copy plus a torn case twin reported two readable candidates and raised a conflict for a session with one loadable copy. Collapse spellings that share a device/inode and resolve to the one whose own directory entry backs the file. - An unreadable head under the requested spelling itself is a case-only twin of nothing, yet it refused the id with no listing entry to delete or unarchive. Report it absent, matching getSessionLocation, so a first run that crashed before its first record can reuse its own 0-byte transcript. The twin-minting protection still applies when the persisted spelling differs from the requested one, and genuinely distinct readable spellings still conflict. Also pin the explicit-standalone child branch's sourceId guard and its documented lineage behaviour, which no case covered. --- .../conversations/session-source.test.ts | 33 +++++++ .../core/src/services/sessionService.test.ts | 85 +++++++++++++++++++ packages/core/src/services/sessionService.ts | 75 ++++++++++++++-- 3 files changed, 187 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/serve/conversations/session-source.test.ts index 8c085be516a..0d8358bee77 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/serve/conversations/session-source.test.ts @@ -32,6 +32,10 @@ const CYCLE_B_ID = '550e8400-e29b-41d4-a716-44665544000d'; const LEGACY_CHILD_OF_EXPLICIT_ID = '550e8400-e29b-41d4-a716-44665544000e'; const SELF_STANDALONE_ID = '550e8400-e29b-41d4-a716-44665544000f'; const MALFORMED_PARENT_STANDALONE_ID = '550e8400-e29b-41d4-a716-446655440010'; +const ATTRIBUTED_STANDALONE_CHILD_ID = '550e8400-e29b-41d4-a716-446655440011'; +const STANDALONE_CHILD_OF_LIVE_ID = '550e8400-e29b-41d4-a716-446655440012'; +const STANDALONE_CYCLE_A_ID = '550e8400-e29b-41d4-a716-446655440013'; +const STANDALONE_CYCLE_B_ID = '550e8400-e29b-41d4-a716-446655440014'; function createStore( records: ReadonlyMap, @@ -82,6 +86,31 @@ describe('conversation session source classification', () => { MALFORMED_PARENT_STANDALONE_ID, { sourceType: 'standalone', parentSessionId: 'not-a-session-id' }, ], + // A forged `sourceId` must not ride the explicit-standalone child + // shortcut, which is reached before the legacy source-pairing guard. + [ + ATTRIBUTED_STANDALONE_CHILD_ID, + { + sourceType: 'standalone', + sourceId: 'realtime_voice:forged-worker', + parentSessionId: EXPLICIT_ID, + }, + ], + // An explicit standalone child is self-describing: its reserved source + // decides the kind, so the parent's own source and continued existence + // are deliberately not consulted (depth-1 is enforced at creation). + [ + STANDALONE_CHILD_OF_LIVE_ID, + { sourceType: 'standalone', parentSessionId: LIVE_ID }, + ], + [ + STANDALONE_CYCLE_A_ID, + { sourceType: 'standalone', parentSessionId: STANDALONE_CYCLE_B_ID }, + ], + [ + STANDALONE_CYCLE_B_ID, + { sourceType: 'standalone', parentSessionId: STANDALONE_CYCLE_A_ID }, + ], ]); const store = createStore(records); @@ -123,6 +152,9 @@ describe('conversation session source classification', () => { [EXPLICIT_ID, 'standalone', 'explicit'], [EXPLICIT_CHILD_ID, 'standalone', 'explicit'], [LEGACY_CHILD_OF_EXPLICIT_ID, 'standalone', 'legacy'], + // The reserved source decides these without reading the parent. + [STANDALONE_CHILD_OF_LIVE_ID, 'standalone', 'explicit'], + [STANDALONE_CYCLE_A_ID, 'standalone', 'explicit'], ] as const)( 'classifies %s as %s %s', async (sessionId, kind, persistence) => { @@ -140,6 +172,7 @@ describe('conversation session source classification', () => { SELF_ID, SELF_STANDALONE_ID, MALFORMED_PARENT_STANDALONE_ID, + ATTRIBUTED_STANDALONE_CHILD_ID, CYCLE_A_ID, ])('rejects malformed or ambiguous lineage for %s', async (sessionId) => { await expect( diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 71687b65ce8..a07ceaeb177 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2653,6 +2653,91 @@ describe('SessionService', () => { sessionService.findSessionIdIgnoringCase(sessionIdA), ).resolves.toBeUndefined(); }); + + it('reports the requested spelling absent when its own transcript head is unreadable', async () => { + // A first run that crashed before its first record leaves a 0-byte + // transcript under the requested spelling. It is a case-only twin of + // nothing, so reusing the id must stay possible — `getSessionLocation` + // already reports the file as nonexistent. + readdirSyncSpy + .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) + .mockReturnValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + undefined, + ); + existsSyncSpy.mockReturnValue(true); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBeUndefined(); + }); + + it('ignores persisted names getSessionLocation cannot classify', async () => { + // Agent-suffixed ids are admitted by the CLI and written under the raw + // session id, but SESSION_FILE_PATTERN excludes them — enumerating them + // here would report a healthy transcript as occupied-but-unreadable. + const agentSessionId = `${sessionIdA}-agent-foo`; + readdirSyncSpy + .mockReturnValueOnce([`${agentSessionId}.jsonl`] as never) + .mockReturnValueOnce([] as never); + const getLocation = vi.spyOn(sessionService, 'getSessionLocation'); + existsSyncSpy.mockReturnValue(true); + + await expect( + sessionService.findSessionIdIgnoringCase(agentSessionId), + ).resolves.toBeUndefined(); + expect(getLocation).not.toHaveBeenCalled(); + }); + + it('collapses case-variant spellings that alias one physical transcript', async () => { + // On a case-insensitive filesystem both spellings open the same file, so + // each reports a readable location even though only one copy exists. + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) + .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + 'active', + ); + statSyncSpy.mockReturnValue({ + dev: 1, + ino: 42, + isFile: () => true, + } as fs.Stats); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBe(sessionIdA); + }); + + it('still rejects two readable spellings backed by distinct files', async () => { + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([ + `${sessionIdA}.jsonl`, + `${legacySessionId}.jsonl`, + ] as never) + .mockReturnValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + 'active', + ); + statSyncSpy.mockImplementation( + (filePath: fs.PathLike) => + ({ + dev: 1, + ino: String(filePath).includes(legacySessionId) ? 43 : 42, + isFile: () => true, + }) as fs.Stats, + ); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + candidateSessionId: undefined, + }); + }); }); describe('loadLastSession', () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 22bde1ba4ac..136875bb599 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -770,6 +770,10 @@ export class SessionService { } for (const fileName of fileNames) { if (fileName.toLowerCase() !== expectedFileName) continue; + // `getSessionLocation` classifies only pattern-matching names, so a + // name it would reject (agent-suffixed ids) must not be enumerated + // here either — otherwise it reads back as occupied-but-unreadable. + if (!SESSION_FILE_PATTERN.test(fileName)) continue; const candidateSessionId = fileName.slice(0, -'.jsonl'.length); const states = candidates.get(candidateSessionId) ?? new Set(); states.add(state); @@ -781,15 +785,20 @@ export class SessionService { // whose head recovers no records (crash-mid-append tear, foreign // project) still occupies the id, but does not make a loadable // session conflict with one. - const readable: string[] = []; + const readable: Array<{ + candidateSessionId: string; + state: SessionArchiveState; + }> = []; for (const candidateSessionId of candidates.keys()) { const location = await this.getSessionLocation(candidateSessionId); if (location === 'conflict') { throw new SessionIdCaseConflictError(sessionId, candidateSessionId); } - if (location !== undefined) readable.push(candidateSessionId); + if (location !== undefined) { + readable.push({ candidateSessionId, state: location }); + } } - if (readable.length === 1) return readable[0]; + if (readable.length === 1) return readable[0].candidateSessionId; if (readable.length === 0) { // Every enumerated file failed content validation: files still on // disk occupy the id (admission must not mint a case-only twin); @@ -809,6 +818,15 @@ export class SessionService { 'unreadable_transcript', ); } + // On a case-insensitive filesystem every spelling opens the same + // physical transcript, so several spellings can each report a readable + // location while only one file exists. Collapse those aliases before + // calling it a conflict. + const aliased = this.resolveAliasedReadableCandidate( + readable, + candidates, + ); + if (aliased !== undefined) return aliased; throw new SessionIdCaseConflictError(sessionId); } const candidate = candidates.entries().next().value; @@ -822,9 +840,15 @@ export class SessionService { throw new SessionIdCaseConflictError(sessionId, candidateSessionId); } if (location !== undefined) return candidateSessionId; - // The head recovered no records: a file still on disk occupies the id - // (admission must not mint a case-only twin of it); one that raced - // away mid-resolution is genuinely absent. + // The head recovered no records. Only a *different* persisted spelling + // occupies the id, because minting the requested spelling beside it would + // create the case-only twin that makes both permanently unrestorable. The + // requested spelling is a twin of nothing: reporting it absent is how a + // first run that crashed before its first record resumes its own 0-byte + // transcript, and it keeps this resolver consistent with + // `getSessionLocation`, which already calls that file nonexistent. + if (candidateSessionId === sessionId) return undefined; + // A file that raced away mid-resolution is genuinely absent. for (const state of states) { if (fs.existsSync(this.getSessionFilePath(candidateSessionId, state))) { throw new SessionIdCaseConflictError( @@ -837,6 +861,45 @@ export class SessionService { return undefined; } + /** + * Collapses readable candidates that are case-variant spellings of one + * physical transcript, as happens on case-insensitive filesystems where + * every spelling opens the same file. Returns the spelling whose own + * directory entry backs that file, or undefined when the candidates are + * genuinely distinct transcripts (a real conflict). + */ + private resolveAliasedReadableCandidate( + readable: Array<{ + candidateSessionId: string; + state: SessionArchiveState; + }>, + candidates: Map>, + ): string | undefined { + const identities = new Set(); + const owners: string[] = []; + for (const { candidateSessionId, state } of readable) { + let stats; + try { + stats = fs.statSync(this.getSessionFilePath(candidateSessionId, state)); + } catch { + return undefined; + } + // Without a device/inode pair there is no proof the spellings alias one + // file, so fall back to reporting a conflict. + if (typeof stats.dev !== 'number' || typeof stats.ino !== 'number') { + return undefined; + } + identities.add(`${stats.dev}:${stats.ino}`); + if (identities.size > 1) return undefined; + // The readable state was reached through a case-folded path unless this + // spelling is itself a directory entry of that state. + if (candidates.get(candidateSessionId)?.has(state)) { + owners.push(candidateSessionId); + } + } + return owners.length === 1 ? owners[0] : undefined; + } + private removeFileIfExists(filePath: string): void { try { fs.unlinkSync(filePath); From 82950111dffcd64e9ec7b2e56667680a0d290f0e Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 21:53:35 +0800 Subject: [PATCH 20/29] fix(cli): key the private conversation directory on the canonical id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore derived the directory hash from the persisted spelling while the seven other materialize/discard call sites derived it from the lowercased live id, so restoring a legacy mixed-case transcript produced one directory and every later Live or task call produced a second, empty one — either orphaning what the first held or failing the call because the session sat outside its isolated directory. Rollback then inspected the other hash and leaked the first directory permanently. The directory belongs to the live entry, which the bridge registers under the canonical id alongside the lifecycle locks and in-flight maps, so both restore paths now derive it from that id too. This also keeps directories that pre-date the change reachable: restore used the lowercased request id before, so every one already on disk is canonical-keyed. Storage-facing operations — transcript filenames, metadata reads and the ACP child's own session storage — keep the authoritative spelling. The design doc and PR2 plan are corrected to scope the spelling rule accordingly. --- docs/design/standalone-daemon-sessions.md | 4 +- docs/plans/2026-08-14-standalone-pr2-core.md | 10 ++--- packages/cli/src/serve/acp-http/dispatch.ts | 7 +++- .../cli/src/serve/acp-http/transport.test.ts | 7 +++- .../serve/multi-workspace-sessions.test.ts | 42 +++++++++++++++++++ packages/cli/src/serve/routes/session.ts | 6 ++- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/design/standalone-daemon-sessions.md b/docs/design/standalone-daemon-sessions.md index bfa1c4bd62d..53bfdba3ea6 100644 --- a/docs/design/standalone-daemon-sessions.md +++ b/docs/design/standalone-daemon-sessions.md @@ -544,7 +544,9 @@ convenience method may omit it only if the SDK generates the UUID before sending the request. Wire IDs, lifecycle locks, and in-flight maps use lowercase canonical UUIDs. For compatibility with legacy transcripts whose filename contains a mixed-case UUID, storage and ACP operations preserve that -authoritative spelling, including the private-directory hash. If more than one +authoritative spelling. The private-directory hash is not one of them: the +directory belongs to the live entry, so it is derived from the canonical UUID +that every materialize and discard call site already uses. If more than one persisted spelling maps to the same canonical UUID, exact lookup fails with a conflict and listing excludes the ambiguous entries; the daemon never chooses one by filesystem enumeration order. The daemon fixes `sessionScope` to diff --git a/docs/plans/2026-08-14-standalone-pr2-core.md b/docs/plans/2026-08-14-standalone-pr2-core.md index cd62d8709f8..c9d93e7c7e8 100644 --- a/docs/plans/2026-08-14-standalone-pr2-core.md +++ b/docs/plans/2026-08-14-standalone-pr2-core.md @@ -120,7 +120,7 @@ interface ConversationSessionMetadataStore { PR2A保留现有`readLoadableLiveConversationMetadata()`导出作为薄兼容adapter,改为接收同一个existence-aware store并复用新reader的分类结果,但对现有Live与legacy projectless caller返回PR2前的metadata shape:legacy不能在这一子PR提前被改写成ACP看到的normalized standalone source。它也不能让explicit standalone穿过generic REST/ACP restore。这样PR2A只提供可审查的分类primitive和reserved-source gate,不在daemon preflight/service存在前部分激活containment。PR2B再把explicit standalone cold restore以及generic legacy standalone兼容恢复迁移到service:generic REST/ACP只调用`restoreLegacyForCompatibility()`窄入口,该入口在任何materialize/bridge调用前重读并要求`kind: "standalone"`且`persistence: "legacy"`,并从此处开始把legacy source归一化为ACP所见的standalone;explicit standalone仍只允许dedicated service consumer。Live和legacy-Live-child继续走Live adapter。若grep确认旧adapter只剩Live consumer则收窄为Live-only或删除无调用导出,不同时维护两套分类规则。 -Generic legacy restore在调用reader前也必须通过唯一case-insensitive resolver把canonical caller ID解析为authoritative storage ID。Archive/lifecycle admission与daemon bridge的live entry key继续使用canonical ID;metadata、ACP child Config/session storage和conversation-directory hash使用storage spelling。这样现有mixed-case transcript不会先被legacy route绑定到lowercase hash目录、再被PR2B service切换到另一目录,同时后续owner-routed REST/ACP请求仍能以canonical UUID找到同一bridge entry。仅大小写不同的重复transcript在任何materialize/bridge调用前fail closed。 +Generic legacy restore在调用reader前也必须通过唯一case-insensitive resolver把canonical caller ID解析为authoritative storage ID。Archive/lifecycle admission与daemon bridge的live entry key继续使用canonical ID;metadata与ACP child Config/session storage使用storage spelling;conversation-directory hash继续使用canonical ID,与daemon bridge live entry key以及现有全部materialize/discard调用点保持一致。这样同一session的私有目录不会在restore与后续Live/task调用之间分裂成两个hash,同时后续owner-routed REST/ACP请求仍能以canonical UUID找到同一bridge entry。仅大小写不同的重复transcript在任何materialize/bridge调用前fail closed。 Lineage规则固定为当前daemon支持的depth 1,同时保持父子lifecycle独立: @@ -344,7 +344,7 @@ Service维护process-local `creating: Map`,state只区 Service同时维护`directoryStates: Map`。`pinned`是本daemon ownership lifetime的child identity,合法写入只有三处:new create materialization、daemon启动后该session第一次load/repair安全观察、以及exclusive load/repair证明old child absent后创建的新identity。普通load遇到已有pin必须传给workspace检查;同路径不同inode不能被当成“重新发现”。`agentBound`包含pinned identity、bridge session event epoch和`released` phase:同一runtime generation的managed relocation完成、daemon再次inspect得到同一pinned identity后先写`released: false`,只有child release确认成功才原子提升为`released: true`。所有reuse和cwd preflight只接受true;failure/unknown在close/quarantine前先清record。复用时重读`getSessionEventEpoch(canonicalSessionId)`,因此ACP channel/session重建不会误用旧bound。Cold session、epoch变化或pin替换都使它无效。PR3接入archive时保留pin但清除agentBound,clean rollback或PR3 delete确认child absent后才清除整个state;PR3也负责deletion journal恢复时的更新。ACP Session内的turn guard保留独立副本作为child-side defense,不能替代daemon state。 -所有接受session identity的service方法都先执行同一个UUID v1-v5 parser并得到lowercase `canonicalSessionId`;malformed id返回`invalid_request`,map、reservation、lifecycle lock和wire DTO只使用canonical value。新建session的`storageSessionId`和canonical value相同。恢复历史mixed-case transcript时,service通过SessionService的case-insensitive resolver得到文件名中的authoritative `storageSessionId`,并且只在SessionService filename、directory hash和ACP-child Config/session storage操作中使用该原始拼写;daemon bridge的live entry lookup(包括`getSessionEventEpoch`)一律使用canonical ID——`packages/acp-bridge`的`byId.get`是精确匹配且无id归一化,storage拼写会错过canonical key的live entry。这保持现有ACP mixed-case load语义,也不会把老transcript绑定到lowercase重算后的另一个child目录。若active/archived namespace中存在两个仅大小写不同的持久化ID,resolver返回conflict,service fail closed;不依赖`readdir`顺序选择其中一个。 +所有接受session identity的service方法都先执行同一个UUID v1-v5 parser并得到lowercase `canonicalSessionId`;malformed id返回`invalid_request`,map、reservation、lifecycle lock和wire DTO只使用canonical value。新建session的`storageSessionId`和canonical value相同。恢复历史mixed-case transcript时,service通过SessionService的case-insensitive resolver得到文件名中的authoritative `storageSessionId`,并且只在SessionService filename和ACP-child Config/session storage操作中使用该原始拼写;daemon bridge的live entry lookup(包括`getSessionEventEpoch`)一律使用canonical ID——`packages/acp-bridge`的`byId.get`是精确匹配且无id归一化,storage拼写会错过canonical key的live entry。这保持现有ACP mixed-case load语义。私有conversation directory由canonical ID派生,因此restore与后续Live/task调用得到同一个child目录。若active/archived namespace中存在两个仅大小写不同的持久化ID,resolver返回conflict,service fail closed;不依赖`readdir`顺序选择其中一个。 创建步骤: @@ -387,7 +387,7 @@ Reservation仅在success、已证明pre-persistence clean rollback、或未发 Listing复用 `server/session-list.ts` 的全量 persisted snapshot/cache和 live merge,不在 page之后过滤。新增 internal standalone predicate path:先筛选 compatible top-level standalone、排除所有 child/Live/other,再按 `(activityTime, sessionId)`排序分页。Cursor绑定 `archiveState + catalogKind: "standalone"`,不能与 generic metadata cursor互换。`truncated`/abort/liveMergeFailed语义保持现有实现。 -列表对外返回canonical UUID,但service内部record保留storage ID供SessionService filename/directory-hash/ACP-child路由使用(daemon bridge entry lookup使用canonical ID,bridge包内无id归一化);storage ID是non-DTO字段,不能被object spread或error serialization带到响应。同一canonical UUID出现多个storage spelling时不选择或合并,而是记录bounded conflict并从列表排除;exact lookup仍返回409。列表不 probe child directory;工作目录状态只在 create/load/resume/repair/prompt中检查。 +列表对外返回canonical UUID,但service内部record保留storage ID供SessionService filename/ACP-child路由使用(daemon bridge entry lookup与conversation directory hash使用canonical ID,bridge包内无id归一化);storage ID是non-DTO字段,不能被object spread或error serialization带到响应。同一canonical UUID出现多个storage spelling时不选择或合并,而是记录bounded conflict并从列表排除;exact lookup仍返回409。列表不 probe child directory;工作目录状态只在 create/load/resume/repair/prompt中检查。 ### 7. Load、resume 与 repair @@ -479,7 +479,7 @@ PR2A跨到`packages/core`的生产改动只允许`SessionService`既有case-inse 若实现需要修改清单外production文件,先说明对应不变量;无法对应则视为scope leakage。特别是SDK/WebShell/capabilities/scheduled-task routes和archive/delete helpers不属于PR2。 -`SessionService.findSessionIdIgnoringCase()`当前生产consumer只有ACP child `loadSession`、ACP child `resumeSession`和`RequestedSessionIdAdmission`,其中三个入口目前都存在exact lookup bypass。PR2A还会让REST internal restore与ACP HTTP internal restore调用它,并让`loadCliConfig`的caller-supplied `--session-id`/ACP requestedSessionId create admission从exact `sessionExistsInAnyState`改走该resolver(R5-2:stdio ACP路径无daemon reserveCreate,exact检查会漏掉legacy mixed-case占用而物化case-only twin)。修改冲突语义时必须回归这六个consumer:单一mixed-case transcript仍返回authoritative spelling并用同一spelling做SessionService filename/directory-hash/ACP-child操作(daemon bridge entry lookup保持canonical ID);case-only duplicate在四个restore入口都fail closed;global create/restore admission显式识别resolver的duplicate结果并把它视为persisted占用,不能让现有通用catch把它降成retryable unavailable,且错误不泄露路径。所有consumer都必须直接调用唯一resolver,不能先用exact lowercase fast path绕过duplicate检测。若实现新增返回类型而不是typed exception,同一轮必须更新全部consumer,不保留旧的“任选第一个”入口。 +`SessionService.findSessionIdIgnoringCase()`当前生产consumer只有ACP child `loadSession`、ACP child `resumeSession`和`RequestedSessionIdAdmission`,其中三个入口目前都存在exact lookup bypass。PR2A还会让REST internal restore与ACP HTTP internal restore调用它,并让`loadCliConfig`的caller-supplied `--session-id`/ACP requestedSessionId create admission从exact `sessionExistsInAnyState`改走该resolver(R5-2:stdio ACP路径无daemon reserveCreate,exact检查会漏掉legacy mixed-case占用而物化case-only twin)。修改冲突语义时必须回归这六个consumer:单一mixed-case transcript仍返回authoritative spelling并用同一spelling做SessionService filename/ACP-child操作(daemon bridge entry lookup与conversation directory hash保持canonical ID);case-only duplicate在四个restore入口都fail closed;global create/restore admission显式识别resolver的duplicate结果并把它视为persisted占用,不能让现有通用catch把它降成retryable unavailable,且错误不泄露路径。所有consumer都必须直接调用唯一resolver,不能先用exact lowercase fast path绕过duplicate检测。若实现新增返回类型而不是typed exception,同一轮必须更新全部consumer,不保留旧的“任选第一个”入口。 ## Structured errors @@ -509,7 +509,7 @@ ACP relocation warning与filesystem error message也不能原样进入standalone - Source矩阵:explicit standalone、legacy none/default、exact Live、empty Live id、standalone with sourceId、other source、top-level/child/grandchild/self/cycle;explicit child在parent active/archived/deleted时仍独立分类,legacy orphan不猜测;新reader标记explicit/legacy,旧adapter允许Live与legacy但拒绝explicit standalone。 - Generic REST与ACP create/restore在任何bridge/admission调用前拒绝explicit standalone;legacy restore仍保持PR2前metadata shape和行为,Live reserved gate回归不变。 -- Mixed-case restore:单一legacy storage ID在REST、ACP HTTP和ACP child load/resume中保留storage spelling用于metadata、ACP child持久化与directory hash,同时daemon bridge live key保持canonical;lowercase exact与uppercase twin并存时四个入口都在materialize/bridge前返回conflict;global admission仍视为persisted占用。 +- Mixed-case restore:单一legacy storage ID在REST、ACP HTTP和ACP child load/resume中保留storage spelling用于metadata与ACP child持久化,同时daemon bridge live key与conversation directory hash保持canonical;lowercase exact与uppercase twin并存时四个入口都在materialize/bridge前返回conflict;global admission仍视为persisted占用。 - Root/child:new、valid empty reuse、non-empty conflict、missing recreate、symlink、wrong owner/mode、file、nested、root replacement、child inode replacement、TOCTOU revalidation(含child捕获后root swap窗口的fs-interception pin)与并发create EEXIST raced re-inspection;junction与Windows case/canonical行为在PR2A的可运行平台矩阵下无法验证,该项作为已知未覆盖项推迟,不在本PR宣称覆盖(libuv在lstat下把junction报告为symlink,风险主要剩win32 case-fold比较分支);standalone失败路径不调用目录删除,保留empty child可由同UUID重试复用,Live现有empty cleanup行为不变。 ### PR2B service tests diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index c4b08d0ea76..500d32283bf 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -1901,9 +1901,14 @@ export class AcpDispatcher { ) { throw new SessionNotFoundError(sessionId); } + // The private directory belongs to the live entry, which the + // bridge registers under the canonical id, so every other + // materialize/discard call site keys it the same way. Hashing + // the persisted spelling here would strand a restored + // mixed-case session in a directory no later call can find. const liveConversationCwd = this.liveSessionIsolation ? await this.liveSessionIsolation.materializeConversationDirectory( - storageSessionId, + sessionId, ) : undefined; assertGenerationOpen?.(); diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index aa51c973262..b58b80491c1 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -5219,11 +5219,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.loadRequests).toContainEqual( expect.objectContaining({ sessionId }), ); - expect(materializeConversationDirectory).toHaveBeenCalledWith( + // The private directory follows the live entry, so it is keyed on the + // canonical id even though storage keeps the mixed-case spelling. + expect(materializeConversationDirectory).toHaveBeenCalledWith(sessionId); + expect(materializeConversationDirectory).not.toHaveBeenCalledWith( storageSessionId, ); expect(changeSessionCwd).toHaveBeenCalledWith(sessionId, { - path: `/live/conversation-${storageSessionId}`, + path: `/live/conversation-${sessionId}`, allowedRoots: [TEST_WORKSPACE], managedRelocation: 'live-conversation', }); diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 5b292bd4a46..4d7b4457a14 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -1982,6 +1982,48 @@ describe('multi-workspace session dispatch', () => { ); }); + it('keeps the private directory canonical when restoring a mixed-case transcript', async () => { + const storageSessionId = LIVE_PROJECTLESS_TASK_ID.toUpperCase(); + await withStoredProjectlessLiveTasks( + [storageSessionId], + async (runtimeDir) => { + const materializeConversationDirectory = vi.fn( + async (sessionId: string) => + path.join(SECONDARY_CWD, `conversation-${sessionId}`), + ); + const { app } = makeHarness({ + secondaryProvenance: 'live-conversation', + secondaryChangeSessionCwdImpl: async (sessionId, req) => ({ + sessionId, + previousCwd: SECONDARY_CWD, + newCwd: req.path, + warnings: [], + }), + liveConversationWorkspace: { + materializeConversationDirectory, + } as unknown as ConversationWorkspace, + secondaryRuntimeBaseDir: runtimeDir, + }); + + const response = await request(app) + .post(`/session/${LIVE_PROJECTLESS_TASK_ID}/load`) + .set('Host', host()) + .send({ cwd: SECONDARY_CWD }); + + expect(response.status).toBe(200); + // Storage keeps the persisted spelling, but the directory follows the + // live entry the bridge registers under the canonical id — otherwise a + // later Live call would materialize a second, empty directory. + expect(materializeConversationDirectory).toHaveBeenCalledWith( + LIVE_PROJECTLESS_TASK_ID, + ); + expect(materializeConversationDirectory).not.toHaveBeenCalledWith( + storageSessionId, + ); + }, + ); + }); + it('rejects an unqualified batch mutation when a Live session id collides with an ordinary transcript', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440110'; diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 695df14729f..b4503b89b28 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -3091,7 +3091,11 @@ export function registerSessionRoutes( if (!materialize) { throw new Error('Live conversation workspace is unavailable.'); } - liveConversationCwd = await materialize(restoredStorageSessionId); + // Keyed on the canonical id, not the persisted spelling: the + // bridge registers the live entry under the canonical id, and + // every later materialize/discard call derives the directory + // from that same id. + liveConversationCwd = await materialize(sessionId); } assertRuntimeGenerationOpen?.(); const restored = From 04ac635de5c90bfb438f6fee311e002b3bd1ca23 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 22:41:57 +0800 Subject: [PATCH 21/29] test(cli): follow the canonical private-directory key in the Live restore case The internal-restore case pinned the directory hash to the persisted spelling, which the canonical-key change inverted. It now asserts the canonical id for the directory and bridge cwd, and keeps the original intent explicit by asserting that the creation-metadata read still uses the persisted spelling. --- packages/cli/src/serve/server.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 29cf0ed1dbe..d34f256841e 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -31331,7 +31331,7 @@ describe('Live conversation runtime lifecycle', () => { } }); - it('uses authoritative persisted spelling for internal restore and rejects case conflicts', async () => { + it('reads the authoritative persisted spelling for internal restore while keeping the private directory canonical, and rejects case conflicts', async () => { const setup = setupLiveRuntime(); setup.registry.add(setup.liveRuntime); const canonicalSessionId = '550e8400-e29b-41d4-a716-446655440000'; @@ -31365,11 +31365,19 @@ describe('Live conversation runtime lifecycle', () => { ); expect( setup.conversationWorkspace.materializeConversationDirectory, - ).toHaveBeenCalledWith(storageSessionId); + ).toHaveBeenCalledWith(canonicalSessionId); + expect( + setup.conversationWorkspace.materializeConversationDirectory, + ).not.toHaveBeenCalledWith(storageSessionId); expect(setup.liveBridge.changeSessionCwdCalls).toContainEqual({ sessionId: canonicalSessionId, - path: `${setup.root.canonicalRoot}/conversation-${storageSessionId}`, + path: `${setup.root.canonicalRoot}/conversation-${canonicalSessionId}`, }); + // Storage-facing reads still follow the persisted spelling. + expect(readCreationMetadataIfReadable).toHaveBeenCalledWith( + storageSessionId, + 'active', + ); findSessionId.mockRejectedValueOnce( new SessionIdCaseConflictError(canonicalSessionId), From f1fc25cd96c90ad088787b16a5e2579ac4658ef9 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 19 Aug 2026 23:54:03 +0800 Subject: [PATCH 22/29] fix(cli): report proven parent lineage from the loadable-session reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported reader returned one verdict for two different situations: a child whose parent lineage it had verified, and an explicit standalone child whose parent it never read. PR2B is being built on that reader, so the ambiguity mattered even though no caller was affected yet. The verdict now carries the parent's own classification. An explicit standalone child whose parent is still readable must have a standalone top-level parent, which also rejects a grandchild or a lineage cycle because neither parent classifies as top-level. A parent that has been archived away or deleted keeps the child loadable — it is self-describing, and its own transcript is the evidence that a valid parent existed when it was created — but `parentSource` is then absent, so a caller that needs proven lineage rejects on that rather than guessing from `kind`. The compatibility adapter reads the new field instead of re-reading the store to re-derive the same classification, so its behaviour is unchanged while a duplicated location lookup and metadata read disappear from every legacy standalone child restore. Adapter output is identical for every input: explicit standalone was already filtered out before the parent check, so no current caller changes behaviour. --- .../conversations/session-source.test.ts | 51 +++++++++++++++--- .../src/serve/conversations/session-source.ts | 53 +++++++++++++++---- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/serve/conversations/session-source.test.ts index 0d8358bee77..f1e5ca33caa 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/serve/conversations/session-source.test.ts @@ -36,6 +36,7 @@ const ATTRIBUTED_STANDALONE_CHILD_ID = '550e8400-e29b-41d4-a716-446655440011'; const STANDALONE_CHILD_OF_LIVE_ID = '550e8400-e29b-41d4-a716-446655440012'; const STANDALONE_CYCLE_A_ID = '550e8400-e29b-41d4-a716-446655440013'; const STANDALONE_CYCLE_B_ID = '550e8400-e29b-41d4-a716-446655440014'; +const STANDALONE_CHILD_OF_LEGACY_ID = '550e8400-e29b-41d4-a716-446655440015'; function createStore( records: ReadonlyMap, @@ -96,9 +97,8 @@ describe('conversation session source classification', () => { parentSessionId: EXPLICIT_ID, }, ], - // An explicit standalone child is self-describing: its reserved source - // decides the kind, so the parent's own source and continued existence - // are deliberately not consulted (depth-1 is enforced at creation). + // An explicit standalone child is self-describing, but a parent that is + // still readable and contradicts depth-1 standalone lineage disqualifies it. [ STANDALONE_CHILD_OF_LIVE_ID, { sourceType: 'standalone', parentSessionId: LIVE_ID }, @@ -111,6 +111,10 @@ describe('conversation session source classification', () => { STANDALONE_CYCLE_B_ID, { sourceType: 'standalone', parentSessionId: STANDALONE_CYCLE_A_ID }, ], + [ + STANDALONE_CHILD_OF_LEGACY_ID, + { sourceType: 'standalone', parentSessionId: LEGACY_ID }, + ], ]); const store = createStore(records); @@ -152,9 +156,7 @@ describe('conversation session source classification', () => { [EXPLICIT_ID, 'standalone', 'explicit'], [EXPLICIT_CHILD_ID, 'standalone', 'explicit'], [LEGACY_CHILD_OF_EXPLICIT_ID, 'standalone', 'legacy'], - // The reserved source decides these without reading the parent. - [STANDALONE_CHILD_OF_LIVE_ID, 'standalone', 'explicit'], - [STANDALONE_CYCLE_A_ID, 'standalone', 'explicit'], + [STANDALONE_CHILD_OF_LEGACY_ID, 'standalone', 'explicit'], ] as const)( 'classifies %s as %s %s', async (sessionId, kind, persistence) => { @@ -164,6 +166,39 @@ describe('conversation session source classification', () => { }, ); + it.each([ + // Parent readable and classified: lineage is proven. + [LEGACY_CHILD_ID, { kind: 'standalone', persistence: 'legacy' }], + [ + LEGACY_CHILD_OF_EXPLICIT_ID, + { kind: 'standalone', persistence: 'explicit' }, + ], + [LIVE_CHILD_ID, { kind: 'live', persistence: 'explicit' }], + [ + STANDALONE_CHILD_OF_LEGACY_ID, + { kind: 'standalone', persistence: 'legacy' }, + ], + ] as const)( + 'reports the proven parent lineage for %s', + async (sessionId, lineage) => { + await expect( + readLoadableConversationSession(sessionId, store), + ).resolves.toMatchObject({ parentSource: lineage }); + }, + ); + + it.each([EXPLICIT_ID, LEGACY_ID, LIVE_ID, EXPLICIT_CHILD_ID])( + 'leaves the parent lineage unproven for %s', + async (sessionId) => { + // Top-level sessions have no parent, and an explicit standalone child + // whose parent was archived away or deleted cannot produce one. Callers + // that need proven lineage must reject on this rather than on `kind`. + const result = await readLoadableConversationSession(sessionId, store); + expect(result).toBeDefined(); + expect(result?.parentSource).toBeUndefined(); + }, + ); + it.each([ ORPHAN_ID, GRANDCHILD_ID, @@ -174,6 +209,10 @@ describe('conversation session source classification', () => { MALFORMED_PARENT_STANDALONE_ID, ATTRIBUTED_STANDALONE_CHILD_ID, CYCLE_A_ID, + // Readable parent contradicting depth-1 standalone lineage. + STANDALONE_CHILD_OF_LIVE_ID, + STANDALONE_CYCLE_A_ID, + STANDALONE_CYCLE_B_ID, ])('rejects malformed or ambiguous lineage for %s', async (sessionId) => { await expect( readLoadableConversationSession(sessionId, store), diff --git a/packages/cli/src/serve/conversations/session-source.ts b/packages/cli/src/serve/conversations/session-source.ts index 0a1d1e9dad1..cae870144a0 100644 --- a/packages/cli/src/serve/conversations/session-source.ts +++ b/packages/cli/src/serve/conversations/session-source.ts @@ -30,9 +30,24 @@ export interface ConversationSessionMetadataStore { export type ConversationSessionKind = 'live' | 'standalone'; +export interface ConversationSessionLineage { + kind: ConversationSessionKind; + persistence: 'explicit' | 'legacy'; +} + export interface LoadableConversationSession { kind: ConversationSessionKind; persistence: 'explicit' | 'legacy'; + /** + * Classification of the persisted parent, set only when this session has a + * parent and that parent is still readable and classifies as top-level. + * Undefined for a top-level session, and for an explicit standalone child + * whose parent has been archived away or deleted: that child is + * self-describing, so a parent it can no longer produce is not evidence + * against it. Callers that require proven lineage must check this rather + * than infer it from `kind`. + */ + parentSource?: ConversationSessionLineage; metadata: LiveSessionCreationMetadata; } @@ -132,7 +147,26 @@ export async function readLoadableConversationSession( isReservedStandaloneSessionSource(metadata) && metadata.sourceId === undefined ) { - return { kind: 'standalone', persistence: 'explicit', metadata }; + // Self-describing: the reserved source decides the kind, so a parent that + // is gone cannot disqualify the child. A parent that is still readable and + // contradicts depth-1 standalone lineage can — which also rejects a + // grandchild or a lineage cycle, because neither parent classifies as + // top-level. + const parent = await readExistingMetadata(parentSessionId, store); + if (parent === undefined) { + return { kind: 'standalone', persistence: 'explicit', metadata }; + } + const parentSource = classifyTopLevelConversationSource(parent); + if (parentSource?.kind !== 'standalone') return undefined; + return { + kind: 'standalone', + persistence: 'explicit', + parentSource: { + kind: parentSource.kind, + persistence: parentSource.persistence, + }, + metadata, + }; } if (metadata.sourceType !== undefined || metadata.sourceId !== undefined) { @@ -146,6 +180,10 @@ export async function readLoadableConversationSession( return { kind: parentSource.kind, persistence: 'legacy', + parentSource: { + kind: parentSource.kind, + persistence: parentSource.persistence, + }, metadata, }; } @@ -165,16 +203,11 @@ export async function readLoadableLiveConversationMetadata( result.kind === 'standalone' && result.metadata.parentSessionId !== undefined ) { - const parent = await readExistingMetadata( - result.metadata.parentSessionId, - store, - ); - const parentSource = parent - ? classifyTopLevelConversationSource(parent) - : undefined; + // The general reader already classified the parent; re-reading the store + // here would only duplicate that work. if ( - parentSource?.kind !== 'standalone' || - parentSource.persistence !== 'legacy' + result.parentSource?.kind !== 'standalone' || + result.parentSource.persistence !== 'legacy' ) { return undefined; } From 8a9e10c7cefa581b56c0bb6f1cdb5fea8c25f64d Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 00:01:24 +0800 Subject: [PATCH 23/29] fix(core): stop the alias resolver from turning I/O and missing inodes into conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the case-variant collapse added earlier in this branch. The stat guard was statically dead: `statSync` without `{ bigint: true }` always returns numbers, so the `typeof` test could never fire. The hazard it was meant to cover is a filesystem that exposes no inodes — FAT/exFAT and some SMB mounts report `ino === 0` for every file — where `dev:ino` collapses genuinely distinct transcripts onto one identity and a real two-transcript conflict silently resolves to one spelling. That is a fail-open on a correctness decision, so it now uses the existing `hasVerifiableInode()` helper, whose docblock describes exactly this case. The resolver also swallowed every `statSync` failure into `undefined`, which its caller reads as positive proof of a conflict. A transient EACCES or EMFILE therefore surfaced as `409 session_conflict` — a permanent-looking answer for a blip that succeeds on retry. Only ENOENT is now treated as meaningful: a transcript that raced away is no longer a competing spelling. Every other error propagates. --- .../core/src/services/sessionService.test.ts | 68 +++++++++++++++++++ packages/core/src/services/sessionService.ts | 24 ++++--- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index a07ceaeb177..9c7a23de1b9 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2738,6 +2738,74 @@ describe('SessionService', () => { candidateSessionId: undefined, }); }); + + it('reports a conflict rather than collapsing when the filesystem exposes no inode', async () => { + // FAT/exFAT and some SMB mounts report ino 0 for every file, so `dev:ino` + // cannot prove two spellings are one transcript. Without that proof the + // pair must stay a conflict instead of silently resolving to one. + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) + .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + 'active', + ); + statSyncSpy.mockReturnValue({ + dev: 1, + ino: 0, + isFile: () => true, + } as fs.Stats); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + }); + }); + + it('propagates an I/O failure instead of reporting it as a case conflict', async () => { + // A transient EACCES/EMFILE says nothing about aliasing; laundering it + // into `session_conflict` would report a retryable blip as permanent. + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) + .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + 'active', + ); + statSyncSpy.mockImplementation(() => { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + }); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ code: 'EACCES' }); + }); + + it('ignores a candidate whose transcript vanishes mid-resolution', async () => { + // The lowercase entry races away, so only the uppercase spelling is left + // to back the readable state and it resolves without a conflict. + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) + .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockImplementation( + async (id) => (id === legacySessionId ? 'archived' : 'active'), + ); + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + if (String(filePath).includes(`${sessionIdA}.jsonl`)) { + throw Object.assign(new Error('gone'), { code: 'ENOENT' }); + } + return { dev: 1, ino: 7, isFile: () => true } as fs.Stats; + }); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBe(legacySessionId); + }); }); describe('loadLastSession', () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 136875bb599..b17647fabd5 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -31,6 +31,7 @@ import { import { SessionFileHistoryAccumulator } from './session-file-history-state.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { hasVerifiableInode } from '../utils/file-identity.js'; import { readRuntimeStatus } from '../utils/runtimeStatus.js'; import { LITE_READ_BUF_SIZE, @@ -866,7 +867,9 @@ export class SessionService { * physical transcript, as happens on case-insensitive filesystems where * every spelling opens the same file. Returns the spelling whose own * directory entry backs that file, or undefined when the candidates are - * genuinely distinct transcripts (a real conflict). + * genuinely distinct transcripts (a real conflict) or when the filesystem + * cannot prove otherwise. An I/O failure other than a vanished file is not + * evidence of a conflict, so it propagates instead of being reported as one. */ private resolveAliasedReadableCandidate( readable: Array<{ @@ -878,17 +881,20 @@ export class SessionService { const identities = new Set(); const owners: string[] = []; for (const { candidateSessionId, state } of readable) { - let stats; + let stats: fs.Stats; try { stats = fs.statSync(this.getSessionFilePath(candidateSessionId, state)); - } catch { - return undefined; - } - // Without a device/inode pair there is no proof the spellings alias one - // file, so fall back to reporting a conflict. - if (typeof stats.dev !== 'number' || typeof stats.ino !== 'number') { - return undefined; + } catch (error) { + // A transcript that raced away is no longer a competing spelling; any + // other failure says nothing about aliasing and must not be laundered + // into a permanent-looking conflict. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; } + // Filesystems that do not expose inodes report 0 for every file, so + // `dev:ino` would collapse genuinely distinct transcripts onto one + // identity. Without that proof, report a conflict rather than pick one. + if (!hasVerifiableInode(stats.ino)) return undefined; identities.add(`${stats.dev}:${stats.ino}`); if (identities.size > 1) return undefined; // The readable state was reached through a case-folded path unless this From bf3ee64820cdac2dc66530cdeb74027c785f6cea Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 00:09:27 +0800 Subject: [PATCH 24/29] fix(core): let a crashed first run resume its transcript past a case twin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-escape added for an unreadable transcript under the requested spelling only covered the single-candidate arm. Once any case twin was enumerated, resolution took the all-unreadable arm instead, where presence was computed over every candidate including the requested spelling's own file — so the documented crash recovery vanished the moment a stale twin existed, and neither file could be deleted because both classify as nonexistent. Reusing an id whose file is already on disk mints no case-only twin, so that arm now takes the same escape. A twin under a different spelling still occupies the id, because minting the requested spelling beside it is what would make both unrestorable. The disappearance test was vacuous: its candidate spelling equalled the request, so it returned at the self-escape and never reached the race loop it named — deleting that loop left it green. It now uses a differing spelling with `existsSync` false, and forcing the loop to throw unconditionally kills it. The all-unreadable rejection test likewise needed two spellings that are both distinct from the request to exercise twin-minting protection. --- .../core/src/services/sessionService.test.ts | 31 +++++++++++++++++-- packages/core/src/services/sessionService.ts | 12 +++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 9c7a23de1b9..f998f31ed4b 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2549,10 +2549,12 @@ describe('SessionService', () => { }); it('rejects case-only duplicates whose heads are all unreadable as occupying the id', async () => { + // Neither spelling on disk is the requested one, so minting the request + // beside them would add a third case-variant of the same id. readdirSyncSpy .mockReturnValueOnce([ - `${sessionIdA}.jsonl`, `${sessionIdA.toUpperCase()}.jsonl`, + `${sessionIdA.replace('e29b', 'E29b')}.jsonl`, ] as never) .mockReturnValueOnce([] as never); // Neither head recovers records, but both files persist on disk. @@ -2642,12 +2644,37 @@ describe('SessionService', () => { }); it('returns undefined when the matching transcript disappears during resolution', async () => { + // The candidate must differ in case from the request, otherwise the + // self-escape short-circuits and the race loop below it never runs. + const legacySessionId = sessionIdA.toUpperCase(); readdirSyncSpy - .mockReturnValueOnce([`${sessionIdA}.jsonl`] as never) + .mockReturnValueOnce([`${legacySessionId}.jsonl`] as never) .mockReturnValueOnce([] as never); vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( undefined, ); + existsSyncSpy.mockReturnValue(false); + + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).resolves.toBeUndefined(); + }); + + it('reports the requested spelling absent even when an unreadable case twin is enumerated', async () => { + // Both files are unreadable, so this takes the multi-candidate arm. The + // requested spelling already exists, so reusing the id mints no twin and + // the crashed-first-run recovery must survive the twin's presence. + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([ + `${sessionIdA}.jsonl`, + `${legacySessionId}.jsonl`, + ] as never) + .mockReturnValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + undefined, + ); + existsSyncSpy.mockReturnValue(true); await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index b17647fabd5..ad54fb81a8e 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -801,9 +801,15 @@ export class SessionService { } if (readable.length === 1) return readable[0].candidateSessionId; if (readable.length === 0) { - // Every enumerated file failed content validation: files still on - // disk occupy the id (admission must not mint a case-only twin); - // files that raced away mid-resolution are genuinely absent. + // The requested spelling's own transcript is a twin of nothing: reusing + // an id whose file already exists mints no case-only twin, so a first + // run that crashed before its first record still resumes it. Same + // escape as the single-candidate arm below. + if (candidates.has(sessionId)) return undefined; + // Every enumerated file failed content validation: a file still on + // disk under another spelling occupies the id, because minting the + // requested spelling beside it would make both permanently + // unrestorable; files that raced away mid-resolution are absent. let anyPresent = false; for (const [candidateSessionId, states] of candidates) { for (const state of states) { From f8783c994c90f73d9eb72c3a2e95ad99dc85fb59 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 00:24:17 +0800 Subject: [PATCH 25/29] fix(cli): fail closed when a filesystem cannot prove directory identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation-directory checks compared `dev`/`ino` directly, so on a filesystem that exposes no inodes — FAT/exFAT and some SMB mounts, where Node reports `ino === 0` for every entry — every directory compared equal. The root pin, the two anti-swap re-probes around `realpath`, and the expected identity check would all confirm a directory that had in fact been replaced, which is the swap those probes exist to catch. They now require a verifiable inode on both sides before treating a match as proof, reusing the `hasVerifiableInode()` helper already written for this in core and exporting it from the package surface. An unverifiable inode reads as a changed identity rather than as a match. The regression test pins a root whose inode is also 0, so a plain `===` comparison still matches and only the verifiability guard can fail it. --- .../conversation-directory-identity.test.ts | 27 +++++++++++++++ .../utils/conversation-directory-identity.ts | 33 +++++++++++++++++-- packages/core/src/index.ts | 1 + 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/conversation-directory-identity.test.ts b/packages/cli/src/utils/conversation-directory-identity.test.ts index a49fe212682..f0362b337bf 100644 --- a/packages/cli/src/utils/conversation-directory-identity.test.ts +++ b/packages/cli/src/utils/conversation-directory-identity.test.ts @@ -188,4 +188,31 @@ describe('conversation directory identity', () => { vi.mocked(lstat).mockRestore(); } }); + + it('refuses to prove identity on a filesystem that reports no inode', async () => { + // FAT/exFAT and some SMB mounts report ino 0 for every entry, which would + // make every directory compare equal and let a swap pass the anti-swap + // checks unnoticed. An unverifiable inode must read as a changed identity. + // The pinned root carries inode 0 too, so a plain `===` comparison would + // match and this only fails on the verifiability guard itself. + const { root } = await tempRoot(); + const inodelessRoot = { ...root, inode: 0 }; + const realLstat = realFsPromises.lstat; + vi.mocked(lstat).mockImplementation((async (path: string) => { + const stats = (await realLstat(path)) as Stats; + return { + ...stats, + ino: 0, + isDirectory: () => stats.isDirectory(), + isSymbolicLink: () => stats.isSymbolicLink(), + } as Stats; + }) as unknown as typeof lstat); + try { + await expect( + revalidateConversationRootIdentity(inodelessRoot), + ).rejects.toMatchObject({ scope: 'root', reason: 'identity_changed' }); + } finally { + vi.mocked(lstat).mockRestore(); + } + }); }); diff --git a/packages/cli/src/utils/conversation-directory-identity.ts b/packages/cli/src/utils/conversation-directory-identity.ts index c7ea1685519..96c80d87bd3 100644 --- a/packages/cli/src/utils/conversation-directory-identity.ts +++ b/packages/cli/src/utils/conversation-directory-identity.ts @@ -8,6 +8,7 @@ import { createHash } from 'node:crypto'; import type { Stats } from 'node:fs'; import { lstat, mkdir, realpath } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { hasVerifiableInode } from '@qwen-code/qwen-code-core'; export interface ConversationRootIdentity { readonly configuredRoot: string; @@ -103,7 +104,29 @@ function hasRootIdentity( stats: Stats, root: ConversationRootIdentity, ): boolean { - return stats.dev === root.device && stats.ino === root.inode; + return ( + hasVerifiableInode(stats.ino) && + hasVerifiableInode(root.inode) && + stats.dev === root.device && + stats.ino === root.inode + ); +} + +/** + * True iff `before` and `after` are provably the same directory. + * + * These comparisons are the anti-swap checks: they must prove the path was not + * replaced between two probes. A filesystem that reports no inode makes every + * directory compare equal, so an unverifiable inode is treated as a changed + * identity rather than as a match. + */ +function isSameDirectoryIdentity(before: Stats, after: Stats): boolean { + return ( + hasVerifiableInode(before.ino) && + hasVerifiableInode(after.ino) && + before.dev === after.dev && + before.ino === after.ino + ); } function hasExpectedDirectoryIdentity( @@ -111,6 +134,10 @@ function hasExpectedDirectoryIdentity( expected: ConversationDirectoryIdentity, ): boolean { return ( + hasVerifiableInode(identity.inode) && + hasVerifiableInode(expected.inode) && + hasVerifiableInode(identity.root.inode) && + hasVerifiableInode(expected.root.inode) && identity.storageSessionId === expected.storageSessionId && identity.name === expected.name && isSameConversationPath(identity.canonicalPath, expected.canonicalPath) && @@ -162,7 +189,7 @@ export async function createConversationRootIdentity( throwIdentityIoError('root', error); } validateDirectoryStats(after, 'root'); - if (before.dev !== after.dev || before.ino !== after.ino) { + if (!isSameDirectoryIdentity(before, after)) { throw new ConversationDirectoryIdentityError('root', 'identity_changed'); } return { @@ -283,7 +310,7 @@ export async function inspectConversationDirectoryIdentity( ) { throw new ConversationDirectoryIdentityError('child', 'not_direct_child'); } - if (before.dev !== after.dev || before.ino !== after.ino) { + if (!isSameDirectoryIdentity(before, after)) { throw new ConversationDirectoryIdentityError('child', 'identity_changed'); } await revalidateConversationRootIdentity(root); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 05563f72962..2c012f56119 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -544,6 +544,7 @@ export * from './utils/environmentContext.js'; export * from './utils/env.js'; export * from './utils/errorParsing.js'; export * from './utils/errors.js'; +export * from './utils/file-identity.js'; export * from './utils/fileUtils.js'; export * from './utils/filesearch/fileSearch.js'; export * as crawlCache from './utils/filesearch/crawlCache.js'; From ceaf5a9476a54d6c73eb116af7dc375163ddf84a Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 00:28:04 +0800 Subject: [PATCH 26/29] fix(cli): keep caller-supplied session-id admission fail-closed on I/O errors Swapping the existence check for the case-insensitive resolver narrowed the catch to `SessionIdCaseConflictError` and rethrew everything else, but the resolver deliberately propagates non-ENOENT `readdir` and transcript-read failures. An unreadable chats directory therefore killed startup with a raw EACCES or ENOTDIR instead of the guarded message, and bypassed the `throwOnSessionIdConflict` contract the ACP path depends on. The previous check answered "occupied" for any read failure. Restoring that keeps an unprovable id on the guarded path; distinguishing "cannot determine" from "occupied" would be a new response shape and is left alone here. --- packages/cli/src/config/config.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4d45c747347..886b7e26bfe 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -44,7 +44,6 @@ import { type WebSearchSettings, MAX_SUBAGENT_DEPTH_LIMIT, addDaemonRequestAttribute, - SessionIdCaseConflictError, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { hooksCommand } from '../commands/hooks.js'; @@ -2078,12 +2077,12 @@ export async function loadCliConfig( occupied = (await sessionService.findSessionIdIgnoringCase(argv['sessionId'])) !== undefined; - } catch (error) { - if (error instanceof SessionIdCaseConflictError) { - occupied = true; - } else { - throw error; - } + } catch { + // Any read failure leaves the id unproven, and the resolver propagates + // non-ENOENT errors. Assume occupied, as the previous existence check + // did: startup must reach the guarded conflict message and honour + // `throwOnSessionIdConflict` rather than die on a raw errno. + occupied = true; } if (occupied) { const message = `Error: Session Id ${argv['sessionId']} already exists (active or archived). Delete or unarchive it first.`; From f716cc6e8c507acd20d6f59282d9000ffa6ade11 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 00:47:45 +0800 Subject: [PATCH 27/29] fix(cli): keep the directory identity module out of the core package barrel Importing `hasVerifiableInode()` from the core package barrel pulled core's whole module graph into the serve pre-listen bundle closure, so `check:serve-fast-path-bundle` reported glob, chokidar, fzf, @iarna/toml and the core shell tool runtime as statically reachable from `run-qwen-serve`. This module is deliberately dependency-free for that reason. The predicate is restated locally with a comment recording why it is not imported, since core has no subpath export for it. The barrel export added for that import is reverted so the package surface is unchanged. --- .../src/utils/conversation-directory-identity.ts | 16 +++++++++++++++- packages/core/src/index.ts | 1 - 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/conversation-directory-identity.ts b/packages/cli/src/utils/conversation-directory-identity.ts index 96c80d87bd3..fcaa3e15037 100644 --- a/packages/cli/src/utils/conversation-directory-identity.ts +++ b/packages/cli/src/utils/conversation-directory-identity.ts @@ -8,7 +8,21 @@ import { createHash } from 'node:crypto'; import type { Stats } from 'node:fs'; import { lstat, mkdir, realpath } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { hasVerifiableInode } from '@qwen-code/qwen-code-core'; + +/** + * True iff `ino` can be used as proof of file identity. + * + * FAT/exFAT and some SMB-style filesystems do not expose inode numbers and + * report `Stats.ino === 0` for every entry, so comparing by `dev:ino` there + * collapses unrelated directories onto one identity. + * + * This restates core's `hasVerifiableInode()` rather than importing it: this + * module is reachable from the serve pre-listen fast path, and importing the + * core package barrel pulls its whole module graph into that bundle closure. + */ +function hasVerifiableInode(ino: number): boolean { + return Number(ino) !== 0; +} export interface ConversationRootIdentity { readonly configuredRoot: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2c012f56119..05563f72962 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -544,7 +544,6 @@ export * from './utils/environmentContext.js'; export * from './utils/env.js'; export * from './utils/errorParsing.js'; export * from './utils/errors.js'; -export * from './utils/file-identity.js'; export * from './utils/fileUtils.js'; export * from './utils/filesearch/fileSearch.js'; export * as crawlCache from './utils/filesearch/crawlCache.js'; From 49dec69a3beb43dabda5544a7310d3634901fc3e Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 08:06:42 +0800 Subject: [PATCH 28/29] fix: correct three defects introduced by the previous review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The inode guard made a directory fail to equal itself.** `createConversationRootIdentity()` compares `before`/`after` of the same path, so requiring a verifiable inode threw `identity_changed` on the very first root establishment and, because the workspace clears its cached root on failure, Conversations never started on exFAT/FAT or an inode-less SMB mount. "Cannot prove unchanged" is not "changed": the root is now established with `inodeVerifiable: false` recorded, comparisons fall back to device, canonical path and stat shape, and the weaker guarantee is explicit on the identity for callers to surface. Where inodes exist they are still required to match. **The occupancy escape was placed to discard a real twin.** It returned early for the whole arm whenever the requested spelling was enumerated, so a present-but-unreadable twin stopped occupying the id — the case-only twin the surrounding comment exists to prevent. The escape belongs per candidate, not per arm: the requested spelling's own file never counts as occupancy, every other spelling still does. **The private directory was still a caller obligation.** The comment claimed the bridge registers live entries canonically and every materialize derives from that id, but the bridge echoes whatever the caller passed, and `LiveTaskService.ensureResident()` passes an id that originates in a tool argument. `ConversationWorkspace` now canonicalizes before hashing, so one session resolves to one directory by construction. Also unifies the resolver's two arms, which were the same algorithm written twice — that duplication is why the escape landed in only one copy. --- .../conversation-runtime-manager.test.ts | 1 + .../conversations/conversation-workspace.ts | 27 +++- packages/cli/src/serve/server.test.ts | 2 + .../conversation-directory-identity.test.ts | 40 ++++-- .../utils/conversation-directory-identity.ts | 46 ++++--- .../core/src/services/sessionService.test.ts | 34 ++++- packages/core/src/services/sessionService.ts | 123 +++++++----------- 7 files changed, 162 insertions(+), 111 deletions(-) diff --git a/packages/cli/src/serve/conversations/conversation-runtime-manager.test.ts b/packages/cli/src/serve/conversations/conversation-runtime-manager.test.ts index add5a781495..337e6e83b92 100644 --- a/packages/cli/src/serve/conversations/conversation-runtime-manager.test.ts +++ b/packages/cli/src/serve/conversations/conversation-runtime-manager.test.ts @@ -25,6 +25,7 @@ const root = { canonicalRoot: '/work/conversations', device: 1, inode: 2, + inodeVerifiable: true, }; function createBridge() { diff --git a/packages/cli/src/serve/conversations/conversation-workspace.ts b/packages/cli/src/serve/conversations/conversation-workspace.ts index babf6065700..4f748106425 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.ts @@ -17,6 +17,7 @@ import { type ConversationDirectoryIdentity, type ConversationRootIdentity, } from '../../utils/conversation-directory-identity.js'; +import { normalizeSessionIdForLookup } from '../../config/session-id.js'; export type { ConversationRootIdentity } from '../../utils/conversation-directory-identity.js'; @@ -166,11 +167,28 @@ export class ConversationWorkspace { return assertExactConversationRoot(await this.getRoot(), candidate); } + /** + * The private directory is derived from the canonical session id, not from + * whatever spelling a caller happens to hold. + * + * `getConversationDirectoryName()` is a case-sensitive hash, and callers reach + * these methods with a mix of request ids, live-entry ids and ids echoed back + * from tool arguments. Canonicalizing here makes one session resolve to one + * directory by construction instead of leaving it to every call site. + */ + private directoryKey(sessionId: string): string { + return normalizeSessionIdForLookup(sessionId); + } + async materializeConversationDirectory(sessionId: string): Promise { const root = await this.revalidate(); try { - return (await materializeConversationDirectoryIdentity(root, sessionId)) - .identity.canonicalPath; + return ( + await materializeConversationDirectoryIdentity( + root, + this.directoryKey(sessionId), + ) + ).identity.canonicalPath; } catch (error) { liveIdentityError(error); } @@ -180,7 +198,10 @@ export class ConversationWorkspace { const root = await this.revalidate(); let identity: ConversationDirectoryIdentity | undefined; try { - identity = await inspectConversationDirectoryIdentity(root, sessionId); + identity = await inspectConversationDirectoryIdentity( + root, + this.directoryKey(sessionId), + ); } catch (error) { liveIdentityError(error); } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index d34f256841e..fc017a4510c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -30507,6 +30507,7 @@ describe('Live conversation runtime lifecycle', () => { canonicalRoot: string; device: number; inode: number; + inodeVerifiable: boolean; }> = {}, ) { const primaryBridge = fakeBridge(); @@ -30523,6 +30524,7 @@ describe('Live conversation runtime lifecycle', () => { canonicalRoot: '/work/live-conversations', device: 1, inode: 2, + inodeVerifiable: true, ...rootOverrides, }; const conversationWorkspace = { diff --git a/packages/cli/src/utils/conversation-directory-identity.test.ts b/packages/cli/src/utils/conversation-directory-identity.test.ts index f0362b337bf..4882e159368 100644 --- a/packages/cli/src/utils/conversation-directory-identity.test.ts +++ b/packages/cli/src/utils/conversation-directory-identity.test.ts @@ -189,14 +189,14 @@ describe('conversation directory identity', () => { } }); - it('refuses to prove identity on a filesystem that reports no inode', async () => { - // FAT/exFAT and some SMB mounts report ino 0 for every entry, which would - // make every directory compare equal and let a swap pass the anti-swap - // checks unnoticed. An unverifiable inode must read as a changed identity. - // The pinned root carries inode 0 too, so a plain `===` comparison would - // match and this only fails on the verifiability guard itself. - const { root } = await tempRoot(); - const inodelessRoot = { ...root, inode: 0 }; + it('degrades instead of failing on a filesystem that reports no inode', async () => { + // FAT/exFAT and some SMB mounts report ino 0 for every entry. Requiring a + // verifiable inode would make the very first root establishment throw + // `identity_changed` — a directory failing to equal itself — and the + // feature would never start there. "Cannot prove unchanged" is not + // "changed": the root is established with the weaker guarantee recorded. + const { base } = await tempRoot(); + const configuredRoot = join(base, 'Inodeless'); const realLstat = realFsPromises.lstat; vi.mocked(lstat).mockImplementation((async (path: string) => { const stats = (await realLstat(path)) as Stats; @@ -208,11 +208,31 @@ describe('conversation directory identity', () => { } as Stats; }) as unknown as typeof lstat); try { + const root = await createConversationRootIdentity(configuredRoot); + expect(root.inodeVerifiable).toBe(false); await expect( - revalidateConversationRootIdentity(inodelessRoot), - ).rejects.toMatchObject({ scope: 'root', reason: 'identity_changed' }); + revalidateConversationRootIdentity(root), + ).resolves.toMatchObject({ inodeVerifiable: false }); + const created = await materializeConversationDirectoryIdentity( + root, + 'inodeless', + ); + expect(created.identity.name).toBe( + getConversationDirectoryName('inodeless'), + ); } finally { vi.mocked(lstat).mockRestore(); } }); + + it('still requires matching inodes when the filesystem reports them', async () => { + const { root } = await tempRoot(); + expect(root.inodeVerifiable).toBe(true); + await rename(root.configuredRoot, `${root.configuredRoot}-old`); + await mkdir(root.configuredRoot, { mode: 0o700 }); + + await expect( + revalidateConversationRootIdentity(root), + ).rejects.toMatchObject({ scope: 'root', reason: 'identity_changed' }); + }); }); diff --git a/packages/cli/src/utils/conversation-directory-identity.ts b/packages/cli/src/utils/conversation-directory-identity.ts index fcaa3e15037..fbea8090ab3 100644 --- a/packages/cli/src/utils/conversation-directory-identity.ts +++ b/packages/cli/src/utils/conversation-directory-identity.ts @@ -29,6 +29,17 @@ export interface ConversationRootIdentity { readonly canonicalRoot: string; readonly device: number; readonly inode: number; + /** + * False when the hosting filesystem does not expose inode numbers, so + * identity cannot be proven by `dev:ino`. + * + * Comparisons then fall back to device, canonical path and stat shape, which + * cannot detect a same-path replacement. That is a real reduction in + * guarantee, but refusing to establish the root would make Conversations + * permanently unusable on exFAT/FAT and some SMB mounts: "cannot prove + * unchanged" is not "changed". Callers should surface this once per root. + */ + readonly inodeVerifiable: boolean; } export interface ConversationDirectoryIdentity { @@ -118,45 +129,41 @@ function hasRootIdentity( stats: Stats, root: ConversationRootIdentity, ): boolean { + if (!root.inodeVerifiable) return stats.dev === root.device; return ( hasVerifiableInode(stats.ino) && - hasVerifiableInode(root.inode) && stats.dev === root.device && stats.ino === root.inode ); } /** - * True iff `before` and `after` are provably the same directory. + * True iff `before` and `after` may be treated as the same directory. * - * These comparisons are the anti-swap checks: they must prove the path was not - * replaced between two probes. A filesystem that reports no inode makes every - * directory compare equal, so an unverifiable inode is treated as a changed - * identity rather than as a match. + * These comparisons are the anti-swap checks around `realpath`. Where inodes + * are available they must match; where the filesystem reports none, there is + * nothing to compare and reporting a change would be a false positive that + * blocks the feature outright, so only the device is required. */ function isSameDirectoryIdentity(before: Stats, after: Stats): boolean { - return ( - hasVerifiableInode(before.ino) && - hasVerifiableInode(after.ino) && - before.dev === after.dev && - before.ino === after.ino - ); + if (!hasVerifiableInode(before.ino) || !hasVerifiableInode(after.ino)) { + return before.dev === after.dev; + } + return before.dev === after.dev && before.ino === after.ino; } function hasExpectedDirectoryIdentity( identity: ConversationDirectoryIdentity, expected: ConversationDirectoryIdentity, ): boolean { + const inodesProvable = + hasVerifiableInode(identity.inode) && hasVerifiableInode(expected.inode); return ( - hasVerifiableInode(identity.inode) && - hasVerifiableInode(expected.inode) && - hasVerifiableInode(identity.root.inode) && - hasVerifiableInode(expected.root.inode) && identity.storageSessionId === expected.storageSessionId && identity.name === expected.name && isSameConversationPath(identity.canonicalPath, expected.canonicalPath) && identity.device === expected.device && - identity.inode === expected.inode && + (!inodesProvable || identity.inode === expected.inode) && isSameConversationPath( identity.root.configuredRoot, expected.root.configuredRoot, @@ -166,7 +173,9 @@ function hasExpectedDirectoryIdentity( expected.root.canonicalRoot, ) && identity.root.device === expected.root.device && - identity.root.inode === expected.root.inode + (!identity.root.inodeVerifiable || + !expected.root.inodeVerifiable || + identity.root.inode === expected.root.inode) ); } @@ -211,6 +220,7 @@ export async function createConversationRootIdentity( canonicalRoot, device: after.dev, inode: after.ino, + inodeVerifiable: hasVerifiableInode(after.ino), }; } diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index f998f31ed4b..2da231a7e32 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2660,10 +2660,10 @@ describe('SessionService', () => { ).resolves.toBeUndefined(); }); - it('reports the requested spelling absent even when an unreadable case twin is enumerated', async () => { - // Both files are unreadable, so this takes the multi-candidate arm. The - // requested spelling already exists, so reusing the id mints no twin and - // the crashed-first-run recovery must survive the twin's presence. + it('lets an unreadable case twin keep occupying the id', async () => { + // Both files are unreadable. The requested spelling's own file is a twin of + // nothing, but the *other* spelling still occupies the id: minting the + // request beside it is what would make both permanently unrestorable. const legacySessionId = sessionIdA.toUpperCase(); readdirSyncSpy .mockReturnValueOnce([ @@ -2676,6 +2676,32 @@ describe('SessionService', () => { ); existsSyncSpy.mockReturnValue(true); + await expect( + sessionService.findSessionIdIgnoringCase(sessionIdA), + ).rejects.toMatchObject({ + name: 'SessionIdCaseConflictError', + sessionId: sessionIdA, + reason: 'unreadable_transcript', + }); + }); + + it('reports the requested spelling absent when only its own file survives', async () => { + // The twin raced away between enumeration and the presence check, so + // nothing but the request's own unreadable file is left to occupy the id. + const legacySessionId = sessionIdA.toUpperCase(); + readdirSyncSpy + .mockReturnValueOnce([ + `${sessionIdA}.jsonl`, + `${legacySessionId}.jsonl`, + ] as never) + .mockReturnValueOnce([] as never); + vi.spyOn(sessionService, 'getSessionLocation').mockResolvedValue( + undefined, + ); + existsSyncSpy.mockImplementation((filePath: fs.PathLike) => + String(filePath).includes(`${sessionIdA}.jsonl`), + ); + await expect( sessionService.findSessionIdIgnoringCase(sessionIdA), ).resolves.toBeUndefined(); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ad54fb81a8e..aa611b2f866 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -781,54 +781,28 @@ export class SessionService { candidates.set(candidateSessionId, states); } } - if (candidates.size > 1) { - // Conflict decisions are content-based, not filename-based: a file - // whose head recovers no records (crash-mid-append tear, foreign - // project) still occupies the id, but does not make a loadable - // session conflict with one. - const readable: Array<{ - candidateSessionId: string; - state: SessionArchiveState; - }> = []; - for (const candidateSessionId of candidates.keys()) { - const location = await this.getSessionLocation(candidateSessionId); - if (location === 'conflict') { - throw new SessionIdCaseConflictError(sessionId, candidateSessionId); - } - if (location !== undefined) { - readable.push({ candidateSessionId, state: location }); - } - } - if (readable.length === 1) return readable[0].candidateSessionId; - if (readable.length === 0) { - // The requested spelling's own transcript is a twin of nothing: reusing - // an id whose file already exists mints no case-only twin, so a first - // run that crashed before its first record still resumes it. Same - // escape as the single-candidate arm below. - if (candidates.has(sessionId)) return undefined; - // Every enumerated file failed content validation: a file still on - // disk under another spelling occupies the id, because minting the - // requested spelling beside it would make both permanently - // unrestorable; files that raced away mid-resolution are absent. - let anyPresent = false; - for (const [candidateSessionId, states] of candidates) { - for (const state of states) { - anyPresent ||= fs.existsSync( - this.getSessionFilePath(candidateSessionId, state), - ); - } - } - if (!anyPresent) return undefined; - throw new SessionIdCaseConflictError( - sessionId, - undefined, - 'unreadable_transcript', - ); - } - // On a case-insensitive filesystem every spelling opens the same - // physical transcript, so several spellings can each report a readable - // location while only one file exists. Collapse those aliases before - // calling it a conflict. + // Conflict decisions are content-based, not filename-based: a file whose + // head recovers no records (crash-mid-append tear, foreign project) still + // occupies the id, but does not make a loadable session conflict with one. + const readable: Array<{ + candidateSessionId: string; + state: SessionArchiveState; + }> = []; + for (const candidateSessionId of candidates.keys()) { + const location = await this.getSessionLocation(candidateSessionId); + if (location === 'conflict') { + throw new SessionIdCaseConflictError(sessionId, candidateSessionId); + } + if (location !== undefined) { + readable.push({ candidateSessionId, state: location }); + } + } + if (readable.length === 1) return readable[0].candidateSessionId; + if (readable.length > 1) { + // On a case-insensitive filesystem every spelling opens the same physical + // transcript, so several spellings can each report a readable location + // while only one file exists. Collapse those aliases before calling it a + // conflict. const aliased = this.resolveAliasedReadableCandidate( readable, candidates, @@ -836,36 +810,33 @@ export class SessionService { if (aliased !== undefined) return aliased; throw new SessionIdCaseConflictError(sessionId); } - const candidate = candidates.entries().next().value; - if (candidate === undefined) return undefined; - const [candidateSessionId, states] = candidate; - // Content first: one readable copy of a spelling present in both state - // directories resolves to that copy (getSessionLocation counts only - // readable transcripts), not a conflict. - const location = await this.getSessionLocation(candidateSessionId); - if (location === 'conflict') { - throw new SessionIdCaseConflictError(sessionId, candidateSessionId); - } - if (location !== undefined) return candidateSessionId; - // The head recovered no records. Only a *different* persisted spelling - // occupies the id, because minting the requested spelling beside it would - // create the case-only twin that makes both permanently unrestorable. The - // requested spelling is a twin of nothing: reporting it absent is how a - // first run that crashed before its first record resumes its own 0-byte - // transcript, and it keeps this resolver consistent with - // `getSessionLocation`, which already calls that file nonexistent. - if (candidateSessionId === sessionId) return undefined; - // A file that raced away mid-resolution is genuinely absent. - for (const state of states) { - if (fs.existsSync(this.getSessionFilePath(candidateSessionId, state))) { - throw new SessionIdCaseConflictError( - sessionId, - candidateSessionId, - 'unreadable_transcript', - ); + // No candidate recovered records. A transcript under a *different* spelling + // still occupies the id, because minting the requested spelling beside it + // would create the case-only twin that makes both permanently + // unrestorable. The requested spelling's own file is a twin of nothing, so + // it never counts as occupancy: that is how a first run which crashed + // before its first record resumes its own 0-byte transcript, and it keeps + // this resolver consistent with `getSessionLocation`, which already calls + // that file nonexistent. Anything that raced away is genuinely absent. + let occupyingSpelling: string | undefined; + for (const [candidateSessionId, states] of candidates) { + if (candidateSessionId === sessionId) continue; + for (const state of states) { + if (fs.existsSync(this.getSessionFilePath(candidateSessionId, state))) { + occupyingSpelling = candidateSessionId; + break; + } } + if (occupyingSpelling !== undefined) break; } - return undefined; + if (occupyingSpelling === undefined) return undefined; + throw new SessionIdCaseConflictError( + sessionId, + // Naming the single enumerated spelling is actionable; with several, no + // one of them is the answer. + candidates.size === 1 ? occupyingSpelling : undefined, + 'unreadable_transcript', + ); } /** From e4e9ef52923224e711e35e98e8679e1cf8756aa6 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 20 Aug 2026 10:50:26 +0800 Subject: [PATCH 29/29] fix(cli): Collapse case-variant session ids in batch lifecycle and CLI create Batch delete/archive/unarchive locked on canonical keys but still deduped raw spellings, so two case variants of one id deadlocked the batch. CLI --session-id now stores the lowercase spelling so new mixed-case transcripts stop accumulating. Co-authored-by: Cursor --- packages/cli/src/config/config.test.ts | 21 ++++++ packages/cli/src/config/config.ts | 9 ++- .../src/serve/server/session-archive.test.ts | 74 +++++++++++++++++++ .../cli/src/serve/server/session-archive.ts | 12 ++- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 19eef772b3f..e03aa7c8d56 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1990,6 +1990,27 @@ describe('loadCliConfig', () => { expect(config.getSessionId()).toBe(sessionId); }); + it('canonicalizes a mixed-case caller-supplied sessionId before storing it', async () => { + const sessionId = '123E4567-E89B-12D3-A456-426614174000'; + mockSessionServiceInstance.findSessionIdIgnoringCase.mockResolvedValue( + undefined, + ); + + const config = await loadCliConfig( + {}, + { sessionId } as CliArgs, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + ); + + expect(config.getSessionId()).toBe(sessionId.toLowerCase()); + }); + it('should use internal sandbox session ID without treating it as a new session', async () => { const sessionId = '123e4567-e89b-12d3-a456-426614174000'; vi.stubEnv('SANDBOX', 'sandbox-exec'); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 886b7e26bfe..8babd54e3fa 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -74,7 +74,7 @@ import { reviewCommand } from '../commands/review.js'; import { serveCommand } from '../commands/serve.js'; import { sessionsCommand } from '../commands/sessions.js'; import { updateCommand } from '../commands/update.js'; -import { isValidSessionId } from './session-id.js'; +import { isValidSessionId, normalizeSessionIdForLookup } from './session-id.js'; export { isValidSessionId } from './session-id.js'; @@ -2077,11 +2077,14 @@ export async function loadCliConfig( occupied = (await sessionService.findSessionIdIgnoringCase(argv['sessionId'])) !== undefined; - } catch { + } catch (error) { // Any read failure leaves the id unproven, and the resolver propagates // non-ENOENT errors. Assume occupied, as the previous existence check // did: startup must reach the guarded conflict message and honour // `throwOnSessionIdConflict` rather than die on a raw errno. + debugLogger.debug( + `Session id occupancy check failed for ${argv['sessionId']}: ${error}`, + ); occupied = true; } if (occupied) { @@ -2092,7 +2095,7 @@ export async function loadCliConfig( writeStderrLine(message); process.exit(1); } - sessionId = argv['sessionId']; + sessionId = normalizeSessionIdForLookup(argv['sessionId']); } const modelProvidersConfig = settings.modelProviders; diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index 875e97a510e..333c0ce8976 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -318,6 +318,33 @@ describe('archiveDaemonSessions', () => { ).toBe(true); }); + it('collapses case-variant spellings in one batch to a single archive', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440102'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const closeSession = vi.fn().mockResolvedValue(undefined); + + const result = await archiveDaemonSessions({ + sessionIds: [sessionId.toUpperCase(), sessionId], + service: new SessionService(workspaceDir), + bridge: { closeSession }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result).toEqual({ + archived: [sessionId], + alreadyArchived: [], + notFound: [], + errors: [], + }); + expect(closeSession).toHaveBeenCalledTimes(1); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + false, + ); + expect( + fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')), + ).toBe(true); + }); + it('disables a scheduled task bound to the archived session', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440050'; writeSessionFile(workspaceDir, sessionId, 'active'); @@ -752,6 +779,30 @@ describe('unarchiveDaemonSessions', () => { ).toBe(false); }); + it('collapses case-variant spellings in one batch to a single unarchive', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440111'; + writeSessionFile(workspaceDir, sessionId, 'archived'); + + const result = await unarchiveDaemonSessions({ + sessionIds: [sessionId.toUpperCase(), sessionId], + service: new SessionService(workspaceDir), + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result).toEqual({ + unarchived: [sessionId], + alreadyActive: [], + notFound: [], + errors: [], + }); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + true, + ); + expect( + fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')), + ).toBe(false); + }); + it('does not unarchive while another writer holds the lease', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440015'; writeSessionFile(workspaceDir, sessionId, 'archived'); @@ -1002,6 +1053,29 @@ describe('deleteDaemonSessions', () => { expect(ids).toEqual(['other']); // bound task deleted, unbound survives }); + it('collapses case-variant spellings in one batch to a single delete', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440170'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const closeSession = vi.fn().mockResolvedValue(undefined); + + const result = await deleteDaemonSessions({ + sessionIds: [sessionId.toUpperCase(), sessionId], + service: new SessionService(workspaceDir), + bridge: { closeSession }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result).toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + }); + expect(closeSession).toHaveBeenCalledTimes(1); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + false, + ); + }); + it('does not delete while another writer holds the lease', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440071'; writeSessionFile(workspaceDir, sessionId, 'active'); diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index e1742f36b0c..366bb038c11 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -401,7 +401,9 @@ export async function deleteDaemonSessions(params: { coordinatorLockHeld = false, onError, } = params; - const uniqueSessionIds = [...new Set(sessionIds)]; + const uniqueSessionIds = [ + ...new Set(sessionIds.map(normalizeSessionIdForLookup)), + ]; if (!coordinatorLockHeld) { for (const sessionId of uniqueSessionIds) { coordinator.assertNotTransitioning(sessionId); @@ -637,7 +639,9 @@ export async function archiveDaemonSessions(params: { coordinator, coordinatorLockHeld = false, } = params; - const uniqueSessionIds = [...new Set(sessionIds)]; + const uniqueSessionIds = [ + ...new Set(sessionIds.map(normalizeSessionIdForLookup)), + ]; if (!coordinatorLockHeld) { for (const sessionId of uniqueSessionIds) { coordinator.assertNotTransitioning(sessionId); @@ -797,7 +801,9 @@ export async function unarchiveDaemonSessions(params: { coordinator, coordinatorLockHeld = false, } = params; - const uniqueSessionIds = [...new Set(sessionIds)]; + const uniqueSessionIds = [ + ...new Set(sessionIds.map(normalizeSessionIdForLookup)), + ]; if (!coordinatorLockHeld) { for (const sessionId of uniqueSessionIds) { coordinator.assertNotTransitioning(sessionId);