From 5fbdb4e238aa7dc1ac40aaf6d91499f6890791bf Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 26 Aug 2026 19:36:29 +0800 Subject: [PATCH 1/8] docs: Design standalone daemon session API Co-authored-by: Qwen-Coder --- docs/design/standalone-daemon-sessions.md | 257 ++++--- .../2026-08-25-standalone-pr3-daemon-api.md | 708 ++++++++++++++++++ 2 files changed, 854 insertions(+), 111 deletions(-) create mode 100644 docs/plans/2026-08-25-standalone-pr3-daemon-api.md diff --git a/docs/design/standalone-daemon-sessions.md b/docs/design/standalone-daemon-sessions.md index 53bfdba3ea6..744eee4a081 100644 --- a/docs/design/standalone-daemon-sessions.md +++ b/docs/design/standalone-daemon-sessions.md @@ -272,15 +272,20 @@ junction/reparse escapes, path traversal, non-direct descendants, and identity changes are rejected. Device and inode identity are pinned for both the root and every materialized -session child for one daemon ownership lifetime. The owner keeps each child's -validated identity by session ID and compares it before every later use; an -owned `0700` directory substituted at the same path is still compromised. -Identity may be established only at first materialization, after a daemon -restart with no pending deletion journal, or when load, resume, or explicit -repair recreates a path proven absent while holding the lifecycle coordinator. -Archive does not reset it, and the normal-to-staged deletion rename preserves -it. After a restart, a securely recreated root and child at the expected -canonical paths may be accepted only after recovery journals have been +session child for one daemon ownership lifetime when the filesystem exposes a +nonzero inode. The owner keeps each child's validated identity by session ID and +compares it before every later use; on an inode-verifiable filesystem, an owned +`0700` directory substituted at the same path is still compromised. FAT/exFAT +and some SMB filesystems report inode zero. Consistent with the implemented +identity model, those filesystems explicitly fall back to device, canonical +path, direct-child shape, owner/mode where available, and link/reparse checks; +they cannot detect same-path replacement and must not be described as offering +inode attestation. Identity may be established only at first materialization, +after a daemon restart with no pending deletion journal, or when load, resume, +or explicit repair recreates a path proven absent while holding the lifecycle +coordinator. Archive does not reset it, and the normal-to-staged deletion rename +preserves it. After a restart, a securely recreated root and child at the +expected canonical paths may be accepted only after recovery journals have been reconciled; the feature does not promise persistent inode attestation across clean restarts. Windows validates canonical path and link/reparse behavior exposed by the platform without claiming POSIX owner/mode or ACL guarantees. @@ -487,7 +492,9 @@ 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 do not expose the dedicated +constant alone is insufficient. The final app evaluates a runtime closure after +route registration; bootstrap capability snapshots and partial embedded apps +therefore omit the tag. 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 @@ -610,12 +617,10 @@ interface DaemonStandaloneFields { } interface DaemonStandaloneSession - extends DaemonSession, - DaemonStandaloneFields {} + extends DaemonSession, DaemonStandaloneFields {} interface DaemonRestoredStandaloneSession - extends DaemonRestoredSession, - DaemonStandaloneFields {} + extends DaemonRestoredSession, DaemonStandaloneFields {} interface DaemonStandaloneSessionSummary extends DaemonSessionSummary { sourceType: 'standalone'; @@ -650,17 +655,19 @@ The SDK generates a UUID before sending the request. Creation proceeds as one logical transaction: 1. Strictly validate the request and required UUID. -2. Ensure cross-daemon ownership, runtime, and secure root. -3. Under the exclusive lifecycle coordinator, check the deletion-journal - namespace for that UUID and run its bounded reconciliation. Continue only - after the journal reaches a terminal cleared state. A valid record still - pending cleanup returns retryable `409 standalone_session_conflict`; a - compromised record returns `409 deletion_recovery_compromised`. Neither case - materializes a child. While still holding the coordinator, reserve the UUID - daemon-wide across every active runtime bridge, every active and archived - transcript catalog, the Live owner index, and in-flight creation. Admission - is global, but the new session is created only through the validated - Conversations runtime. Any existing owner is a conflict. +2. Ensure cross-daemon ownership, runtime, and secure root, then await the + runtime-generation bounded reconciliation singleflight before acquiring this + UUID's lifecycle coordinator. +3. Under the exclusive lifecycle coordinator, reconcile an exact matching + deletion journal. Continue only after it reaches a terminal cleared state. A + valid record still pending cleanup returns retryable + `409 standalone_session_conflict`; a compromised record returns + `409 deletion_recovery_compromised`. Neither case materializes a child. While + still holding the coordinator, reserve the UUID daemon-wide across every + active runtime bridge, every active and archived transcript catalog, the Live + owner index, and in-flight creation. Admission is global, but the new session + is created only through the validated Conversations runtime. Any existing + owner is a conflict. 4. Validate and reuse an existing empty child or materialize a new deterministic child. A non-empty child without a transcript is a conflict and is never adopted or deleted automatically. @@ -759,11 +766,11 @@ then load; it never retries create automatically. ### Load, resume, prompt, and repair Load and resume first validate source ownership, root, and deterministic child. -Before shared load admission or any missing-child recreation, they check for a -pending deletion journal. If one exists, the daemon runs bounded reconciliation -under the exclusive lifecycle coordinator; it never recreates the normal child -while the journal remains. A non-terminal or compromised recovery returns its -structured deletion error instead of loading the session. +Before per-session admission or any missing-child recreation, they await the +runtime-generation bounded reconciliation singleflight, acquire exclusive +admission, and reconcile an exact pending deletion journal. They never recreate +the normal child while the journal remains. A non-terminal or compromised +recovery returns its structured deletion error instead of loading the session. If the child is absent, the daemon recreates it at the same path, relocates the session, and returns `workingDirectory.state: "recreated"` with a warning that deleted files were not recovered. This recreation holds the lifecycle @@ -853,27 +860,37 @@ directory. ### Deletion transaction WebShell retains its second confirmation and explains that deletion removes the -transcript and private files. The daemon then acquires the exclusive lifecycle -coordinator and writer lease, closes prompt admission, and tears down active -ownership before changing either the directory or transcript. +transcript and private files. The daemon acquires the exclusive lifecycle +coordinator, closes prompt admission, and tears down active ownership before +acquiring the writer lease or changing either the directory or transcript. It +never waits for active bridge work while holding the writer lease. Deletion uses a small durable recovery journal beside the stable Conversations owner record in an owner-only user-global namespace independent of -`QWEN_RUNTIME_DIR` and project runtime bases. Each atomically written record has -a bounded schema containing the session ID, expected directory hash, -transaction phase, validated Conversations-root canonical/device/inode -identity, the exact normal and staged canonical paths, and the validated -child's device/inode identity captured before rename when a child exists. The -atomic rename preserves that identity, so either path can be matched after a -crash between rename and the staged-phase journal write. Recovery must match -the recorded root and applicable child identity before destructive file -cleanup; an identity mismatch or an unprovable identity fails closed and leaves -files untouched. +`QWEN_RUNTIME_DIR` and project runtime bases. A transaction has immutable +`prepared` and, after directory staging, `staged` phase files. Each phase is +atomically written to a previously absent final name; `staged` supplements +rather than replaces `prepared`, avoiding an unlink-to-replace gap on Windows. +Each bounded record contains the session ID, transaction phase, validated +authoritative storage spelling, pre-delete active/archive transcript location, +validated Conversations-root canonical/device/inode identity, the exact +deterministic normal and staged child names, and the validated child's device/inode +identity captured before rename when a child exists. Recovery derives the only +allowed direct-child paths from the recorded canonical root and those validated +names; it never follows an arbitrary serialized child path. The storage spelling +must case-normalize to the canonical request UUID and remains the cleanup key +after transcript unlink removes the only rediscoverable copy of that spelling. +The atomic rename preserves directory identity, so the prepared evidence plus +physical state remains sufficient after a crash between rename and the +staged-phase write. Recovery must match the recorded root and applicable child +identity using the existing inode-verifiable or documented reduced filesystem +guarantee before destructive file cleanup; an identity mismatch fails closed +and leaves files untouched. If both normal and staged children are absent, record that state, delete the -transcript, and clear the journal. Missing files do not block transcript -deletion. If either path exists but fails validation, stop before transcript -mutation. +transcript, perform idempotent post-commit cleanup, and clear the journal. +Missing files do not block transcript deletion. If either path exists but fails +validation, stop before transcript mutation. 1. If the session has active ownership, wait for its prompt to settle or cancel, close its ACP session in the shared Conversations child, and remove its live @@ -881,73 +898,81 @@ mutation. 2. Revalidate owner, root, source, transcript, normal child, and absence of conflicting staged state. 3. Persist a prepared deletion record, including the validated normal child's - identity and exact normal/staged paths when the child exists. + identity and deterministic normal/staged names when the child exists, plus the + authoritative transcript spelling and active/archive location. 4. If the normal child exists, atomically rename it to the exact `.deleting` - sibling and atomically advance the journal to the staged phase. Transcript - deletion cannot start until that phase is durable. If the phase update - fails, restore the child before clearing the journal; interruption leaves a - prepared record whose pre-rename child identity safely drives recovery. -5. Delete the active or archived transcript and its sidecars. -6. If deletion reports an error, re-read the transcript and all sidecar state - under the writer lease. Only a fully intact set permits restoring the normal - child first and clearing the journal last, followed by retryable - `500 transcript_deletion_failed` with the session intact. A fully absent set - commits transcript deletion and continues to step 7. Partial or unknown - state retains the journal and staged child and returns - `transcript_deletion_outcome_unknown`; recovery must reconcile it before any - rollback or recursive cleanup. If restoring a fully intact set fails, leave - both journal and staged child for repair and return - `working_directory_recovery_failed`. If both children were already absent, - retain the journal on intact, partial, or unknown deletion failure so an - exact retry or bounded reconciliation can finish the authorized deletion. -7. If transcript deletion succeeds, recursively remove only the exact validated - staged child, then clear the journal. + sibling and write the immutable staged phase. Transcript deletion cannot + start until that phase is durable. If the staged write fails, restore the + child before clearing prepared evidence; interruption leaves a prepared + record whose pre-rename identity plus physical state drives recovery. +5. Unlink the one accepted active or archived transcript. This unlink is the + logical deletion commit point; sidecars remain retryable post-commit cleanup. +6. If unlink reports an error, re-read authoritative transcript location under + the writer lease. An intact transcript permits restoring the staged child + first and clearing the journal last, followed by retryable + `500 transcript_deletion_failed`. An absent transcript means deletion + committed and cleanup continues. Conflicted, unreadable, or unprovable state + retains the journal and directory state and returns + `transcript_deletion_outcome_unknown`. A failed restore retains both phase + evidence and the staged child and returns + `working_directory_recovery_failed`. +7. After commit, idempotently remove transcript sidecars, bridge attachments, + and only the exact validated staged child. Clear `staged` first and + `prepared` last only after all cleanup succeeds. Final removal failure does not resurrect the transcript. Return the session ID in `fileCleanupPending` and retain the journal so an exact retry or bounded reconciliation can resume cleanup. -Reconciliation has explicit reachable entry points. The first successful -Conversations ownership acquisition in a daemon lifetime runs a bounded pass -over deletion-journal records after secure-root validation and before standalone -route admission; this does not initialize Conversations while Live and -standalone are unused. Each record is reconciled under its exclusive lifecycle -coordinator and the transcript writer lease. A delete retry containing that exact -session ID checks for a matching journal before mapping an absent transcript to -`notFound`; if no session in another context owns the UUID, a valid record resumes -the authorized deletion and returns the session ID in `removed` after terminal -cleanup. Creation checks and reconciles the same UUID before reservation, and -load, resume, or repair of an existing transcript checks before normal child -validation or recreation. A startup pass that reaches its fixed safety bound -leaves remaining records untouched and reachable through a singleton delete -retry; it never guesses from staged-looking directories. A non-terminal or -compromised record is isolated to its UUID: the pass records the structured -error, leaves that record untouched, and continues without blocking unrelated -standalone sessions. +Reconciliation has explicit reachable entry points. The first mutating, load, +resume, or repair operation after successful Conversations ownership acquisition +starts one runtime-generation singleflight for a bounded pass over deletion +journal records after secure-root validation. The caller awaits this pass before +acquiring its requested session lifecycle lock. The pass sorts canonical IDs +and holds only one record's exclusive lifecycle coordinator and transcript +writer lease at a time; it never runs while a caller-specific session lock is +held. This does not initialize Conversations while Live and standalone are +unused. Read-only get and export inspect but never reconcile a matching record. +List validates only returned page items, taking shared admission for one UUID at +a time, and omits journaled or no-longer-valid entries. A delete retry containing +that exact session ID checks for a matching journal before mapping an absent +transcript to `notFound`; if no session in another context owns the UUID, a valid +record resumes the authorized deletion and returns the session ID in `removed`. +Creation, load, resume, repair, and exact delete then always reconcile their +requested UUID inside its exclusive admission even after the bounded pass +reaches its fixed safety limit. The pass never guesses from staged-looking +directories. A non-terminal or compromised record is isolated to its UUID: the +pass records the structured error, leaves that record untouched, and continues +without blocking unrelated standalone sessions. + +Before a valid record authorizes directory or attachment cleanup, recovery +rechecks every runtime and live owner for the canonical UUID. A project, Live, +child, conflicting transcript spelling, or foreign bridge entry that appeared +outside the standalone lifecycle makes the record compromised and leaves every +file untouched. Durable deletion evidence authorizes cleanup of the deleted +standalone incarnation only; it never authorizes mutation of a later owner. Recovery considers active and archived transcripts and every Conversations source before destructive cleanup: -- Transcript and sidecars are fully intact, journal valid, staged exists, normal - absent, and the recorded root/child identities match: restore staged to normal - first and clear the journal last, regardless of whether the durable phase is - prepared or staged. -- Transcript and sidecars are fully intact, journal valid, normal exists, staged - absent, and the recorded root/child identities match: clear the journal - without touching the directory, regardless of whether its durable phase is - prepared or staged. -- Transcript and sidecars are fully intact, journal valid, and both directories - absent: finish transcript deletion and clear the journal. An intact deletion - failure retains the journal and reports `transcript_deletion_failed` for a - later exact retry or bounded reconciliation. -- Transcript and sidecars are fully absent, journal valid, staged exists, - normal absent, and recorded identities match: finish exact staged cleanup and - clear the journal. -- Transcript or sidecar state is partial or unknown: retain the journal and - staged state, report `transcript_deletion_outcome_unknown`, and leave every - directory untouched until bounded reconciliation proves a terminal state. -- Transcript and sidecars are fully absent, both directories are absent, and - the journal's recorded root identity matches: clear the completed journal. +- The transcript is intact, a recorded child is matching at the staged path, + and normal is absent: restore staged to normal first and clear the phase files + last. +- The transcript is intact, a recorded child is matching at normal, and staged + is absent: clear the stale phase files without touching the directory. +- The transcript is intact, the record proves the child was absent, and both + paths remain absent: clear the stale phase files and keep the transcript. +- The transcript is absent and a recorded child is matching at staged: finish + post-commit cleanup and clear the phase files. +- The transcript is absent and both paths are absent: finish idempotent sidecar + and attachment cleanup, record a vanished expected child diagnostically when + applicable, and clear the completed phase files. +- The transcript is intact, the record expected a child, and both paths are + absent: report `deletion_recovery_compromised`; a missing private directory is + not equivalent to a record that proved absence before deletion. +- Transcript location is conflicted, unreadable, or unknown: retain journal and + directory state, report `transcript_deletion_outcome_unknown`, and leave every + directory untouched. - Both normal and staged exist, regardless of journal phase or validity: report `deletion_recovery_compromised` and leave every file untouched. - The journal is invalid or missing for staged state, the hash does not match, @@ -959,6 +984,12 @@ deletion was authorized. Creation cannot establish a new incarnation of a UUID while any journal for that UUID remains, so recovery never treats a fresh normal child as belonging beside an older staged child. +The recovery commit rule is deliberately singular: an intact transcript rolls +the incomplete transaction back to an intact session, while an absent +transcript commits deletion and requires cleanup. An exact delete retry may +start a fresh transaction after rollback clears stale evidence. Conflicted or +unreadable transcript state proves neither outcome and fails closed. + ### Failure contract | Condition | Result | @@ -976,7 +1007,7 @@ child as belonging beside an older staged child. | 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 deletion outcome is conflicted/unreadable | `500 transcript_deletion_outcome_unknown` | | Transcript rollback cannot restore staged child | `500 working_directory_recovery_failed` | | Create cleanup outcome is unknown | `500 standalone_creation_outcome_unknown` with UUID | | Conversations root identity or trust fails | `503 conversation_root_compromised` | @@ -1147,7 +1178,10 @@ pending, crashes between child rename and phase persistence, crashes between rollback restore and journal clear, embedded-app capability absence, multi-daemon ownership, and macOS/Linux/Windows path behavior. -Estimated size: 500-850 production lines and 950-1,600 test lines. +Audited estimate against PR2B head: 1,250-2,050 production lines and +2,500-4,000 test lines, dominated by the durable deletion transaction and fault +injection. If production logic exceeds 1,000 added lines, obtain maintainer +direction before publication as required by the repository advisory gate. Exit criterion: the complete feature works through REST without SDK/WebShell, survives daemon restart, and safely advertises v1. @@ -1339,17 +1373,18 @@ the daemon contract. - Archive/unarchive retain the child and do not cascade to children. - Prompt, repair, rename, archive, unarchive, and delete obey one lifecycle admission boundary. -- Delete closes active ownership, stages the exact child, deletes active or - archived transcript and sidecars, and returns the exact batch fields. +- Delete closes active ownership, stages the exact child, commits by unlinking + the active or archived transcript, then idempotently cleans sidecars, + attachments, and the staged child and returns the exact batch fields. - Every journal write, rename, transcript delete, rollback, final cleanup, and restart recovery boundary is fault-injected. -- Owner acquisition and a singleton delete retry reconcile a valid journal whose - transcript is already absent; bounded startup work leaves excess records for - exact retry. +- The first mutating/load/repair operation after owner acquisition and a + singleton delete retry reconcile a valid journal whose transcript is already + absent; bounded first-use work leaves excess records for exact retry. - Invalid/missing journal, normal-plus-staged conflict, hash mismatch, and unsafe staged path remain untouched. - Failed final cleanup reports `fileCleanupPending`; a singleton delete retry and - the owner-acquisition startup pass resume only the journaled exact path. + the owner-generation first-use pass resume only the journaled exact path. - Creation with the same UUID cannot materialize a new child until its pending deletion journal is terminally reconciled and cleared. diff --git a/docs/plans/2026-08-25-standalone-pr3-daemon-api.md b/docs/plans/2026-08-25-standalone-pr3-daemon-api.md new file mode 100644 index 00000000000..116c90b1dcc --- /dev/null +++ b/docs/plans/2026-08-25-standalone-pr3-daemon-api.md @@ -0,0 +1,708 @@ +# Standalone daemon sessions PR3 implementation plan + +**Status:** design-only, provisional on QwenLM/qwen-code PR #9978 head +`cd635a61ca778f067c101efb140557eceeee9afd`. Production implementation must +start from the merge commit on a fresh branch after PR2B is approved and merged. + +**Goal:** publish the complete daemon-only standalone-v1 contract: prompt-less +creation, list/get/load/resume/repair, rename/export, archive/unarchive/delete, +crash-safe deletion reconciliation, and conditional +`standalone_sessions_v1` advertisement. SDK, WebShell, and WebUI remain later +stages. + +**Source of truth:** `docs/design/standalone-daemon-sessions.md`. This plan turns +that architecture into tasks against the actual PR2B implementation. Where this +plan tightens an ambiguous failure boundary, the architecture document is +updated in the same design change. + +## Baseline and implementation gates + +The inspected PR2B head is open, mergeable, and currently blocked by requested +changes. It adds the internal `StandaloneSessionService`, managed private +directories, runtime quarantine, lifecycle admission, exact lookup/list, +load/resume, prompt/continue guards, and projectless Live-task adoption. It does +not register standalone routes or advertise a capability. The delta from the +previous inspected head only retains queued deferred-artifact batches after a +failed drain; it does not change any PR3 service or lifecycle seam. + +PR3 implementation does not begin until all of the following hold: + +1. PR #9978 is approved, green, merged, and its merge commit is present in the + current `origin/main`. +2. Every Critical review item on #9978 is classified against the final merged + code. In particular, deferred-artifact replay, quarantine state, directory + replacement, restore compatibility, and rollback ownership must not remain + uncertain. +3. Issue #9490's remaining lock-key/live-entry spelling contract is either fixed + or explicitly proven irrelevant to the UUID v1-v5 standalone lifecycle. + PR3 must not introduce a second normalization rule. +4. The implementation branch rebuilds this inventory against merged `main` and + records any signature drift before editing production code. +5. Maintainers are told that PR3 touches cross-package/core lifecycle code. If + production logic exceeds 1,000 added lines, the repository's advisory gate + applies even though the change is a feature rather than a refactor. + +## Locked scope + +### Included + +- The complete `/standalone/sessions` REST route family. +- Prompt-less top-level standalone creation with a caller-provided UUID. +- Active and archived list/exact lookup, load/resume, explicit directory repair, + title rename, and transcript export. +- Standalone-only archive, unarchive, and delete batches. +- Durable deletion authorization, exact directory staging, restart recovery, + and `fileCleanupPending`. +- Conditional capability advertisement only when the service, journal, + lifecycle, routes, and managed-directory primitives are installed together. +- Daemon integration tests, fault injection, and the PR3 E2E plan. + +### Excluded + +- TypeScript SDK methods or public SDK types (PR4). +- WebUI/WebShell context selection or presentation (PR5/PR6). +- Attachments as a new product surface, quotas, expiration, or background + garbage collection. +- Moving/forking a standalone session into a project. +- Durable standalone cron, workflows, native LSP, worktree isolation, or a + stronger OS sandbox. +- Generic session routes learning how to discover cold standalone transcripts. +- A second runtime, ACP child, standalone service, lifecycle lock map, writer + lease, or filesystem identity implementation. + +## Actual PR2B seam inventory + +PR3 extends these existing seams rather than replacing them: + +- `StandaloneSessionService` already owns canonical request IDs, process-local + creation state, runtime activity, source/lineage verification, pinned child + identity, managed relocation, creation, load/resume, exact lookup/list, and + prompt/continue admission. It lacks prompt-less create, repair, metadata, + export, archive/unarchive/delete, and deletion reconciliation. +- `ConversationWorkspace` owns the secure Conversations root and deterministic + direct-child identity. It can inspect/create/recreate and discard an empty + child. It cannot stage, restore, or recursively clean a journal-authorized + child. +- `SessionArchiveCoordinator` already provides shared admission, + fail-fast exclusive mutation, and waiting exclusive-after-shared mutation. + Every PR3 operation uses this instance. +- `SessionService` already owns writer leases, authoritative storage spelling, + active/archived snapshots, archive/unarchive, rename, transcript reads, usage + salvage, and all transcript sidecar paths. Its current `removeSession()` + combines transcript unlink and sidecar cleanup and returns only a boolean; + that is insufficient for a durable post-commit cleanup retry. +- `session-export.ts` already renders html/md/json/jsonl and remains the only + export formatter. +- `createServeFeatures()` and `CONDITIONAL_SERVE_FEATURES` already provide the + fail-closed capability mechanism. Bootstrap capabilities intentionally omit + the new tag. +- `createServeApp()` constructs the Conversations ownership, runtime manager, + workspace, service, route graph, and final capability handler. The new route + is mounted only when the complete dependency object exists. + +## Public REST contract + +All body validators require a JSON object, reject arrays, reject unknown keys, +and validate every field before ownership acquisition or filesystem mutation. +Mutation routes use the existing daemon mutation middleware. A request can +never provide `cwd`, `workspaceCwd`, `workspaceId`, source, scope, branch, or +worktree state. + +| Route | Request | Success | +| ------------------------------------------------ | ----------------------------------------------- | ----------------------------------------------------------------- | +| `POST /standalone/sessions` | `{ sessionId, modelServiceId?, approvalMode? }` | `200` standalone session; no prompt is sent | +| `GET /standalone/sessions` | `cursor?`, `size?`, `archiveState?` | `200 { sessions, nextCursor?, liveMergeFailed?, truncated? }` | +| `GET /standalone/sessions/:id` | none | `202 { sessionId, state: "creating" }` or `200` summary | +| `POST /standalone/sessions/:id/load` | restore options without workspace fields | `200` restored session | +| `POST /standalone/sessions/:id/resume` | restore options without workspace fields | `200` restored session | +| `POST /standalone/sessions/:id/repair-directory` | empty body | `200 { sessionId, projectlessOutputDirectory, workingDirectory }` | +| `PATCH /standalone/sessions/:id/metadata` | `{ displayName }` | `200 { sessionId, displayName }` | +| `GET /standalone/sessions/:id/export` | `format` is `html`, `md`, `json`, or `jsonl` | existing export content type, filename, and body | +| `POST /standalone/sessions/archive` | `{ sessionIds }` | `200 { archived, alreadyArchived, notFound, errors }` | +| `POST /standalone/sessions/unarchive` | `{ sessionIds }` | `200 { unarchived, alreadyActive, notFound, errors }` | +| `POST /standalone/sessions/delete` | `{ sessionIds }` | `200 { removed, notFound, errors, fileCleanupPending }` | + +Additional wire rules: + +- `sessionId` is a canonicalized RFC UUID v1-v5. Batch inputs contain 1-100 + strings, are canonicalized and de-duplicated while preserving first-input + order, and reject the entire body before work if any ID is invalid. +- `displayName` is non-empty after trimming, contains no control characters, + and is capped at the existing 256-character daemon limit. The route does not + expose PR bindings or organization metadata. +- List reuses the current cursor/page-size/archive-state implementation, returns + top-level explicit and compatible legacy standalone sessions only, and does + not stat private directories. +- Load/resume accept only the options already represented by + `RestoreStandaloneSessionOptions`; client identity comes from the existing + header parser, not the body. +- Archive/unarchive do not expose `resolveConflicts`. An active-plus-archived or + case-ambiguous ID is a per-ID `standalone_session_conflict`; the daemon never + deletes one copy to resolve it. +- Batch errors have exactly `{ sessionId, code, message }`. A failure for one ID + does not roll back successful operations on other IDs. +- Creation continues after an HTTP disconnect. If the session commits but the + response socket is gone, the route detaches only the response client's bridge + registration; it does not delete the transcript or private directory. The + caller recovers with exact GET and load/resume and never automatically retries + create. + +## Ownership and capability publication + +Every route is process-global at the HTTP layer but resolves only the owned, +trusted, non-primary Conversations runtime through `StandaloneSessionService`. +It never accepts a workspace selector and never falls back to the primary +runtime. Current ownership failure remains a structured 503 even though the +capability describes support rather than current availability. + +Add `standalone_sessions_v1` to `SERVE_CAPABILITY_REGISTRY` and to +`CONDITIONAL_SERVE_FEATURES` behind one +`standaloneSessionsAvailable: () => boolean` closure. `createServeFeatures()` +evaluates the closure for each capabilities response, after app construction; +it is true only after all of the following are constructed and the route +registrar has run: + +- `ConversationRuntimeOwnership` +- `ConversationRuntimeManager` +- `ConversationWorkspace` +- `StandaloneDeletionJournal` +- `StandaloneSessionService` +- the shared `SessionArchiveCoordinator` +- the complete standalone route registrar + +`currentServeFeaturesForRunQwenServe()` does not set the toggle, so the +bootstrap listener omits the feature. The final app handler evaluates the +runtime closure after route installation. Direct/embedded `createServeApp()` +calls without any dependency above omit both routes and capability. Availability +does not depend on Live Voice enablement and does not eagerly materialize the +Conversations root. + +## Lifecycle admission + +The route parses a UUID once. The canonical UUID is the coordinator/in-flight/ +journal key. The durable storage spelling returned by `SessionService` remains +authoritative for transcript and ACP storage operations. No lifecycle method +re-normalizes a storage spelling into a second filename. + +Lock order is fixed: + +1. Conversations cross-daemon ownership/runtime activity. +2. Canonical session lifecycle lock; parent shared before child exclusive for + the existing child-create path. +3. Close or check active bridge work inside the lifecycle boundary, before a + writer lease is acquired. +4. Session writer or maintenance lease for transcript mutation. +5. Journal, managed-directory, transcript, or cold-metadata persistence. +6. Bridge detach and catalog notification after the durable mutation. + +No PR3 operation holds a writer lease while waiting for another session lock or +for active bridge work to settle. +Batch operations acquire one ID at a time and may run independent IDs in +parallel. Rename, repair, archive, unarchive, and delete use exclusive +admission. Exact get/export use shared admission. List stays snapshot-based, +then takes shared admission for one returned page item at a time to revalidate +and journal-filter it; it never locks the whole catalog or holds multiple +session locks. Load/resume/create continue to use the existing +waiting-exclusive path. + +## Journal design + +Create `StandaloneDeletionJournal` under the stable ownership namespace: + +```text +/conversations/deletions/ + delete-.prepared.json + delete-.staged.json +``` + +The directory is owner-only. Each file is owner-only, bounded to 8 KiB, written +through an exclusive temporary file in the same directory, fsynced, and renamed +to a previously absent final name. The two immutable phase files avoid the +unlink-then-rename replacement gap on Windows. `staged` supplements rather than +overwrites `prepared`; recovery accepts both only when their immutable fields +match. Clearing unlinks `staged` first and `prepared` last, then fsyncs the +directory. A crash during clear can only leave an earlier safe phase. + +The version-1 record contains only bounded data: + +```ts +interface StandaloneDeletionRecordV1 { + version: 1; + phase: 'prepared' | 'staged'; + sessionId: string; + storageSessionId: string; + transcriptLocation: 'active' | 'archived'; + root: { + canonicalPath: string; + device: number; + inode: number; + inodeVerifiable: boolean; + }; + directory: + | { kind: 'absent' } + | { + kind: 'present'; + normalName: string; + stagedName: string; + device: number; + inode: number; + inodeVerifiable: boolean; + }; +} +``` + +Readers validate exact keys, version, canonical request UUID, +`storageSessionId` pattern and case-folded equality, active/archive location, +filename/phase agreement, numeric and boolean identity fields, size, +ownership/mode, deterministic normal/staged names, direct-child paths, and the +current root identity. The storage spelling and pre-delete location are durable +cleanup inputs after transcript unlink, when they can no longer be rediscovered. +Recovery derives the two direct-child paths from the recorded canonical root and +validated deterministic names; it never follows a serialized arbitrary child +path. Temporary files and unrelated names are not treated as authorization. +Root and child `inodeVerifiable` use the existing identity model: nonzero inodes +must match; inode-zero filesystems fall back explicitly to device, canonical +path, direct-child shape, owner/mode where available, and link/reparse checks. + +There is no second lockfile: the cross-daemon Conversations owner and the +existing in-process lifecycle coordinator are the writers' authority. Exact +lookup can read a journal while holding shared admission, but never mutates it. + +The first mutating/load/repair operation after ownership acquisition starts one +generation-scoped singleflight for a bounded pass of at most 32 canonical IDs. +The caller awaits that pass before acquiring its requested session lifecycle +lock. The pass validates and sorts journal filenames, then holds only one +record's exclusive lifecycle lock and writer lease at a time; it never runs +while any caller-specific session lock is held. Read-only get/list/export do not +run reconciliation; they inspect the journal under their read-admission rules +and fail closed or omit a pending ID. Exact delete and create/load/resume/repair +then reconcile their requested UUID inside its exclusive admission even after +the bounded pass reaches its limit. Thus root creation stays lazy, exact GET +remains non-mutating, and first-use recovery cannot self-deadlock. + +Before a journal authorizes directory or attachment cleanup, reconciliation +also rechecks every runtime/live owner for the canonical UUID. A newly observed +project, Live, child, conflicting transcript spelling, or foreign bridge entry +returns `deletion_recovery_compromised` and touches nothing. The journal proves +an earlier standalone delete request; it does not grant authority over a later +owner that failed to participate in the standalone lifecycle coordinator. + +## Managed-directory operations + +Extend `ConversationWorkspace`; do not perform path manipulation in routes or +the journal class. + +- Derive the normal name with the existing SHA-256 helper and the staged name as + its fixed `.deleting` sibling. +- `inspectStandaloneDeletionPaths()` validates root, normal, staged, expected + device/inode, direct-child shape, owner/mode, and the impossible-both-present + state. +- `stageStandaloneDirectory()` revalidates the pinned normal identity, proves + the staged path absent, atomically renames normal to staged, then proves that + the staged path carries the same identity under the recorded filesystem + guarantee before the staged phase is written. +- `restoreStagedStandaloneDirectory()` requires normal absent and a matching + staged identity, renames staged to normal, and revalidates the restored + identity before journal clear. +- `removeStagedStandaloneDirectory()` accepts only a valid journal record and a + matching staged identity, recursively removes that exact sibling, and + revalidates the root afterward. + +The lifecycle lock plus a valid journal prevents any cooperating daemon path +from creating or adopting the same UUID while cleanup is pending. The owner-only +root protects other OS users. A malicious process running as the same OS user is +outside the existing daemon filesystem threat model; Node has no portable +inode-bound recursive unlink, and inode-zero filesystems cannot detect same-path +replacement. The implementation must state this boundary rather than claiming +a stronger guarantee. + +## Core transcript commit point + +Define transcript unlink as the logical deletion commit point. This produces a +smaller and more recoverable contract than pretending the transcript and all +sidecars can change atomically. + +Add narrow `SessionService` lifecycle primitives, reusing its private snapshot, +usage-salvage, and sidecar helpers: + +1. `removeSessionTranscriptForLifecycle()` validates the maintainable snapshot, + rechecks the writer lease callback, unlinks the one accepted active or + archived transcript, and commits usage salvage immediately after that + unlink. It does not remove sidecars. +2. `cleanupRemovedSessionState()` idempotently removes worktree, PR, prompt + ledger, file-history, and organization sidecars even when the transcript is + already absent. Existing `removeSession()` composes the same internal helpers + so ordinary behavior remains unchanged. + +Standalone delete rejects active/archived conflicts before calling the first +primitive. If transcript unlink throws, re-read only authoritative transcript +location under the same writer lease: + +- The original transcript is still intact: restore staged to normal, clear the + journal, and return retryable `transcript_deletion_failed`. +- The transcript is absent: the logical delete committed; continue cleanup. +- Location is conflicted, unreadable, or cannot be proven: retain journal and + staged state and return `transcript_deletion_outcome_unknown`. + +After commit, `cleanupRemovedSessionState()`, bridge attachment cleanup, and +exact staged-directory cleanup are idempotent post-commit work. Any failure +returns the ID in both `removed` and `fileCleanupPending`, retains the journal, +and is retried by exact delete or bounded reconciliation. It never resurrects +the transcript. The journal is cleared only after all post-commit cleanup is +confirmed. + +## Per-operation implementation + +### Prompt-less create + +Refactor the existing create engine to make initial-prompt admission optional; +do not copy the source-persistence, durable reread, binding, or quarantine +transaction. The public route uses the no-prompt variant. Projectless Live task +and child creation keep their existing prompt-bearing variants. Both variants +reconcile the exact UUID before reservation and use the same create map. + +The returned bridge client registration belongs to the HTTP response. If +`res.writable` is false after commit, invoke one narrow service detach method for +that client ID and return without writing. Detach failure is diagnostic only; +the durable session remains discoverable. + +### Load, resume, and repair + +Load/resume reconcile the exact journal before directory preparation, then keep +their existing source/live-entry/epoch/pin checks. + +Repair has no hidden prompt replay. Under exclusive admission it: + +1. Reconciles a matching journal. +2. Requires an active standalone transcript; archived returns `session_archived`. +3. Verifies any live entry's source, lineage, storage ID, and epoch. +4. Returns retryable `session_busy` if an active prompt or cwd-bound background + work prevents relocation; it never cancels work automatically. +5. Reuses a valid child, restores a journaled child, or recreates only an absent + child, then reapplies managed binding/release. +6. If a cold restore was required solely to perform repair, detaches the repair + response client before returning the directory result. + +Factor the directory/bind portion shared with restore. Do not implement repair +by calling the public load route or by replaying transcript history through +Express. + +### Metadata and export + +Rename validates the title before runtime work, reconciles/fails on a matching +journal, and runs under exclusive admission. For an active verified live entry, +use the existing bridge metadata mutation so listeners receive the event. For a +cold or archived entry, use `SessionService.renameSession()` with the +authoritative spelling/location. Mark the catalog and invalidate active and +archived list caches only after persistence succeeds. The deterministic child +name never changes. + +Export runs under shared admission, rejects a pending journal, proves standalone +source/location, and calls the existing formatter with the authoritative +storage spelling and archive state. It never creates or repairs a child and does +not expose the internal workspace in the response. + +### Archive and unarchive + +Both operations reconcile/fail on a matching journal, prove standalone source +again inside the exclusive lifecycle and writer lease, and never call the +generic project scheduled-task maintenance helpers. Durable standalone cron is +unsupported, so touching the shared Conversations cron file would be a scope +leak. + +Archive closes active ownership with agent-close confirmation, moves the +transcript through `SessionService.archiveSessions(resolveConflicts: false)`, +retains the private child and pin, clears `agentBound`, marks the catalog, and +invalidates both list states. Unarchive moves the transcript back but does not +restore or recreate the child; the next load/repair performs identity work. +Parent and child sessions never cascade. + +### Delete and reconciliation + +For each canonical ID under exclusive admission: + +1. Reconcile an existing journal. A terminal committed deletion returns + `removed`; a compromised record returns its structured error. +2. Prove the transcript is active/archived standalone and not a child of another + product context. Close active ownership and confirm it is no longer live. +3. Acquire the writer/maintenance lease and re-read source/location. +4. Inspect the normal/staged child. Compromise or both-present fails before any + mutation. Write `prepared`, recording the authoritative storage spelling, + active/archive location, and either a matching child or absence. +5. If present, atomically stage the child and write immutable `staged` evidence. +6. Unlink the transcript through the core commit-point primitive and classify an + error as intact, committed, or unknown. +7. After commit, idempotently clean core sidecars, bridge attachments, and the + exact staged child. Return `fileCleanupPending` and retain the journal on any + failure. +8. Clear journal phases only after cleanup succeeds, clear the service's pinned + directory state, mark the catalog, and invalidate both list states. + +Recovery uses physical state plus a valid journal, not the nominal phase alone: + +| Recorded directory | Transcript | Normal child | Staged child | Action | +| ------------------ | --------------------------- | ----------------- | ------------ | --------------------------------------------------------- | +| present | intact | matching | absent | clear stale journal | +| present | intact | absent | matching | restore staged, then clear | +| absent | intact | absent | absent | clear stale journal; keep transcript | +| present | absent | absent | matching | finish post-commit cleanup, then clear | +| absent | absent | absent | absent | finish sidecar cleanup, then clear | +| present | absent | absent | absent | finish sidecar cleanup, record vanished child, then clear | +| present | intact | absent | absent | compromised before commit; touch nothing | +| any | any | present | present | compromised; touch nothing | +| any | partial/conflict/unreadable | any | any | outcome unknown; touch nothing | +| any | any | mismatched/unsafe | any | compromised; touch nothing | + +A valid `directory.kind: "absent"` record requires both child paths absent. +Creation cannot reserve/materialize the UUID while either phase file remains. +Read-only exact GET returns 404 when the transcript is absent; when it is intact +but journaled, it returns retryable conflict without reconciling. Listing omits +journaled IDs so a partially deleted session is never advertised as stable. + +Recovery therefore has one simple commit rule: an intact transcript means roll +the incomplete deletion back to an intact session; an absent transcript means +logical deletion committed and cleanup must finish. An exact delete retry may +start a fresh transaction after rollback clears the stale record. Conflicted or +unreadable location proves neither state and remains outcome-unknown. + +## Error mapping + +Extend `StandaloneSessionServiceErrorCode` and the existing centralized daemon +error serializer; do not inspect message text. + +| Code | HTTP | Retryable | +| ------------------------------------- | ---: | ----------------------------------------------------- | +| `invalid_request` | 400 | no | +| `standalone_session_not_found` | 404 | no | +| `standalone_session_conflict` | 409 | depends on pending/in-flight state | +| `session_archived` | 409 | no | +| `session_busy` | 409 | yes | +| `working_directory_missing` | 409 | yes through repair | +| `working_directory_compromised` | 409 | no | +| `deletion_recovery_compromised` | 409 | no | +| `standalone_creation_rolled_back` | 500 | yes, after exact GET confirms 404 | +| `standalone_creation_outcome_unknown` | 500 | no automatic create retry | +| `transcript_deletion_failed` | 500 | yes | +| `transcript_deletion_outcome_unknown` | 500 | no automatic retry except exact delete/reconciliation | +| `working_directory_recovery_failed` | 500 | yes through exact delete/reconciliation | + +Existing `conversation_*` 503 mappings remain unchanged. Errors include the +session ID when known and never include managed filesystem paths. + +## File-level implementation order + +1. **Core commit-point primitives** + - Modify `packages/core/src/services/sessionService.ts` and its tests. + - Preserve existing remove/archive/unarchive behavior and usage salvage. +2. **Journal and directory staging** + - Add + `packages/cli/src/serve/conversations/standalone-deletion-journal.ts` and + colocated tests. + - Extend `conversation-workspace.ts` and identity tests with staged-path + operations and platform-specific failures. +3. **Service lifecycle** + - Extend `standalone-session-service.ts` and its tests with reconciliation, + prompt-less create, detach, repair, metadata/export, lifecycle batches, and + delete. + - Keep all source, lineage, runtime-generation, and pin decisions here. +4. **Route adapters and errors** + - Add `packages/cli/src/serve/routes/standalone-sessions.ts` and tests. + - Register it from `server.ts`; extend `error-response.ts` without duplicating + status logic. +5. **Capability and embed gates** + - Update `capabilities.ts`, `server/serve-features.ts`, server tests, and + bootstrap tests. + - Prove partial/direct embeds omit both route and feature. +6. **Integration/E2E** + - Add real-daemon integration coverage after build/bundle. + - Execute `.qwen/e2e-tests/2026-08-25-standalone-pr3-daemon-api.md`. + +Keep the implementation as one logical publication PR with ordered commits for +the layers above. Re-estimate after tasks 1-3 compile. If the production diff is +over 1,000 lines, request maintainer direction before publication: either keep +one atomic PR with explicit awareness, or use stacked review units whose first +unit exposes no routes/capability and whose final unit atomically publishes the +complete feature. Do not silently split the capability contract. + +Current evidence suggests the old 500-850 production-line estimate is no longer +credible. A working estimate is 1,250-2,050 production lines and 2,500-4,000 +test lines, dominated by durable deletion and fault injection. This is an +estimate, not permission to fill the budget. + +## Verification matrix + +### Focused unit tests + +- Strict body/query validation, forbidden keys, malformed UUIDs, duplicate + batch IDs, bounded arrays, and exact status/body schemas. +- Capability off for bootstrap, partial embeds, missing workspace/manager/ + journal/service/routes; on only for the complete app. +- Prompt-less create, response disconnect detach, exact GET 202/200/404, and no + automatic create retry. +- Source/context/lineage conflicts for active, archived, child, Live, project, + corrupt, mixed-case, and active-plus-archived transcripts. +- Read-only get/list/export do not write or reconcile journal state. +- Repair of ready/missing/replaced/staged directories, busy prompt/background + work, live/cold sessions, and detach of repair-only restore. +- Rename/export for live, cold, and archived sessions; no child materialization. +- Archive/unarchive retain child, clear binding on archive, do not cascade, and + never touch durable cron. +- Journal schema/path/mode/size/phase validation; immutable phase idempotency; + verifiable and inode-zero identity modes; stale temp files; bounded scanning; + unrelated compromised records do not block safe IDs. +- Fault injection before/after prepared write, directory rename, staged write, + transcript unlink, usage commit, every sidecar cleanup, attachment cleanup, + recursive removal, journal clear, and runtime restart. +- Every row of the recovery table, including normal-plus-staged, unsafe staged + path, root replacement, child inode replacement, mixed-case legacy spelling, + absent transcript with pending cleanup, a later foreign-context UUID owner, + and exact retry. +- Writer lease loss, runtime generation closure, daemon drain, cross-daemon + owner conflict, and release failure at every mutation boundary. +- Existing ordinary/Live create, restore, archive, unarchive, delete, metadata, + export, capabilities, and scheduled-task tests remain unchanged. + +### Integration and E2E + +- Build and bundle before real-daemon tests. +- Complete REST lifecycle: create → exact get → prompt through existing owner + route → close → load/resume → rename/export → archive → unarchive → delete. +- Kill/restart at each journal boundary and verify exact recovery. +- Disconnect create response after persistence and recover by UUID. +- Concurrent prompt versus repair/archive/delete admission. +- Two daemons contending for Conversations ownership; never primary fallback. +- Embedded app without Conversations dependencies has 404 routes and no feature. +- macOS and Linux identity/mode/rename/restart behavior. +- Windows canonical path, reparse/junction rejection, open-handle rename/delete + failures, restart, immutable phase files, and cleanup-pending behavior. + +Required local checks after implementation: + +```bash +cd packages/core && npx vitest run src/services/sessionService.test.ts +cd packages/cli && npx vitest run \ + src/serve/conversations/conversation-workspace.test.ts \ + src/serve/conversations/standalone-deletion-journal.test.ts \ + src/serve/conversations/standalone-session-service.test.ts \ + src/serve/routes/standalone-sessions.test.ts \ + src/serve/server/error-response.test.ts \ + src/serve/server.test.ts +cd ../.. && npm run build && npm run bundle && npm run typecheck && npm run lint +npm run test:integration:cli:sandbox:none +``` + +Run serial Vitest commands if coverage output or shared temp state contends. +Then perform the repository-required open-ended full-diff audit until two +consecutive clean passes; any fix resets the count. + +## Design audit log + +### Finding round 1: architecture and failure paths + +- Found a contradiction between non-mutating exact lookup and unconditional + reconciliation on first ownership acquisition. Changed the plan so read-only + routes inspect/fail closed while mutation/load paths reconcile. +- Found a Windows replacement gap in a single mutable phase file. Changed the + journal to immutable prepared/staged files. +- Found that existing `removeSession()` cannot retry sidecar cleanup once the + transcript is absent. Added narrow core commit-point and idempotent cleanup + primitives. +- Found that generic archive/delete helpers mutate scheduled-task storage. + Standalone lifecycle calls core storage primitives directly and does not reuse + that project-scoped maintenance behavior. + +### Finding round 2: compatibility, maintainability, and simpler alternatives + +- Rejected a second lifecycle service/lock and route-level filesystem work. +- Rejected full transcript-plus-sidecar rollback as an unprovable multi-file + transaction. Transcript unlink is the logical commit; cleanup is journaled. +- Rejected eager runtime initialization solely to reconcile journals; exact + per-ID recovery plus a bounded first mutation keeps the root lazy. +- Rejected direct reuse of generic metadata and batch routes because they admit + workspace/PR/conflict-resolution semantics outside standalone v1. +- Retained one export renderer, one writer lease, one directory identity model, + and one capability registry. + +### Finding round 3: lock and recovery-state precision + +- Found that the initial lock order allowed active bridge settlement while a + transcript writer lease was held. Moved bridge close/busy checks before writer + acquisition and kept list snapshot-based rather than locking every UUID. +- Found that the initial recovery table conflated an intentionally absent child + with a recorded child for which both paths vanished. Split the states so only + proven pre-delete absence permits transcript commit; unexplained pre-commit + disappearance fails closed. + +### Finding round 4: first-use reconciliation concurrency + +- Found that an underspecified first-use journal pass could be invoked after a + caller acquired its requested UUID lock, then self-deadlock while scanning the + same record. Made the bounded pass generation-singleflight, sorted, one-lock- + at-a-time, and mandatory before any caller-specific lifecycle admission. +- Tightened list consistency without locking the catalog: it validates only the + returned page, one shared UUID admission at a time, and omits journaled or + no-longer-valid entries. + +### Finding round 5: filesystem identity portability + +- Found that the first journal schema implicitly required a verifiable child + inode even though PR2B deliberately supports inode-zero filesystems using a + reduced identity guarantee. Added explicit child/root verifiability evidence, + matching rules, threat-boundary wording, and tests for both modes. + +### Finding round 6: post-commit legacy spelling + +- Found that canonical UUID alone cannot recover mixed-case legacy sidecar names + after transcript unlink removes the only authoritative spelling. Added the + validated storage spelling and pre-delete active/archive location to durable + journal evidence and recovery tests. + +### Finding round 7: incarnation isolation + +- Required reconciliation to recheck every runtime/live owner before journal- + authorized cleanup, so durable evidence for an old standalone incarnation can + never mutate a later foreign owner of the same UUID. + +### Finding round 8: capability construction timing + +- Replaced an ambiguous capability boolean with a per-response availability + closure, matching `createServeFeatures()` construction order and ensuring the + tag cannot appear before route registration completes. + +### Finding round 9: one deletion commit rule + +- Simplified crash recovery so every intact transcript rolls back incomplete + deletion, including sessions whose child was already absent. Only transcript + absence commits deletion; an explicit retry can start a new transaction after + stale evidence is cleared. + +### Finding round 10: rendered wire schema + +- Found that pipe characters in the export query cell split the Markdown route + table into extra columns. Reworded the enum so the rendered request schema + remains exact. + +### Finding round 11: minimal journal evidence + +- Removed duplicate serialized child canonical paths. Root identity plus + validated deterministic normal/staged names uniquely derives the only allowed + direct children, reducing record size and eliminating arbitrary child-path + inputs without weakening recovery. + +### Clean audit pass 1: security, compatibility, and testing + +- Rechecked path authorization, crash points, transcript/sidecar commit + boundaries, source/lineage isolation, capability gating, batch behavior, + response disconnects, cold/archive behavior, and platform coverage. No new + actionable issue was found. + +### Clean audit pass 2: simplicity, maintainability, and scope + +- Rechecked existing seam reuse, lock ownership, error mappings, file-level + sequencing, estimates, exclusions, verification breadth, and simpler + alternatives. No new actionable issue was found. + +After these changes, no additional clear scope, compatibility, failure-path, +testing, or simpler-design issue was found under the inspected #9978 head. The +remaining blockers are upstream interface drift and maintainer review, not an +unresolved PR3 product decision. From af53b12e1bf5a4c28c05b9bb2e95d4735ddbbbf9 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 26 Aug 2026 20:00:02 +0800 Subject: [PATCH 2/8] feat(core): Split session deletion lifecycle Co-authored-by: Qwen-Coder --- .../core/src/services/sessionService.test.ts | 112 ++++++++++++++++++ packages/core/src/services/sessionService.ts | 74 ++++++++++-- 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index f1fb3886c70..4b99a264585 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -41,6 +41,7 @@ import { stableSessionArtifactId, } from './session-artifact-persistence.js'; import { SessionOrganizationService } from './session-organization-service.js'; +import { SessionTranscriptChangedError } from './session-writer-lease.js'; import { CompressionStatus } from '../core/turn.js'; import type { ChatRecord } from './chatRecordingService.js'; import * as jsonl from '../utils/jsonl-utils.js'; @@ -2054,6 +2055,117 @@ describe('SessionService', () => { expect.stringContaining(`/chats/archive/${sessionIdA}.ledger.jsonl`), ); }); + + it('can commit only the active transcript for lifecycle deletion', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return [recordA1]; + }, + ); + existsSyncSpy.mockReturnValue(true); + const removeOrganizationSpy = vi.spyOn( + SessionOrganizationService.prototype, + 'removeSession', + ); + + const removed = await sessionService.removeSessionTranscriptForLifecycle( + sessionIdA, + 'active', + ); + + expect(removed).toBe(true); + expect(unlinkSyncSpy).toHaveBeenCalledTimes(1); + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + ); + expect(commitUsageBeforeTranscriptDeletion).toHaveBeenCalledOnce(); + expect(rmSyncSpy).not.toHaveBeenCalled(); + expect(removeOrganizationSpy).not.toHaveBeenCalled(); + }); + + it('rejects lifecycle deletion when active and archived transcripts conflict', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + await expect( + sessionService.removeSessionTranscriptForLifecycle( + sessionIdA, + 'active', + ), + ).rejects.toBeInstanceOf(SessionTranscriptChangedError); + + expect(prepareUsageBeforeTranscriptDeletion).not.toHaveBeenCalled(); + expect(unlinkSyncSpy).not.toHaveBeenCalled(); + }); + + it('rejects lifecycle deletion when the transcript moved states', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (!filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return [recordA1]; + }, + ); + + await expect( + sessionService.removeSessionTranscriptForLifecycle( + sessionIdA, + 'active', + ), + ).rejects.toBeInstanceOf(SessionTranscriptChangedError); + + expect(unlinkSyncSpy).not.toHaveBeenCalled(); + }); + + it('cleans sidecars after the transcript is already absent', async () => { + existsSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const value = filePath.toString(); + return ( + value.endsWith(`${sessionIdA}.worktree.json`) || + value.endsWith(`${sessionIdA}.pr.json`) || + value.endsWith(`${sessionIdA}.ledger.jsonl`) + ); + }); + const removeOrganizationSpy = vi + .spyOn(SessionOrganizationService.prototype, 'removeSession') + .mockResolvedValue(); + + await sessionService.cleanupRemovedSessionState(sessionIdA); + + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`${sessionIdA}.worktree.json`), + ); + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`${sessionIdA}.pr.json`), + ); + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`${sessionIdA}.ledger.jsonl`), + ); + expect(rmSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`file-history/${sessionIdA}`), + { recursive: true, force: true }, + ); + expect(removeOrganizationSpy).toHaveBeenCalledWith(sessionIdA); + }); + + it('surfaces lifecycle sidecar cleanup failures for retry', async () => { + const cleanupError = new Error('organization cleanup failed'); + vi.spyOn( + SessionOrganizationService.prototype, + 'removeSession', + ).mockRejectedValue(cleanupError); + + await expect( + sessionService.cleanupRemovedSessionState(sessionIdA), + ).rejects.toBe(cleanupError); + }); }); describe('archiveSessions', () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index cd17d816cd8..cbaa1f02c9e 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1391,6 +1391,7 @@ export class SessionService { private async removeSessionOrganization( sessionId: string, assertCanMutate?: () => void, + propagateFailure = false, ): Promise { try { const service = new SessionOrganizationService( @@ -1408,6 +1409,7 @@ export class SessionService { } } catch (error) { assertCanMutate?.(); + if (propagateFailure) throw error; this.warn( `removeSession: failed to clear session organization for ${sessionId}: ${error}`, ); @@ -2374,13 +2376,28 @@ export class SessionService { sessionId: string, options: RemoveSessionOptions = {}, ): Promise { - const removed = await this.removeSessionFiles(sessionId, options); + const removed = await this.removeSessionTranscripts(sessionId, options); if (removed) { - await this.removeSessionOrganization(sessionId, options.assertCanMutate); + await this.cleanupRemovedSessionStateInternal(sessionId, options, false); } return removed; } + async removeSessionTranscriptForLifecycle( + sessionId: string, + expectedLocation: SessionArchiveState, + options: RemoveSessionOptions = {}, + ): Promise { + return this.removeSessionTranscripts(sessionId, options, expectedLocation); + } + + async cleanupRemovedSessionState( + sessionId: string, + options: RemoveSessionOptions = {}, + ): Promise { + await this.cleanupRemovedSessionStateInternal(sessionId, options, true); + } + /** * Usage salvage wrapper enforcing the "never blocks deletion" contract at * the call site. Preparation is read-only so rejected lifecycle mutations @@ -2411,9 +2428,10 @@ export class SessionService { } } - private async removeSessionFiles( + private async removeSessionTranscripts( sessionId: string, options: RemoveSessionOptions = {}, + expectedLocation?: SessionArchiveState, ): Promise { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { return false; @@ -2423,6 +2441,13 @@ export class SessionService { const physicalSnapshot = await this.resolveMaintainableSessionSnapshot(sessionId); if (physicalSnapshot.location === undefined) return false; + if ( + expectedLocation !== undefined && + (physicalSnapshot.location !== expectedLocation || + physicalSnapshot.identities.length !== 1) + ) { + throw new SessionTranscriptChangedError(); + } const preparedUsage = new Map< string, PreparedUsageBeforeTranscriptDeletion @@ -2446,16 +2471,9 @@ export class SessionService { preparedUsage.get(identity.filePath) ?? null, ); } - options.assertCanMutate?.(); - this.removeWorktreeSidecars(sessionId); - options.assertCanMutate?.(); - this.removePrSidecars(sessionId); - options.assertCanMutate?.(); - this.removePromptLedgers(sessionId); - options.assertCanMutate?.(); - this.removeFileHistoryBackups(sessionId); return true; } catch (error) { + if (expectedLocation !== undefined) throw error; if ( error instanceof SessionStorageEntryError && error.reason === 'foreign_project' @@ -2469,6 +2487,40 @@ export class SessionService { } } + private async cleanupRemovedSessionStateInternal( + sessionId: string, + options: RemoveSessionOptions, + propagateOrganizationFailure: boolean, + ): Promise { + this.cleanupRemovedSessionFiles(sessionId, options); + options.assertCanMutate?.(); + await this.removeSessionOrganization( + sessionId, + options.assertCanMutate, + propagateOrganizationFailure, + ); + } + + private cleanupRemovedSessionFiles( + sessionId: string, + options: RemoveSessionOptions, + ): void { + options.assertCanMutate?.(); + this.removeWorktreeSidecars(sessionId); + options.assertCanMutate?.(); + this.removePrSidecars(sessionId); + options.assertCanMutate?.(); + this.removePromptLedgers(sessionId); + options.assertCanMutate?.(); + this.removeFileHistoryBackups(sessionId); + } + + private async removeSessionFiles(sessionId: string): Promise { + const removed = await this.removeSessionTranscripts(sessionId); + if (removed) this.cleanupRemovedSessionFiles(sessionId, {}); + return removed; + } + async archiveSessions( sessionIds: string[], options: ArchiveSessionsOptions = {}, From a7d68b0e609a09f9cba4fd30cba280ac43d7067b Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 26 Aug 2026 20:00:28 +0800 Subject: [PATCH 3/8] feat(cli): Add standalone deletion journal Co-authored-by: Qwen-Coder --- .../conversation-workspace.test.ts | 107 ++++ .../conversations/conversation-workspace.ts | 153 ++++- .../standalone-deletion-journal.test.ts | 237 +++++++ .../standalone-deletion-journal.ts | 576 ++++++++++++++++++ .../utils/conversation-directory-identity.ts | 62 +- 5 files changed, 1130 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts create mode 100644 packages/cli/src/serve/conversations/standalone-deletion-journal.ts diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index d0e2545a0d5..2ddd653077b 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -530,4 +530,111 @@ describe('Live conversation workspace root', () => { } expect(ensured.error).toBe(racedError); }); + + it('stages and restores the exact standalone directory identity', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + await writeFile( + join(prepared.identity.canonicalPath, 'transient.txt'), + 'keep during staging', + ); + + const staged = await workspace.stageStandaloneDirectory( + 'standalone', + prepared.identity, + ); + + expect(staged.name).toBe(`${prepared.identity.name}.deleting`); + expect(staged.canonicalPath).toBe( + `${prepared.identity.canonicalPath}.deleting`, + ); + expect(staged).toMatchObject({ + storageSessionId: prepared.identity.storageSessionId, + device: prepared.identity.device, + inode: prepared.identity.inode, + }); + await expect( + workspace.inspectStandaloneDeletionPaths('standalone', prepared.identity), + ).resolves.toMatchObject({ status: 'staged' }); + + const restored = await workspace.restoreStagedStandaloneDirectory( + 'standalone', + prepared.identity, + ); + expect(restored).toMatchObject({ + name: prepared.identity.name, + canonicalPath: prepared.identity.canonicalPath, + device: prepared.identity.device, + inode: prepared.identity.inode, + }); + await expect( + lstat(join(restored.canonicalPath, 'transient.txt')), + ).resolves.toMatchObject({ size: 19 }); + }); + + it('removes only a matching staged standalone directory', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + await mkdir(join(prepared.identity.canonicalPath, 'nested'), { + mode: 0o700, + }); + await writeFile( + join(prepared.identity.canonicalPath, 'nested', 'remove.txt'), + 'remove', + ); + const staged = await workspace.stageStandaloneDirectory( + 'standalone', + prepared.identity, + ); + + await workspace.removeStagedStandaloneDirectory( + 'standalone', + prepared.identity, + ); + + await expect(lstat(staged.canonicalPath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect( + (await lstat((await workspace.getRoot()).canonicalRoot)).isDirectory(), + ).toBe(true); + }); + + it('fails closed when both normal and staged deletion paths exist', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + await mkdir(`${prepared.identity.canonicalPath}.deleting`, { mode: 0o700 }); + + const inspected = await workspace.inspectStandaloneDeletionPaths( + 'standalone', + prepared.identity, + ); + + expect(inspected.status).toBe('compromised'); + if (inspected.status !== 'compromised') { + throw new Error('expected compromised'); + } + expect(inspected.error.reason).toBe('unexpected_identity'); + }); + + it('rejects a replacement directory during deletion staging', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + await rename( + prepared.identity.canonicalPath, + `${prepared.identity.canonicalPath}.preserved`, + ); + await mkdir(prepared.identity.canonicalPath, { mode: 0o700 }); + + await expect( + workspace.stageStandaloneDirectory('standalone', prepared.identity), + ).rejects.toMatchObject({ + name: 'ConversationDirectoryIdentityError', + reason: 'unexpected_identity', + }); + }); }); diff --git a/packages/cli/src/serve/conversations/conversation-workspace.ts b/packages/cli/src/serve/conversations/conversation-workspace.ts index 63bd68613e9..e3545c046a6 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.ts @@ -4,14 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdir, rmdir } from 'node:fs/promises'; +import { readdir, rename, rm, rmdir } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { assertExactConversationRootIdentity, ConversationDirectoryIdentityError, createConversationRootIdentity, + getConversationDirectoryName, + getConversationStagedDirectoryName, inspectConversationDirectoryIdentity, + inspectConversationStagedDirectoryIdentity, + isSameConversationDirectoryObject, materializeConversationDirectoryIdentity, revalidateConversationRootIdentity, type ConversationDirectoryIdentity, @@ -42,6 +46,15 @@ export type StandaloneDirectoryEnsureResult = error: ConversationDirectoryIdentityError; }; +export type StandaloneDeletionPathsInspection = + | { status: 'absent' } + | { status: 'normal'; identity: ConversationDirectoryIdentity } + | { status: 'staged'; identity: ConversationDirectoryIdentity } + | { + status: 'compromised'; + error: ConversationDirectoryIdentityError; + }; + function liveIdentityError( error: unknown, exactRoot = false, @@ -339,4 +352,140 @@ export class ConversationWorkspace { throw error; } } + + async inspectStandaloneDeletionPaths( + storageSessionId: string, + expected?: ConversationDirectoryIdentity, + ): Promise { + const root = await this.revalidateStandaloneRoot(); + try { + const normal = await inspectConversationDirectoryIdentity( + root, + storageSessionId, + ); + const staged = await inspectConversationStagedDirectoryIdentity( + root, + storageSessionId, + ); + if (normal && staged) { + throw new ConversationDirectoryIdentityError( + 'child', + 'unexpected_identity', + ); + } + const identity = normal ?? staged; + if ( + identity && + expected && + !isSameConversationDirectoryObject(identity, expected) + ) { + throw new ConversationDirectoryIdentityError( + 'child', + 'unexpected_identity', + ); + } + if (normal) return { status: 'normal', identity: normal }; + if (staged) return { status: 'staged', identity: staged }; + return { status: 'absent' }; + } catch (error) { + if ( + error instanceof ConversationDirectoryIdentityError && + error.scope === 'child' + ) { + return { status: 'compromised', error }; + } + throw error; + } + } + + async stageStandaloneDirectory( + storageSessionId: string, + expected: ConversationDirectoryIdentity, + ): Promise { + const inspected = await this.inspectStandaloneDeletionPaths( + storageSessionId, + expected, + ); + if (inspected.status === 'compromised') throw inspected.error; + if (inspected.status !== 'normal') { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + const root = await this.revalidateStandaloneRoot(); + await rename( + inspected.identity.canonicalPath, + join( + root.canonicalRoot, + getConversationStagedDirectoryName(storageSessionId), + ), + ); + await revalidateConversationRootIdentity(root); + const staged = await inspectConversationStagedDirectoryIdentity( + root, + storageSessionId, + ); + if ( + !staged || + !isSameConversationDirectoryObject(staged, inspected.identity) + ) { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + return staged; + } + + async restoreStagedStandaloneDirectory( + storageSessionId: string, + expected: ConversationDirectoryIdentity, + ): Promise { + const inspected = await this.inspectStandaloneDeletionPaths( + storageSessionId, + expected, + ); + if (inspected.status === 'compromised') throw inspected.error; + if (inspected.status !== 'staged') { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + const root = await this.revalidateStandaloneRoot(); + await rename( + inspected.identity.canonicalPath, + join(root.canonicalRoot, getConversationDirectoryName(storageSessionId)), + ); + await revalidateConversationRootIdentity(root); + const restored = await inspectConversationDirectoryIdentity( + root, + storageSessionId, + ); + if ( + !restored || + !isSameConversationDirectoryObject(restored, inspected.identity) + ) { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + return restored; + } + + async removeStagedStandaloneDirectory( + storageSessionId: string, + expected: ConversationDirectoryIdentity, + ): Promise { + const inspected = await this.inspectStandaloneDeletionPaths( + storageSessionId, + expected, + ); + if (inspected.status === 'compromised') throw inspected.error; + if (inspected.status !== 'staged') { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + const confirmed = await inspectConversationStagedDirectoryIdentity( + inspected.identity.root, + storageSessionId, + ); + if ( + !confirmed || + !isSameConversationDirectoryObject(confirmed, inspected.identity) + ) { + throw new ConversationDirectoryIdentityError('child', 'identity_changed'); + } + await rm(confirmed.canonicalPath, { recursive: true }); + await revalidateConversationRootIdentity(confirmed.root); + } } diff --git a/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts b/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts new file mode 100644 index 00000000000..6e6f88c10f1 --- /dev/null +++ b/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getConversationDirectoryName } from '../../utils/conversation-directory-identity.js'; +import { ConversationWorkspace } from './conversation-workspace.js'; +import { + StandaloneDeletionJournal, + StandaloneDeletionJournalError, + type StandaloneDeletionRecordV1, +} from './standalone-deletion-journal.js'; + +const SESSION_ID = '550e8400-e29b-41d4-a716-446655440000'; + +describe('StandaloneDeletionJournal', () => { + let homeDir: string; + let stableBaseDir: string; + let ownerDirectory: string; + let workspace: ConversationWorkspace; + let journal: StandaloneDeletionJournal; + + beforeEach(async () => { + homeDir = await fs.mkdtemp(path.join(tmpdir(), 'qwen-deletion-journal-')); + stableBaseDir = path.join(homeDir, '.qwen'); + ownerDirectory = path.join(stableBaseDir, 'conversations'); + await fs.mkdir(ownerDirectory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') { + await fs.chmod(ownerDirectory, 0o700); + } + workspace = new ConversationWorkspace({ homeDir }); + journal = new StandaloneDeletionJournal(stableBaseDir); + }); + + afterEach(async () => { + await fs.rm(homeDir, { recursive: true, force: true }); + }); + + const journalPath = (phase: 'prepared' | 'staged') => + path.join( + ownerDirectory, + 'deletions', + `delete-${SESSION_ID}.${phase}.json`, + ); + + const makeRecord = async ( + phase: 'prepared' | 'staged', + directory: StandaloneDeletionRecordV1['directory'] = { kind: 'absent' }, + ): Promise => { + const root = await workspace.getRoot(); + return { + version: 1, + phase, + sessionId: SESSION_ID, + storageSessionId: SESSION_ID.toUpperCase(), + transcriptLocation: 'active', + root: { + canonicalPath: root.canonicalRoot, + device: root.device, + inode: root.inode, + inodeVerifiable: root.inodeVerifiable, + }, + directory, + }; + }; + + it('writes and reads an owner-only prepared record', async () => { + const root = await workspace.getRoot(); + const record = await makeRecord('prepared'); + + await journal.writePrepared(record, root); + + await expect(journal.read(SESSION_ID, root)).resolves.toEqual({ + prepared: record, + }); + await expect(journal.hasRecord(SESSION_ID)).resolves.toBe(true); + if (process.platform !== 'win32') { + expect((await fs.stat(journalPath('prepared'))).mode & 0o777).toBe(0o600); + expect( + (await fs.stat(path.dirname(journalPath('prepared')))).mode & 0o777, + ).toBe(0o700); + } + }); + + it('writes matching immutable phases and clears staged before prepared', async () => { + const root = await workspace.getRoot(); + const prepared = await makeRecord('prepared'); + const staged = { ...prepared, phase: 'staged' as const }; + await journal.writePrepared(prepared, root); + await journal.writeStaged(staged, root); + + await expect(journal.read(SESSION_ID, root)).resolves.toEqual({ + prepared, + staged, + }); + await expect(journal.listSessionIds()).resolves.toEqual([SESSION_ID]); + + await journal.clear(SESSION_ID, root); + + await expect(journal.read(SESSION_ID, root)).resolves.toBeUndefined(); + }); + + it('refuses to overwrite an immutable prepared phase', async () => { + const root = await workspace.getRoot(); + const record = await makeRecord('prepared'); + await journal.writePrepared(record, root); + + await expect(journal.writePrepared(record, root)).rejects.toMatchObject({ + reason: 'conflict', + }); + }); + + it('rejects a staged phase whose immutable fields differ', async () => { + const root = await workspace.getRoot(); + const prepared = await makeRecord('prepared'); + await journal.writePrepared(prepared, root); + + await expect( + journal.writeStaged( + { ...prepared, phase: 'staged', transcriptLocation: 'archived' }, + root, + ), + ).rejects.toMatchObject({ reason: 'compromised' }); + await expect(fs.lstat(journalPath('staged'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('rejects a staged record without its prepared authorization', async () => { + const root = await workspace.getRoot(); + const staged = await makeRecord('staged'); + await fs.mkdir(path.dirname(journalPath('staged')), { + recursive: true, + mode: 0o700, + }); + await fs.writeFile(journalPath('staged'), JSON.stringify(staged), { + mode: 0o600, + }); + + await expect(journal.read(SESSION_ID, root)).rejects.toBeInstanceOf( + StandaloneDeletionJournalError, + ); + }); + + it('rejects extra record keys and retains fail-closed presence', async () => { + const root = await workspace.getRoot(); + const record = { ...(await makeRecord('prepared')), extra: true }; + await fs.mkdir(path.dirname(journalPath('prepared')), { + recursive: true, + mode: 0o700, + }); + await fs.writeFile(journalPath('prepared'), JSON.stringify(record), { + mode: 0o600, + }); + + await expect(journal.hasRecord(SESSION_ID)).resolves.toBe(true); + await expect(journal.read(SESSION_ID, root)).rejects.toMatchObject({ + reason: 'compromised', + }); + }); + + it('rejects a record tied to another root identity', async () => { + const root = await workspace.getRoot(); + const record = await makeRecord('prepared'); + await journal.writePrepared(record, root); + + await expect( + journal.read(SESSION_ID, { ...root, device: root.device + 1 }), + ).rejects.toMatchObject({ reason: 'compromised' }); + }); + + it('rejects a symlinked record', async () => { + if (process.platform === 'win32') return; + const root = await workspace.getRoot(); + const record = await makeRecord('prepared'); + const target = path.join(homeDir, 'foreign.json'); + await fs.writeFile(target, JSON.stringify(record), { mode: 0o600 }); + await fs.mkdir(path.dirname(journalPath('prepared')), { + recursive: true, + mode: 0o700, + }); + await fs.symlink(target, journalPath('prepared')); + + await expect(journal.read(SESSION_ID, root)).rejects.toMatchObject({ + reason: 'compromised', + }); + }); + + it('lists only canonical phase files in sorted bounded order', async () => { + const root = await workspace.getRoot(); + const secondId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + const first = await makeRecord('prepared'); + const second: StandaloneDeletionRecordV1 = { + ...first, + sessionId: secondId, + storageSessionId: secondId, + }; + await journal.writePrepared(first, root); + await journal.writePrepared(second, root); + await fs.writeFile( + path.join(path.dirname(journalPath('prepared')), '.unfinished.tmp'), + 'ignored', + ); + + await expect(journal.listSessionIds(1)).resolves.toEqual([SESSION_ID]); + }); + + it('rejects invalid caller ids before deriving a path', async () => { + await expect(journal.hasRecord('../escape')).rejects.toMatchObject({ + reason: 'compromised', + }); + }); + + it('validates deterministic names for present directories', async () => { + const root = await workspace.getRoot(); + const normalName = getConversationDirectoryName(SESSION_ID); + const record = await makeRecord('prepared', { + kind: 'present', + normalName, + stagedName: `${normalName}.deleting`, + device: 1, + inode: 2, + inodeVerifiable: true, + }); + + await journal.writePrepared(record, root); + + await expect(journal.read(SESSION_ID, root)).resolves.toEqual({ + prepared: record, + }); + }); +}); diff --git a/packages/cli/src/serve/conversations/standalone-deletion-journal.ts b/packages/cli/src/serve/conversations/standalone-deletion-journal.ts new file mode 100644 index 00000000000..14d5df836c4 --- /dev/null +++ b/packages/cli/src/serve/conversations/standalone-deletion-journal.ts @@ -0,0 +1,576 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { parseCallerSuppliedSessionId } from '../../config/session-id.js'; +import { + getConversationDirectoryName, + isSameConversationPath, + type ConversationRootIdentity, +} from '../../utils/conversation-directory-identity.js'; +import { getConversationRuntimeOwnerPath } from './conversation-runtime-ownership.js'; + +const JOURNAL_DIRECTORY = 'deletions'; +const MAX_RECORD_BYTES = 8 * 1024; +const STORAGE_SESSION_ID_PATTERN = /^[0-9a-fA-F-]{32,36}$/; +const JOURNAL_FILE_PATTERN = + /^delete-([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.(prepared|staged)\.json$/; + +export interface StandaloneDeletionRootIdentity { + canonicalPath: string; + device: number; + inode: number; + inodeVerifiable: boolean; +} + +export type StandaloneDeletionDirectory = + | { kind: 'absent' } + | { + kind: 'present'; + normalName: string; + stagedName: string; + device: number; + inode: number; + inodeVerifiable: boolean; + }; + +export interface StandaloneDeletionRecordV1 { + version: 1; + phase: 'prepared' | 'staged'; + sessionId: string; + storageSessionId: string; + transcriptLocation: 'active' | 'archived'; + root: StandaloneDeletionRootIdentity; + directory: StandaloneDeletionDirectory; +} + +export interface StandaloneDeletionJournalEntry { + prepared: StandaloneDeletionRecordV1; + staged?: StandaloneDeletionRecordV1; +} + +export type StandaloneDeletionJournalErrorReason = 'conflict' | 'compromised'; + +export class StandaloneDeletionJournalError extends Error { + override readonly name = 'StandaloneDeletionJournalError'; + + constructor(readonly reason: StandaloneDeletionJournalErrorReason) { + super(`Standalone deletion journal is ${reason}.`); + } +} + +interface DirectoryIdentity { + device: number; + inode: number; + inodeVerifiable: boolean; +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +function parseSessionId(value: string): string { + const parsed = parseCallerSuppliedSessionId(value); + if (parsed.kind !== 'valid') { + throw new StandaloneDeletionJournalError('compromised'); + } + return parsed.sessionId; +} + +function hasVerifiableInode(inode: number): boolean { + return inode !== 0; +} + +function sameDirectoryIdentity( + left: DirectoryIdentity, + right: DirectoryIdentity, +): boolean { + return ( + left.device === right.device && + left.inodeVerifiable === right.inodeVerifiable && + (!left.inodeVerifiable || left.inode === right.inode) + ); +} + +function exactKeys( + value: Record, + expected: readonly string[], +): boolean { + const keys = Object.keys(value).sort(); + return ( + keys.length === expected.length && + keys.every((key, index) => key === expected[index]) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isIdentityNumber(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function parseIdentity( + value: unknown, + pathField: boolean, +): StandaloneDeletionRootIdentity | undefined { + if (!isRecord(value)) return undefined; + if ( + !isIdentityNumber(value['device']) || + !isIdentityNumber(value['inode']) || + typeof value['inodeVerifiable'] !== 'boolean' || + (value['inodeVerifiable'] ? value['inode'] === 0 : value['inode'] !== 0) + ) { + return undefined; + } + if ( + pathField && + (typeof value['canonicalPath'] !== 'string' || + value['canonicalPath'].length === 0 || + value['canonicalPath'].length > 4096 || + !path.isAbsolute(value['canonicalPath'])) + ) { + return undefined; + } + return { + canonicalPath: pathField ? (value['canonicalPath'] as string) : '', + device: value['device'], + inode: value['inode'], + inodeVerifiable: value['inodeVerifiable'], + }; +} + +function parseRecord( + value: unknown, + expectedSessionId: string, + expectedPhase: 'prepared' | 'staged', + currentRoot: ConversationRootIdentity, +): StandaloneDeletionRecordV1 { + if ( + !isRecord(value) || + !exactKeys(value, [ + 'directory', + 'phase', + 'root', + 'sessionId', + 'storageSessionId', + 'transcriptLocation', + 'version', + ]) || + value['version'] !== 1 || + value['phase'] !== expectedPhase || + value['sessionId'] !== expectedSessionId || + typeof value['storageSessionId'] !== 'string' || + !STORAGE_SESSION_ID_PATTERN.test(value['storageSessionId']) || + value['storageSessionId'].toLowerCase() !== expectedSessionId || + (value['transcriptLocation'] !== 'active' && + value['transcriptLocation'] !== 'archived') + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + + const root = parseIdentity(value['root'], true); + if ( + !root || + !isRecord(value['root']) || + !exactKeys(value['root'], [ + 'canonicalPath', + 'device', + 'inode', + 'inodeVerifiable', + ]) || + !isSameConversationPath(root.canonicalPath, currentRoot.canonicalRoot) || + root.device !== currentRoot.device || + root.inodeVerifiable !== currentRoot.inodeVerifiable || + (root.inodeVerifiable && root.inode !== currentRoot.inode) + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + + const rawDirectory = value['directory']; + let directory: StandaloneDeletionDirectory; + if ( + isRecord(rawDirectory) && + exactKeys(rawDirectory, ['kind']) && + rawDirectory['kind'] === 'absent' + ) { + directory = { kind: 'absent' }; + } else { + const identity = parseIdentity(rawDirectory, false); + const normalName = getConversationDirectoryName(expectedSessionId); + if ( + !identity || + !isRecord(rawDirectory) || + !exactKeys(rawDirectory, [ + 'device', + 'inode', + 'inodeVerifiable', + 'kind', + 'normalName', + 'stagedName', + ]) || + rawDirectory['kind'] !== 'present' || + rawDirectory['normalName'] !== normalName || + rawDirectory['stagedName'] !== `${normalName}.deleting` + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + directory = { + kind: 'present', + normalName, + stagedName: `${normalName}.deleting`, + device: identity.device, + inode: identity.inode, + inodeVerifiable: identity.inodeVerifiable, + }; + } + + return { + version: 1, + phase: expectedPhase, + sessionId: expectedSessionId, + storageSessionId: value['storageSessionId'], + transcriptLocation: value['transcriptLocation'], + root, + directory, + }; +} + +function sameImmutableRecord( + prepared: StandaloneDeletionRecordV1, + staged: StandaloneDeletionRecordV1, +): boolean { + return ( + JSON.stringify({ ...prepared, phase: 'staged' }) === JSON.stringify(staged) + ); +} + +export class StandaloneDeletionJournal { + private readonly ownerDirectory: string; + private readonly journalDirectory: string; + + constructor(stableBaseDir: string) { + if (!path.isAbsolute(stableBaseDir)) { + throw new TypeError('Standalone deletion journal base must be absolute.'); + } + this.ownerDirectory = path.dirname( + getConversationRuntimeOwnerPath(stableBaseDir), + ); + this.journalDirectory = path.join(this.ownerDirectory, JOURNAL_DIRECTORY); + } + + async hasRecord(rawSessionId: string): Promise { + const sessionId = parseSessionId(rawSessionId); + return ( + (await this.pathExists(this.recordPath(sessionId, 'prepared'))) || + (await this.pathExists(this.recordPath(sessionId, 'staged'))) + ); + } + + async listSessionIds(limit = 32): Promise { + const identity = await this.inspectJournalDirectory(); + if (!identity) return []; + let names: string[]; + try { + names = await fs.readdir(this.journalDirectory); + } catch (error) { + if (isMissing(error)) return []; + throw error; + } + await this.assertDirectoryIdentity(this.journalDirectory, identity); + const ids = new Set(); + for (const name of names) { + const match = JOURNAL_FILE_PATTERN.exec(name); + if (match?.[1]) ids.add(match[1]); + } + return [...ids].sort().slice(0, Math.max(0, limit)); + } + + async read( + rawSessionId: string, + currentRoot: ConversationRootIdentity, + ): Promise { + const sessionId = parseSessionId(rawSessionId); + const prepared = await this.readPhase(sessionId, 'prepared', currentRoot); + const staged = await this.readPhase(sessionId, 'staged', currentRoot); + if (!prepared && !staged) return undefined; + if (!prepared || (staged && !sameImmutableRecord(prepared, staged))) { + throw new StandaloneDeletionJournalError('compromised'); + } + return { prepared, ...(staged ? { staged } : {}) }; + } + + async writePrepared( + record: StandaloneDeletionRecordV1, + currentRoot: ConversationRootIdentity, + ): Promise { + const validated = parseRecord( + record, + parseSessionId(record.sessionId), + 'prepared', + currentRoot, + ); + if (await this.hasRecord(validated.sessionId)) { + throw new StandaloneDeletionJournalError('conflict'); + } + await this.writePhase(validated); + } + + async writeStaged( + record: StandaloneDeletionRecordV1, + currentRoot: ConversationRootIdentity, + ): Promise { + const sessionId = parseSessionId(record.sessionId); + const validated = parseRecord(record, sessionId, 'staged', currentRoot); + const existing = await this.read(sessionId, currentRoot); + if (!existing || existing.staged) { + throw new StandaloneDeletionJournalError('conflict'); + } + if (!sameImmutableRecord(existing.prepared, validated)) { + throw new StandaloneDeletionJournalError('compromised'); + } + await this.writePhase(validated); + } + + async clear( + rawSessionId: string, + currentRoot: ConversationRootIdentity, + ): Promise { + const sessionId = parseSessionId(rawSessionId); + const existing = await this.read(sessionId, currentRoot); + if (!existing) return; + const identity = await this.inspectJournalDirectory(); + if (!identity) throw new StandaloneDeletionJournalError('compromised'); + await this.unlinkIfExists(this.recordPath(sessionId, 'staged')); + await this.unlinkIfExists(this.recordPath(sessionId, 'prepared')); + await this.assertDirectoryIdentity(this.journalDirectory, identity); + await this.syncDirectory(); + } + + private recordPath(sessionId: string, phase: 'prepared' | 'staged'): string { + return path.join( + this.journalDirectory, + `delete-${sessionId}.${phase}.json`, + ); + } + + private async readPhase( + sessionId: string, + phase: 'prepared' | 'staged', + currentRoot: ConversationRootIdentity, + ): Promise { + const filePath = this.recordPath(sessionId, phase); + let pathStat: Awaited>; + try { + pathStat = await fs.lstat(filePath); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } + this.assertRecordFile(pathStat); + let handle: fs.FileHandle; + try { + handle = await fs.open( + filePath, + fsConstants.O_RDONLY | + (process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0)), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new StandaloneDeletionJournalError('compromised'); + } + throw error; + } + try { + const handleStat = await handle.stat(); + this.assertRecordFile(handleStat); + if ( + handleStat.dev !== pathStat.dev || + handleStat.ino !== pathStat.ino || + handleStat.size !== pathStat.size + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + const serialized = await handle.readFile('utf8'); + let parsed: unknown; + try { + parsed = JSON.parse(serialized); + } catch { + throw new StandaloneDeletionJournalError('compromised'); + } + return parseRecord(parsed, sessionId, phase, currentRoot); + } finally { + await handle.close(); + } + } + + private async writePhase(record: StandaloneDeletionRecordV1): Promise { + const serialized = `${JSON.stringify(record)}\n`; + if (Buffer.byteLength(serialized) > MAX_RECORD_BYTES) { + throw new StandaloneDeletionJournalError('compromised'); + } + const identity = await this.ensureJournalDirectory(); + const target = this.recordPath(record.sessionId, record.phase); + await this.assertPathAbsent(target); + const temporary = path.join( + this.journalDirectory, + `.${path.basename(target)}.${randomUUID()}.tmp`, + ); + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open( + temporary, + fsConstants.O_CREAT | + fsConstants.O_EXCL | + fsConstants.O_WRONLY | + (process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0)), + 0o600, + ); + await handle.writeFile(serialized, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await this.assertDirectoryIdentity(this.journalDirectory, identity); + await this.assertPathAbsent(target); + await fs.rename(temporary, target); + if (process.platform !== 'win32') await fs.chmod(target, 0o600); + await this.assertDirectoryIdentity(this.journalDirectory, identity); + await this.syncDirectory(); + } catch (error) { + await handle?.close().catch(() => undefined); + await fs.unlink(temporary).catch(() => undefined); + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new StandaloneDeletionJournalError('conflict'); + } + throw error; + } + } + + private async ensureJournalDirectory(): Promise { + const owner = await this.inspectPrivateDirectory(this.ownerDirectory); + let created = false; + try { + await fs.mkdir(this.journalDirectory, { mode: 0o700 }); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + if (created && process.platform !== 'win32') { + await fs.chmod(this.journalDirectory, 0o700); + } + await this.assertDirectoryIdentity(this.ownerDirectory, owner); + return this.inspectPrivateDirectory(this.journalDirectory); + } + + private async inspectJournalDirectory(): Promise< + DirectoryIdentity | undefined + > { + try { + return await this.inspectPrivateDirectory(this.journalDirectory); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } + } + + private async inspectPrivateDirectory( + directory: string, + ): Promise { + const stat = await fs.lstat(directory); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (process.platform !== 'win32' && + ((stat.mode & 0o777) !== 0o700 || + (typeof process.getuid === 'function' && + stat.uid !== process.getuid()))) + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + return { + device: stat.dev, + inode: stat.ino, + inodeVerifiable: hasVerifiableInode(stat.ino), + }; + } + + private async assertDirectoryIdentity( + directory: string, + expected: DirectoryIdentity, + ): Promise { + const current = await this.inspectPrivateDirectory(directory); + if (!sameDirectoryIdentity(current, expected)) { + throw new StandaloneDeletionJournalError('compromised'); + } + } + + private assertRecordFile(stat: Awaited>): void { + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 1 || + stat.size <= 0 || + stat.size > MAX_RECORD_BYTES || + (process.platform !== 'win32' && + ((Number(stat.mode) & 0o777) !== 0o600 || + (typeof process.getuid === 'function' && + stat.uid !== process.getuid()))) + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + } + + private async assertPathAbsent(filePath: string): Promise { + try { + await fs.lstat(filePath); + } catch (error) { + if (isMissing(error)) return; + throw error; + } + throw new StandaloneDeletionJournalError('conflict'); + } + + private async pathExists(filePath: string): Promise { + try { + await fs.lstat(filePath); + return true; + } catch (error) { + if (isMissing(error)) return false; + throw error; + } + } + + private async unlinkIfExists(filePath: string): Promise { + try { + await fs.unlink(filePath); + } catch (error) { + if (!isMissing(error)) throw error; + } + } + + private async syncDirectory(): Promise { + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(this.journalDirectory, fsConstants.O_RDONLY); + await handle.sync(); + } catch (error) { + if ( + process.platform !== 'win32' || + !['EACCES', 'EINVAL', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) { + throw error; + } + } finally { + await handle?.close().catch(() => undefined); + } + } +} diff --git a/packages/cli/src/utils/conversation-directory-identity.ts b/packages/cli/src/utils/conversation-directory-identity.ts index d45ec87aa0d..1ae709120d3 100644 --- a/packages/cli/src/utils/conversation-directory-identity.ts +++ b/packages/cli/src/utils/conversation-directory-identity.ts @@ -105,6 +105,12 @@ export function getConversationDirectoryName(storageSessionId: string): string { .digest('hex')}`; } +export function getConversationStagedDirectoryName( + storageSessionId: string, +): string { + return `${getConversationDirectoryName(storageSessionId)}.deleting`; +} + function validateDirectoryStats( stats: Stats, scope: ConversationDirectoryIdentityScope, @@ -171,9 +177,13 @@ function hasExpectedDirectoryIdentity( identity.root.canonicalRoot, expected.root.canonicalRoot, ) && + isSameConversationPath( + identity.root.configuredRoot, + expected.root.configuredRoot, + ) && identity.root.device === expected.root.device && + identity.root.inodeVerifiable === expected.root.inodeVerifiable && (!identity.root.inodeVerifiable || - !expected.root.inodeVerifiable || identity.root.inode === expected.root.inode) ); } @@ -295,13 +305,13 @@ export async function assertExactConversationRootIdentity( return root; } -export async function inspectConversationDirectoryIdentity( +async function inspectConversationNamedDirectoryIdentity( root: ConversationRootIdentity, storageSessionId: string, + name: string, expected?: ConversationDirectoryIdentity, ): Promise { await revalidateConversationRootIdentity(root); - const name = getConversationDirectoryName(storageSessionId); const candidate = join(root.canonicalRoot, name); let before: Stats; try { @@ -354,6 +364,52 @@ export async function inspectConversationDirectoryIdentity( return identity; } +export async function inspectConversationDirectoryIdentity( + root: ConversationRootIdentity, + storageSessionId: string, + expected?: ConversationDirectoryIdentity, +): Promise { + return inspectConversationNamedDirectoryIdentity( + root, + storageSessionId, + getConversationDirectoryName(storageSessionId), + expected, + ); +} + +export async function inspectConversationStagedDirectoryIdentity( + root: ConversationRootIdentity, + storageSessionId: string, +): Promise { + return inspectConversationNamedDirectoryIdentity( + root, + storageSessionId, + getConversationStagedDirectoryName(storageSessionId), + ); +} + +export function isSameConversationDirectoryObject( + identity: ConversationDirectoryIdentity, + expected: ConversationDirectoryIdentity, +): boolean { + const identityInodeVerifiable = hasVerifiableInode(identity.inode); + const expectedInodeVerifiable = hasVerifiableInode(expected.inode); + return ( + identity.storageSessionId === expected.storageSessionId && + identity.device === expected.device && + identityInodeVerifiable === expectedInodeVerifiable && + (!identityInodeVerifiable || identity.inode === expected.inode) && + isSameConversationPath( + identity.root.canonicalRoot, + expected.root.canonicalRoot, + ) && + identity.root.device === expected.root.device && + (!identity.root.inodeVerifiable || + !expected.root.inodeVerifiable || + identity.root.inode === expected.root.inode) + ); +} + export async function materializeConversationDirectoryIdentity( root: ConversationRootIdentity, storageSessionId: string, From b64aab0f2a6fb6d3b3d23b5e9f83da81ef2e77ca Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 26 Aug 2026 23:47:08 +0800 Subject: [PATCH 4/8] feat(cli): Publish standalone session API Co-authored-by: Qwen-Coder --- docs/design/standalone-daemon-sessions.md | 17 +- docs/developers/qwen-serve-protocol.md | 26 + .../2026-08-25-standalone-pr3-daemon-api.md | 79 +- packages/cli/src/serve/capabilities.ts | 6 + .../conversation-workspace.test.ts | 24 + .../conversations/conversation-workspace.ts | 22 + .../standalone-deletion-journal.test.ts | 14 + .../standalone-deletion-journal.ts | 12 +- .../standalone-session-service.test.ts | 879 ++++++++++++- .../standalone-session-service.ts | 1158 ++++++++++++++++- .../src/serve/live/live-task-service.test.ts | 20 +- .../cli/src/serve/live/live-task-service.ts | 6 +- .../serve/routes/standalone-sessions.test.ts | 404 ++++++ .../src/serve/routes/standalone-sessions.ts | 466 +++++++ packages/cli/src/serve/server.test.ts | 47 + packages/cli/src/serve/server.ts | 58 +- .../src/serve/server/error-response.test.ts | 5 + .../cli/src/serve/server/error-response.ts | 6 +- .../cli/src/serve/server/serve-features.ts | 3 + .../cli/src/serve/server/session-export.ts | 7 +- .../cli/src/serve/session-id-admission.ts | 4 +- .../utils/conversation-directory-identity.ts | 13 +- 22 files changed, 3170 insertions(+), 106 deletions(-) create mode 100644 packages/cli/src/serve/routes/standalone-sessions.test.ts create mode 100644 packages/cli/src/serve/routes/standalone-sessions.ts diff --git a/docs/design/standalone-daemon-sessions.md b/docs/design/standalone-daemon-sessions.md index 744eee4a081..336d111be9e 100644 --- a/docs/design/standalone-daemon-sessions.md +++ b/docs/design/standalone-daemon-sessions.md @@ -9,9 +9,12 @@ source of truth for the standalone-session design and delivery plan. not a documentation-only gate: it keeps this document synchronized while 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. +implementation of runtime ownership and ordinary-workspace isolation. +[PR #9341](https://github.com/QwenLM/qwen-code/pull/9341) and +[PR #9978](https://github.com/QwenLM/qwen-code/pull/9978) are the merged PR2A +and PR2B implementation of isolation primitives and the internal standalone +service. The remaining public daemon API, capability, SDK, WebUI, and WebShell +work is delivered in PR3 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 @@ -617,10 +620,12 @@ interface DaemonStandaloneFields { } interface DaemonStandaloneSession - extends DaemonSession, DaemonStandaloneFields {} + extends DaemonSession, + DaemonStandaloneFields {} interface DaemonRestoredStandaloneSession - extends DaemonRestoredSession, DaemonStandaloneFields {} + extends DaemonRestoredSession, + DaemonStandaloneFields {} interface DaemonStandaloneSessionSummary extends DaemonSessionSummary { sourceType: 'standalone'; @@ -1178,7 +1183,7 @@ pending, crashes between child rename and phase persistence, crashes between rollback restore and journal clear, embedded-app capability absence, multi-daemon ownership, and macOS/Linux/Windows path behavior. -Audited estimate against PR2B head: 1,250-2,050 production lines and +Audited estimate against merged PR2B: 1,250-2,050 production lines and 2,500-4,000 test lines, dominated by the durable deletion transaction and fault injection. If production logic exceeds 1,000 added lines, obtain maintainer direction before publication as required by the repository advisory gate. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index c504fdbb108..93237c97ff0 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -203,6 +203,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'extension_batch_activation_v2', 'workspace_settings', 'workspace_init', 'workspace_mcp_restart', 'session_recap', 'session_generation', 'session_btw', 'session_shell_command', + 'standalone_sessions_v1', 'mcp_workspace_pool', 'mcp_pool_restart', 'require_auth', 'allow_origin', 'auth_device_flow', 'permission_mediation', 'prompt_absolute_deadline', 'writer_idle_timeout', @@ -491,6 +492,7 @@ operator diagnostic snapshot documented below. | `workspace_voice` | settings persistence is available, so the legacy primary workspace Voice settings routes are active. | | `workspace_voice_transcription` | the primary workspace has a configured Voice transcription model. | | `session_shell_command` | session shell execution is explicitly enabled. | +| `standalone_sessions_v1` | the daemon has installed the complete standalone-session runtime, lifecycle coordinator, durable deletion journal, managed-directory implementation, and `/standalone/sessions` route family. Direct embeds without the complete dependency graph omit both the routes and this tag. | | `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | | `session_generation` | session generation helpers are available. | | `scheduled_task_session_reuse` | durable scheduled-task session management is active and every managed daemon runtime has installed the callback that lets a task explicitly bind to its current existing session. | @@ -2013,6 +2015,30 @@ This route exposes only stable client-facing fields. It intentionally omits debug internals such as process IDs, spawn args, stderr tails, root URIs, and workspace-folder paths. +### Standalone session lifecycle (`standalone_sessions_v1`) + +When `/capabilities.features` contains `standalone_sessions_v1`, the daemon exposes a process-global route family for top-level standalone sessions owned by its dedicated Conversations runtime. These routes never accept a workspace selector and never fall back to the primary workspace. Direct embeds that cannot construct the complete Conversations ownership, runtime, directory, lifecycle, and deletion-journal dependency graph omit both the feature and all routes below. + +| Route | Request | Success | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /standalone/sessions` | `{ "sessionId": "", "modelServiceId"?: string, "approvalMode"?: ApprovalMode }` | `200` with the standalone session, `context: { "kind": "standalone" }`, and its managed projectless output directory. Creation is prompt-less. | +| `GET /standalone/sessions` | Query: `cursor?`, `size?` (1-100), `archiveState?` (`active` or `archived`) | `200 { sessions, nextCursor?, liveMergeFailed?, truncated? }` | +| `GET /standalone/sessions/:id` | none | `202 { sessionId, state: "creating" }` while local creation is in flight, otherwise `200` with the exact summary. | +| `POST /standalone/sessions/:id/load` | Existing restore options only: `historyPageSize?`, `liveReplayMode?`, `hideInheritedHistory?`, `approvalMode?`; client identity stays in `X-Qwen-Client-Id`. | `200` restored standalone session. | +| `POST /standalone/sessions/:id/resume` | Same restore options as `load`. | `200` restored standalone session without load-history replay. | +| `POST /standalone/sessions/:id/repair-directory` | Empty body or `{}` | `200` with the verified or recreated managed directory. | +| `PATCH /standalone/sessions/:id/metadata` | `{ "displayName": string }` | `200 { sessionId, displayName }` | +| `GET /standalone/sessions/:id/export` | Query: `format=html`, `format=md`, `format=json`, or `format=jsonl` (defaults to `html`). | Existing export content type, filename, and body. | +| `POST /standalone/sessions/archive` | `{ "sessionIds": ["", ...] }` | `200 { archived, alreadyArchived, notFound, errors }` | +| `POST /standalone/sessions/unarchive` | `{ "sessionIds": ["", ...] }` | `200 { unarchived, alreadyActive, notFound, errors }` | +| `POST /standalone/sessions/delete` | `{ "sessionIds": ["", ...] }` | `200 { removed, notFound, errors, fileCleanupPending }` | + +Bodies must be JSON objects with no unknown fields. IDs are RFC UUID v1-v5 values; the daemon canonicalizes them to lowercase. Batch requests contain 1-100 strings and are validated and de-duplicated before mutation. A batch failure is reported as `{ sessionId, code, message }` and does not roll back successful operations on other IDs. `fileCleanupPending` means transcript deletion committed but journal-authorized sidecar or managed-directory cleanup must be retried by reconciliation; the session is already logically removed. + +Only explicit standalone transcripts and the documented top-level legacy compatibility shape are visible. Child, Live, project, worktree, ambiguous, unreadable, or deletion-journaled records fail closed. Creation continues if its HTTP response disconnects; a committed session is not deleted, and the response client is detached. Recover by exact GET followed by load/resume instead of retrying create as an attach. + +Archive, unarchive, repair, rename, and delete share the same per-session lifecycle admission as load/resume and prompts. Delete uses transcript unlink as its durable commit point and a private journal plus atomic managed-directory staging for crash recovery. Recovery restores the directory when the transcript remains intact and completes cleanup when the transcript is gone; any mismatched identity, conflicting path, foreign owner, or ambiguous transcript returns a structured fail-closed error. + ### `POST /session` Spawn a new agent or attach to an existing one (under `sessionScope: 'single'`, the default). diff --git a/docs/plans/2026-08-25-standalone-pr3-daemon-api.md b/docs/plans/2026-08-25-standalone-pr3-daemon-api.md index 116c90b1dcc..d6b559eecc1 100644 --- a/docs/plans/2026-08-25-standalone-pr3-daemon-api.md +++ b/docs/plans/2026-08-25-standalone-pr3-daemon-api.md @@ -1,8 +1,9 @@ # Standalone daemon sessions PR3 implementation plan -**Status:** design-only, provisional on QwenLM/qwen-code PR #9978 head -`cd635a61ca778f067c101efb140557eceeee9afd`. Production implementation must -start from the merge commit on a fresh branch after PR2B is approved and merged. +**Status:** implementation-ready against merged QwenLM/qwen-code PR #9978. +The implementation branch starts from `origin/main` merge commit +`e5f14e33e0b7a0ad35b36acd3b91b3f117a27efb`; the final PR2B head was +`bd3b27f88fef7e0959b044113bf45d56b8b92b75`. **Goal:** publish the complete daemon-only standalone-v1 contract: prompt-less creation, list/get/load/resume/repair, rename/export, archive/unarchive/delete, @@ -17,30 +18,36 @@ updated in the same design change. ## Baseline and implementation gates -The inspected PR2B head is open, mergeable, and currently blocked by requested -changes. It adds the internal `StandaloneSessionService`, managed private -directories, runtime quarantine, lifecycle admission, exact lookup/list, -load/resume, prompt/continue guards, and projectless Live-task adoption. It does -not register standalone routes or advertise a capability. The delta from the -previous inspected head only retains queued deferred-artifact batches after a -failed drain; it does not change any PR3 service or lifecycle seam. - -PR3 implementation does not begin until all of the following hold: - -1. PR #9978 is approved, green, merged, and its merge commit is present in the - current `origin/main`. -2. Every Critical review item on #9978 is classified against the final merged - code. In particular, deferred-artifact replay, quarantine state, directory - replacement, restore compatibility, and rollback ownership must not remain - uncertain. -3. Issue #9490's remaining lock-key/live-entry spelling contract is either fixed - or explicitly proven irrelevant to the UUID v1-v5 standalone lifecycle. - PR3 must not introduce a second normalization rule. -4. The implementation branch rebuilds this inventory against merged `main` and - records any signature drift before editing production code. -5. Maintainers are told that PR3 touches cross-package/core lifecycle code. If - production logic exceeds 1,000 added lines, the repository's advisory gate - applies even though the change is a feature rather than a refactor. +PR2B is merged, approved, and present in `origin/main`. Its final tree adds the +internal `StandaloneSessionService`, managed private directories, runtime +quarantine, lifecycle admission, exact lookup/list, load/resume, +prompt/continue guards, and projectless Live-task adoption. It does not +register standalone routes or advertise a capability. The final review delta +adds restore-failure cleanup, terminal-quarantine propagation, persisted/live +PR-summary merging, canonical Live-task entry IDs, structured unavailable +errors, and ACP compatibility-restorer wiring. These changes strengthen but do +not replace the PR3 seams below. + +PR3 implementation and publication gates are: + +1. **Satisfied:** PR #9978 is approved and merged, and its merge commit is the + current `origin/main` baseline. +2. **Satisfied:** the final PR has no unresolved review threads. The final + restore cleanup, quarantine state, directory replacement, compatibility + restore, and rollback ownership changes preserve the declared fail-closed + behavior. +3. **Satisfied for PR3:** issue #9490 remains open for mixed-case legacy and + UUIDv7 populations, but PR3 accepts only canonicalizable UUID v1-v5 values. + `normalizeSessionIdForLookup()` and the final Live-task/coordinator paths use + the same lower-cased bridge key for that population. PR3 introduces no + second normalization rule; the broader compatibility defect stays in + #9490. +4. **Satisfied:** this inventory was rebuilt against merged `main`; no signature + drift changes the implementation order or ownership boundaries below. +5. **Publication gate:** maintainers are told that PR3 touches + cross-package/core lifecycle code. If production logic exceeds 1,000 added + lines, the repository's advisory gate applies even though the change is a + feature rather than a refactor. ## Locked scope @@ -475,6 +482,7 @@ error serializer; do not inspect message text. | `invalid_request` | 400 | no | | `standalone_session_not_found` | 404 | no | | `standalone_session_conflict` | 409 | depends on pending/in-flight state | +| `standalone_session_operation_failed` | 500 | no; refresh state before deciding whether to retry | | `session_archived` | 409 | no | | `session_busy` | 409 | yes | | `working_directory_missing` | 409 | yes through repair | @@ -702,7 +710,20 @@ consecutive clean passes; any fix resets the count. sequencing, estimates, exclusions, verification breadth, and simpler alternatives. No new actionable issue was found. +### Merged-head reconciliation + +- Rebuilt the seam inventory against merged PR2B commit `e5f14e33e0` and final + PR head `bd3b27f88f`. The final review fixes strengthen cleanup, quarantine, + summary merging, Live-task canonicalization, unavailable errors, and ACP + compatibility restore without changing PR3's route, journal, or commit-point + ownership. +- Verified that PR #9978 is approved with no unresolved review threads and that + its merge requirements completed successfully. +- Classified issue #9490's remaining mixed-case legacy/UUIDv7 lock-key defect as + out of scope for PR3's UUID v1-v5 public contract. The final PR2B Live paths + already use `normalizeSessionIdForLookup()` for every PR3-valid ID. + After these changes, no additional clear scope, compatibility, failure-path, -testing, or simpler-design issue was found under the inspected #9978 head. The -remaining blockers are upstream interface drift and maintainer review, not an +testing, or simpler-design issue was found against merged PR2B. The remaining +publication gate is maintainer awareness, not upstream interface drift or an unresolved PR3 product decision. diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 33c4cd6c331..dc042a13bf0 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -123,6 +123,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_metadata: { since: 'v1' }, session_organization: { since: 'v1' }, session_export: { since: 'v1' }, + standalone_sessions_v1: { since: 'v1' }, session_transcript: { since: 'v1' }, session_transcript_pagination: { since: 'v1' }, // Daemon supports the MCP client guardrail surface: an in-process @@ -508,6 +509,7 @@ export interface AdvertiseFeatureToggles { acpHttpEnabled?: boolean; realtimeVoiceEnabled?: boolean; workspaceTrustHotReloadAvailable?: boolean; + standaloneSessionsAvailable?: boolean; } /** @@ -547,6 +549,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< (toggles: AdvertiseFeatureToggles) => boolean > = new Map boolean>([ ['require_auth', (toggles) => toggles.requireAuth === true], + [ + 'standalone_sessions_v1', + (toggles) => toggles.standaloneSessionsAvailable === true, + ], ['mcp_workspace_pool', (toggles) => toggles.mcpPoolActive === true], ['mcp_pool_restart', (toggles) => toggles.mcpPoolActive === true], [ diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index 2ddd653077b..6a2503f0f8e 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -573,6 +573,30 @@ describe('Live conversation workspace root', () => { ).resolves.toMatchObject({ size: 19 }); }); + it('reconstructs only internally consistent deletion expectations', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const prepared = await workspace.prepareStandaloneDirectory('standalone'); + + await expect( + workspace.createStandaloneDeletionExpectation('standalone', { + device: prepared.identity.device, + inode: prepared.identity.inode, + inodeVerifiable: prepared.identity.inode !== 0, + }), + ).resolves.toEqual(prepared.identity); + await expect( + workspace.createStandaloneDeletionExpectation('standalone', { + device: prepared.identity.device, + inode: 0, + inodeVerifiable: true, + }), + ).rejects.toMatchObject({ + name: 'ConversationDirectoryIdentityError', + reason: 'unexpected_identity', + }); + }); + it('removes only a matching staged standalone directory', async () => { const home = await tempHome(); const workspace = new ConversationWorkspace({ homeDir: home }); diff --git a/packages/cli/src/serve/conversations/conversation-workspace.ts b/packages/cli/src/serve/conversations/conversation-workspace.ts index e3545c046a6..1beabc96a0a 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.ts @@ -398,6 +398,28 @@ export class ConversationWorkspace { } } + async createStandaloneDeletionExpectation( + storageSessionId: string, + recorded: { device: number; inode: number; inodeVerifiable: boolean }, + ): Promise { + if (recorded.inodeVerifiable !== (recorded.inode !== 0)) { + throw new ConversationDirectoryIdentityError( + 'child', + 'unexpected_identity', + ); + } + const root = await this.revalidateStandaloneRoot(); + const name = getConversationDirectoryName(storageSessionId); + return { + root, + storageSessionId, + name, + canonicalPath: join(root.canonicalRoot, name), + device: recorded.device, + inode: recorded.inode, + }; + } + async stageStandaloneDirectory( storageSessionId: string, expected: ConversationDirectoryIdentity, diff --git a/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts b/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts index 6e6f88c10f1..afa290d771b 100644 --- a/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts +++ b/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts @@ -164,6 +164,20 @@ describe('StandaloneDeletionJournal', () => { }); }); + it('fails closed when the journal directory loses its private identity', async () => { + if (process.platform === 'win32') return; + const root = await workspace.getRoot(); + await journal.writePrepared(await makeRecord('prepared'), root); + await fs.chmod(path.dirname(journalPath('prepared')), 0o755); + + await expect(journal.hasRecord(SESSION_ID)).rejects.toMatchObject({ + reason: 'compromised', + }); + await expect(journal.read(SESSION_ID, root)).rejects.toMatchObject({ + reason: 'compromised', + }); + }); + it('rejects a record tied to another root identity', async () => { const root = await workspace.getRoot(); const record = await makeRecord('prepared'); diff --git a/packages/cli/src/serve/conversations/standalone-deletion-journal.ts b/packages/cli/src/serve/conversations/standalone-deletion-journal.ts index 14d5df836c4..b94bcf341fd 100644 --- a/packages/cli/src/serve/conversations/standalone-deletion-journal.ts +++ b/packages/cli/src/serve/conversations/standalone-deletion-journal.ts @@ -268,10 +268,13 @@ export class StandaloneDeletionJournal { async hasRecord(rawSessionId: string): Promise { const sessionId = parseSessionId(rawSessionId); - return ( + const identity = await this.inspectJournalDirectory(); + if (!identity) return false; + const exists = (await this.pathExists(this.recordPath(sessionId, 'prepared'))) || - (await this.pathExists(this.recordPath(sessionId, 'staged'))) - ); + (await this.pathExists(this.recordPath(sessionId, 'staged'))); + await this.assertDirectoryIdentity(this.journalDirectory, identity); + return exists; } async listSessionIds(limit = 32): Promise { @@ -298,8 +301,11 @@ export class StandaloneDeletionJournal { currentRoot: ConversationRootIdentity, ): Promise { const sessionId = parseSessionId(rawSessionId); + const identity = await this.inspectJournalDirectory(); + if (!identity) return undefined; const prepared = await this.readPhase(sessionId, 'prepared', currentRoot); const staged = await this.readPhase(sessionId, 'staged', currentRoot); + await this.assertDirectoryIdentity(this.journalDirectory, identity); if (!prepared && !staged) return undefined; if (!prepared || (staged && !sameImmutableRecord(prepared, staged))) { throw new StandaloneDeletionJournalError('compromised'); diff --git a/packages/cli/src/serve/conversations/standalone-session-service.test.ts b/packages/cli/src/serve/conversations/standalone-session-service.test.ts index 9beff9199c7..99cd68ecf68 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.test.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.test.ts @@ -28,6 +28,10 @@ import { StandaloneSessionService, type StandaloneSessionServiceOptions, } from './standalone-session-service.js'; +import type { + StandaloneDeletionJournal, + StandaloneDeletionJournalEntry, +} from './standalone-deletion-journal.js'; const { listWorkspaceSessionsForResponse } = vi.hoisted(() => ({ listWorkspaceSessionsForResponse: vi.fn(), @@ -70,8 +74,10 @@ interface Harness { | 'getSessionEventEpoch' | 'getSessionLastEventId' | 'sendPrompt' + | 'updateSessionMetadata' | 'killSession' | 'detachClient' + | 'deleteSessionAttachments' | 'markSessionCatalogChanged' >]: ReturnType; }; @@ -82,7 +88,23 @@ interface Harness { ensureStandaloneDirectory: ReturnType; lifecycle: SessionArchiveCoordinator; quarantineRuntime: ReturnType; + hasForeignSessionOwner: ReturnType; invalidateSessionListCache: ReturnType; + deletionJournal: { + [K in keyof Pick< + StandaloneDeletionJournal, + | 'listSessionIds' + | 'hasRecord' + | 'read' + | 'writePrepared' + | 'writeStaged' + | 'clear' + >]: ReturnType; + }; + inspectStandaloneDeletionPaths: ReturnType; + stageStandaloneDirectory: ReturnType; + restoreStagedStandaloneDirectory: ReturnType; + removeStagedStandaloneDirectory: ReturnType; } function createHarness(): Harness { @@ -100,7 +122,7 @@ function createHarness(): Harness { throw new SessionNotFoundError(sessionId); }), getSessionCurrentCwd: vi.fn(() => identity.canonicalPath), - restoreStandaloneSession: vi.fn(async () => { + restoreStandaloneSession: vi.fn(async (_action, request) => { restoredSummary = { sessionId, workspaceCwd: root.canonicalRoot, @@ -114,6 +136,9 @@ function createHarness(): Harness { workspaceCwd: root.canonicalRoot, currentCwd: root.canonicalRoot, attached: false, + ...(request.clientId !== undefined + ? { clientId: request.clientId } + : {}), sourceType: 'standalone', state: {}, }; @@ -131,8 +156,10 @@ function createHarness(): Harness { context?.onPromptAdmitted?.(); return Promise.resolve({ stopReason: 'end_turn' }); }), + updateSessionMetadata: vi.fn((_id, metadata) => metadata), killSession: vi.fn(async () => true), detachClient: vi.fn(async () => undefined), + deleteSessionAttachments: vi.fn(async () => undefined), markSessionCatalogChanged: vi.fn(), }; const runtime = { @@ -153,6 +180,7 @@ function createHarness(): Harness { service.freezeForTerminalQuarantine(candidate); }); const invalidateSessionListCache = vi.fn(); + const hasForeignSessionOwner = vi.fn(async () => false); const ensureRuntime = vi.fn(async () => runtime); const inspectStandaloneDirectory = vi.fn(async () => ({ status: 'ready' as const, @@ -162,6 +190,25 @@ function createHarness(): Harness { status: 'recreated' as const, identity, })); + const deletionJournal = { + listSessionIds: vi.fn(async () => []), + hasRecord: vi.fn(async () => false), + read: vi.fn(async () => undefined), + writePrepared: vi.fn(async () => undefined), + writeStaged: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + }; + const inspectStandaloneDeletionPaths = vi.fn(async () => ({ + status: 'normal' as const, + identity, + })); + const stageStandaloneDirectory = vi.fn(async () => ({ + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + })); + const restoreStagedStandaloneDirectory = vi.fn(async () => identity); + const removeStagedStandaloneDirectory = vi.fn(async () => undefined); const lifecycle = new SessionArchiveCoordinator(); const options: StandaloneSessionServiceOptions = { ensureRuntime, @@ -182,12 +229,19 @@ function createHarness(): Harness { })), inspectStandaloneDirectory, ensureStandaloneDirectory, + inspectStandaloneDeletionPaths, + createStandaloneDeletionExpectation: vi.fn(async () => identity), + stageStandaloneDirectory, + restoreStagedStandaloneDirectory, + removeStagedStandaloneDirectory, }, + deletionJournal: deletionJournal as unknown as StandaloneDeletionJournal, lifecycle, requestedSessionIdAdmission: { reserveCreate: vi.fn(async () => reservation), reserveRestore: vi.fn(() => restoreReservation), }, + hasForeignSessionOwner, invalidateSessionListCache, }; const service = new StandaloneSessionService(options); @@ -202,7 +256,13 @@ function createHarness(): Harness { ensureStandaloneDirectory, lifecycle, quarantineRuntime, + hasForeignSessionOwner, invalidateSessionListCache, + deletionJournal, + inspectStandaloneDeletionPaths, + stageStandaloneDirectory, + restoreStagedStandaloneDirectory, + removeStagedStandaloneDirectory, }; } @@ -247,11 +307,461 @@ function mockActiveLegacyStandalone(storageSessionId = sessionId): void { ).mockResolvedValue({ sourceType: 'default' }); } +function mockArchivedStandalone(storageSessionId = sessionId): void { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(storageSessionId); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'archived', + ); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadataIfReadable', + ).mockResolvedValue({ sourceType: 'standalone' }); +} + +function mockWriterLease(): { + assertOwnedAndUnchanged: ReturnType; + release: ReturnType; +} { + const lease = { + assertOwnedAndUnchanged: vi.fn(async () => undefined), + release: vi.fn(async () => undefined), + }; + vi.spyOn( + SessionService.prototype, + 'acquireSessionWriterLease', + ).mockResolvedValue(lease as never); + vi.spyOn( + SessionService.prototype, + 'acquireSessionMaintenanceLease', + ).mockResolvedValue(lease as never); + return lease; +} + +function deletionEntry( + phase: 'prepared' | 'staged' = 'staged', +): StandaloneDeletionJournalEntry { + const prepared = { + version: 1 as const, + phase: 'prepared' as const, + sessionId, + storageSessionId: sessionId, + transcriptLocation: 'active' as const, + root: { + canonicalPath: root.canonicalRoot, + device: root.device, + inode: root.inode, + inodeVerifiable: root.inodeVerifiable, + }, + directory: { + kind: 'present' as const, + normalName: identity.name, + stagedName: `${identity.name}.deleting`, + device: identity.device, + inode: identity.inode, + inodeVerifiable: true, + }, + }; + return phase === 'prepared' + ? { prepared } + : { prepared, staged: { ...prepared, phase: 'staged' } }; +} + afterEach(() => { vi.restoreAllMocks(); }); describe('StandaloneSessionService', () => { + it('creates a durable standalone session without admitting a prompt', async () => { + mockDurableStandalone(); + const harness = createHarness(); + + const created = await harness.service.create({ sessionId }); + + expect(created).toMatchObject({ + session: { sessionId, sourceType: 'standalone' }, + projectlessOutputDirectory: identity.canonicalPath, + workingDirectory: { state: 'ready' }, + }); + expect(harness.bridge.sendPrompt).not.toHaveBeenCalled(); + expect(harness.bridge.spawnStandaloneSession).toHaveBeenCalledOnce(); + expect(harness.reservation.release).toHaveBeenCalledOnce(); + }); + + it('fails read-only exact lookup closed while deletion is journaled', async () => { + mockActiveStandalone(); + vi.spyOn(SessionService.prototype, 'getSessionListItem').mockResolvedValue({ + sessionId, + cwd: root.canonicalRoot, + startTime: '2026-08-24T00:00:00.000Z', + mtime: Date.parse('2026-08-24T00:00:00.000Z'), + filePath: '/runtime/chats/session.jsonl', + messageCount: 1, + prompt: 'hello', + }); + const harness = createHarness(); + harness.deletionJournal.hasRecord.mockResolvedValueOnce(true); + + await expect(harness.service.get(sessionId)).rejects.toMatchObject({ + code: 'standalone_session_conflict', + retryable: true, + }); + + expect(harness.deletionJournal.read).not.toHaveBeenCalled(); + }); + + it('renames a verified live standalone through the bridge', async () => { + mockActiveStandalone(); + const harness = createHarness(); + harness.bridge.getSessionSummary.mockReturnValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + sourceType: 'standalone', + clientCount: 1, + hasActivePrompt: false, + }); + + await expect( + harness.service.rename(sessionId, 'Named session', 'client-1'), + ).resolves.toEqual({ sessionId, displayName: 'Named session' }); + + expect(harness.bridge.updateSessionMetadata).toHaveBeenCalledWith( + sessionId, + { displayName: 'Named session' }, + { clientId: 'client-1' }, + ); + }); + + it.each(['', ' ', 'bad\nname', 'x'.repeat(257)])( + 'rejects an invalid standalone display name before runtime work', + async (displayName) => { + const harness = createHarness(); + + await expect( + harness.service.rename(sessionId, displayName), + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(harness.ensureRuntime).not.toHaveBeenCalled(); + }, + ); + + it('archives and unarchives only top-level standalone transcripts', async () => { + const archiveHarness = createHarness(); + mockActiveStandalone(); + const archiveLease = mockWriterLease(); + vi.spyOn(SessionService.prototype, 'archiveSessions').mockResolvedValue({ + archived: [sessionId], + alreadyArchived: [], + resolvedConflicts: [], + notFound: [], + errors: [], + }); + + await expect(archiveHarness.service.archive([sessionId])).resolves.toEqual({ + archived: [sessionId], + alreadyArchived: [], + notFound: [], + errors: [], + }); + expect(archiveLease.release).toHaveBeenCalledOnce(); + + vi.restoreAllMocks(); + const unarchiveHarness = createHarness(); + mockArchivedStandalone(); + const unarchiveLease = mockWriterLease(); + vi.spyOn(SessionService.prototype, 'unarchiveSessions').mockResolvedValue({ + unarchived: [sessionId], + alreadyActive: [], + resolvedConflicts: [], + notFound: [], + errors: [], + }); + + await expect( + unarchiveHarness.service.unarchive([sessionId]), + ).resolves.toEqual({ + unarchived: [sessionId], + alreadyActive: [], + notFound: [], + errors: [], + }); + expect(unarchiveLease.release).toHaveBeenCalledOnce(); + }); + + it('does not misreport an unexpected batch failure as a session conflict', async () => { + mockActiveStandalone(); + const harness = createHarness(); + mockWriterLease(); + vi.spyOn(SessionService.prototype, 'archiveSessions').mockRejectedValue( + new Error('storage unavailable'), + ); + + await expect(harness.service.archive([sessionId])).resolves.toMatchObject({ + archived: [], + errors: [ + { + sessionId, + code: 'standalone_session_operation_failed', + }, + ], + }); + }); + + it('journals, stages, commits, and cleans a standalone deletion', async () => { + mockActiveStandalone(); + const harness = createHarness(); + const lease = mockWriterLease(); + vi.spyOn( + SessionService.prototype, + 'removeSessionTranscriptForLifecycle', + ).mockResolvedValue(true); + vi.spyOn( + SessionService.prototype, + 'cleanupRemovedSessionState', + ).mockResolvedValue(); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [], + }); + + expect(harness.deletionJournal.writePrepared).toHaveBeenCalledOnce(); + expect(harness.stageStandaloneDirectory).toHaveBeenCalledWith( + sessionId, + identity, + ); + expect(harness.deletionJournal.writeStaged).toHaveBeenCalledOnce(); + expect(harness.removeStagedStandaloneDirectory).toHaveBeenCalledWith( + sessionId, + identity, + ); + expect(harness.bridge.deleteSessionAttachments).toHaveBeenCalledWith( + sessionId, + { assertCanCommit: expect.any(Function) }, + ); + expect(harness.deletionJournal.clear).toHaveBeenCalledOnce(); + expect(lease.release).toHaveBeenCalledOnce(); + }); + + it('does not begin deletion while another runtime owns the id', async () => { + mockActiveStandalone(); + const harness = createHarness(); + harness.hasForeignSessionOwner.mockResolvedValueOnce(true); + + await expect(harness.service.delete([sessionId])).resolves.toMatchObject({ + removed: [], + errors: [ + { + sessionId, + code: 'standalone_session_conflict', + }, + ], + }); + + expect(harness.deletionJournal.writePrepared).not.toHaveBeenCalled(); + expect(harness.bridge.killSession).not.toHaveBeenCalled(); + }); + + it('retains deletion evidence when post-commit cleanup fails', async () => { + mockActiveStandalone(); + const harness = createHarness(); + mockWriterLease(); + vi.spyOn( + SessionService.prototype, + 'removeSessionTranscriptForLifecycle', + ).mockResolvedValue(true); + vi.spyOn( + SessionService.prototype, + 'cleanupRemovedSessionState', + ).mockRejectedValue(new Error('sidecar I/O failed')); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [sessionId], + }); + + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + expect(harness.removeStagedStandaloneDirectory).toHaveBeenCalledOnce(); + }); + + it('retains deletion evidence when attachment cleanup fails', async () => { + mockActiveStandalone(); + const harness = createHarness(); + mockWriterLease(); + vi.spyOn( + SessionService.prototype, + 'removeSessionTranscriptForLifecycle', + ).mockResolvedValue(true); + vi.spyOn( + SessionService.prototype, + 'cleanupRemovedSessionState', + ).mockResolvedValue(); + harness.bridge.deleteSessionAttachments.mockRejectedValueOnce( + new Error('attachment I/O failed'), + ); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [sessionId], + }); + + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + }); + + it('rolls back a staged directory when recovery finds the transcript intact', async () => { + mockActiveStandalone(); + const harness = createHarness(); + mockWriterLease(); + harness.deletionJournal.read.mockResolvedValueOnce( + deletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'staged', + identity: { + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + }, + }); + + await expect(harness.service.create({ sessionId })).rejects.toMatchObject({ + code: 'standalone_session_conflict', + }); + + expect(harness.restoreStagedStandaloneDirectory).toHaveBeenCalledWith( + sessionId, + identity, + ); + expect(harness.deletionJournal.clear).toHaveBeenCalledWith(sessionId, root); + }); + + it('finishes staged cleanup when recovery finds the transcript committed', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + const cleanup = vi + .spyOn(SessionService.prototype, 'cleanupRemovedSessionState') + .mockResolvedValue(); + const harness = createHarness(); + mockWriterLease(); + harness.deletionJournal.read.mockResolvedValueOnce( + deletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'staged', + identity: { + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + }, + }); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [], + }); + + expect(harness.removeStagedStandaloneDirectory).toHaveBeenCalledWith( + sessionId, + identity, + ); + expect(cleanup).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ assertCanMutate: expect.any(Function) }), + ); + expect(harness.bridge.deleteSessionAttachments).toHaveBeenCalledWith( + sessionId, + { assertCanCommit: expect.any(Function) }, + ); + expect(harness.deletionJournal.clear).toHaveBeenCalledWith(sessionId, root); + }); + + it('fails recovery closed when another runtime owns the journaled id', async () => { + const harness = createHarness(); + harness.deletionJournal.read.mockResolvedValueOnce( + deletionEntry() as never, + ); + harness.hasForeignSessionOwner.mockResolvedValueOnce(true); + + await expect(harness.service.delete([sessionId])).resolves.toMatchObject({ + removed: [], + errors: [ + { + sessionId, + code: 'deletion_recovery_compromised', + }, + ], + }); + + expect(harness.inspectStandaloneDeletionPaths).not.toHaveBeenCalled(); + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + }); + + it('fails closed when deletion recovery sees conflicting directory paths', async () => { + mockActiveStandalone(); + const harness = createHarness(); + harness.deletionJournal.read.mockResolvedValueOnce( + deletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'compromised', + }); + + await expect(harness.service.create({ sessionId })).rejects.toMatchObject({ + code: 'deletion_recovery_compromised', + retryable: false, + }); + + expect(harness.restoreStagedStandaloneDirectory).not.toHaveBeenCalled(); + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + }); + + it('restores staged state when transcript deletion fails before commit', async () => { + mockActiveStandalone(); + const harness = createHarness(); + mockWriterLease(); + vi.spyOn( + SessionService.prototype, + 'removeSessionTranscriptForLifecycle', + ).mockRejectedValue(new Error('unlink failed')); + harness.inspectStandaloneDeletionPaths + .mockResolvedValueOnce({ status: 'normal', identity }) + .mockResolvedValueOnce({ + status: 'staged', + identity: { + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + }, + }); + + await expect(harness.service.delete([sessionId])).resolves.toMatchObject({ + removed: [], + errors: [ + { + sessionId, + code: 'transcript_deletion_failed', + }, + ], + }); + + expect(harness.restoreStagedStandaloneDirectory).toHaveBeenCalledWith( + sessionId, + identity, + ); + expect(harness.deletionJournal.clear).toHaveBeenCalledWith(sessionId, root); + }); + it('creates a depth-1 child with explicit standalone source and durable parent lineage', async () => { const childSessionId = '22222222-2222-4222-8222-222222222222'; const storageParentSessionId = sessionId.toUpperCase(); @@ -334,28 +844,95 @@ describe('StandaloneSessionService', () => { new Promise((spawnResolve) => { releaseSpawn = () => { spawnResolve({ - sessionId, + sessionId, + workspaceCwd: root.canonicalRoot, + attached: false, + sourceType: 'standalone', + sourcePersisted: true, + }); + }; + resolve(); + }), + ); + }); + const creating = harness.service.createWithInitialPrompt( + { sessionId }, + 'first task', + ); + await spawnStarted; + + await expect(harness.service.get(sessionId.toUpperCase())).resolves.toEqual( + { sessionId, state: 'creating' }, + ); + expect(harness.ensureRuntime).toHaveBeenCalledOnce(); + expect(getSessionListItem).not.toHaveBeenCalled(); + + releaseSpawn(); + await creating; + }); + + it('does not expose an in-flight child as a top-level create', async () => { + const childSessionId = '22222222-2222-4222-8222-222222222222'; + const storageParentSessionId = sessionId.toUpperCase(); + vi.spyOn(SessionService.prototype, 'findSessionIdIgnoringCase') + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(sessionId) + .mockResolvedValueOnce(storageParentSessionId) + .mockResolvedValueOnce(undefined) + .mockResolvedValue(childSessionId); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'active', + ); + vi.spyOn(SessionService.prototype, 'readCreationMetadataIfReadable') + .mockResolvedValueOnce({ sourceType: 'standalone' }) + .mockResolvedValueOnce({ sourceType: 'standalone' }) + .mockResolvedValue({ + sourceType: 'standalone', + parentSessionId: storageParentSessionId, + }); + const harness = createHarness(); + await harness.service.createWithInitialPrompt({ sessionId }, 'parent task'); + harness.bridge.getSessionSummary.mockReturnValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + clientCount: 0, + hasActivePrompt: false, + }); + let releaseSpawn!: () => void; + const spawnStarted = new Promise((resolve) => { + harness.bridge.spawnStandaloneSession.mockImplementationOnce( + () => + new Promise((spawnResolve) => { + releaseSpawn = () => { + spawnResolve({ + sessionId: childSessionId, workspaceCwd: root.canonicalRoot, attached: false, sourceType: 'standalone', sourcePersisted: true, + parentSessionPersisted: true, }); }; resolve(); }), ); }); - const creating = harness.service.createWithInitialPrompt( - { sessionId }, - 'first task', + const creating = harness.service.createChildWithInitialPrompt( + { + sessionId: childSessionId, + parentSessionId: sessionId, + promptId: 'prompt-child', + }, + 'child task', ); await spawnStarted; - await expect(harness.service.get(sessionId.toUpperCase())).resolves.toEqual( - { sessionId, state: 'creating' }, - ); - expect(harness.ensureRuntime).toHaveBeenCalledOnce(); - expect(getSessionListItem).not.toHaveBeenCalled(); + await expect(harness.service.get(childSessionId)).rejects.toMatchObject({ + code: 'standalone_session_not_found', + sessionId: childSessionId, + }); releaseSpawn(); await creating; @@ -374,10 +951,6 @@ describe('StandaloneSessionService', () => { SessionService.prototype, 'readCreationMetadataIfReadable', ).mockResolvedValue({ sourceType: 'standalone' }); - vi.spyOn( - SessionService.prototype, - 'readCreationMetadataIfReadable', - ).mockResolvedValue({ sourceType: 'standalone' }); vi.spyOn(SessionService.prototype, 'getSessionListItem').mockResolvedValue({ sessionId: storageSessionId, cwd: root.canonicalRoot, @@ -424,6 +997,30 @@ describe('StandaloneSessionService', () => { expect(harness.bridge.getSessionSummary).not.toHaveBeenCalled(); }); + it('does not reveal a child transcript through exact top-level lookup', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(sessionId); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'active', + ); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadataIfReadable', + ).mockResolvedValue({ + sourceType: 'standalone', + parentSessionId: '22222222-2222-4222-8222-222222222222', + }); + const harness = createHarness(); + + await expect(harness.service.get(sessionId)).rejects.toMatchObject({ + code: 'standalone_session_not_found', + sessionId, + }); + expect(harness.bridge.getSessionSummary).not.toHaveBeenCalled(); + }); + it('merges only volatile live state onto authoritative persisted identity', async () => { vi.spyOn( SessionService.prototype, @@ -474,6 +1071,45 @@ describe('StandaloneSessionService', () => { }); }); + it('rejects a live worktree collision during exact lookup', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(sessionId); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'active', + ); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadataIfReadable', + ).mockResolvedValue({ sourceType: 'standalone' }); + vi.spyOn(SessionService.prototype, 'getSessionListItem').mockResolvedValue({ + sessionId, + cwd: root.canonicalRoot, + startTime: '2026-08-24T00:00:00.000Z', + mtime: Date.parse('2026-08-24T01:00:00.000Z'), + prompt: 'persisted title', + filePath: '/transcripts/session.jsonl', + sourceType: 'standalone', + isArchived: false, + }); + const harness = createHarness(); + harness.bridge.getSessionSummary.mockReturnValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + clientCount: 0, + hasActivePrompt: false, + worktree: { slug: 'other', path: '/other', branch: 'other' }, + }); + + await expect(harness.service.get(sessionId)).rejects.toMatchObject({ + code: 'standalone_session_conflict', + sessionId, + }); + }); + it('merges persisted PR bindings with live bindings by PR number', async () => { const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'standalone-prs-')); const sidecar = path.join(temp, `${sessionId}.pr.json`); @@ -566,13 +1202,22 @@ describe('StandaloneSessionService', () => { workspaceCwd: '/untrusted-root', createdAt: '2026-08-24T01:00:00.000Z', sourceType: 'standalone', - sourceId: 'must-be-stripped', - parentSessionId: sessionId, clientCount: 0, hasActivePrompt: false, }, ], }); + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(uniqueSessionId.toUpperCase()); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'active', + ); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadataIfReadable', + ).mockResolvedValue({ sourceType: 'standalone' }); const harness = createHarness(); await expect(harness.service.list()).resolves.toEqual({ @@ -596,6 +1241,47 @@ describe('StandaloneSessionService', () => { ); }); + it('omits a list row carrying foreign live isolation state', async () => { + listWorkspaceSessionsForResponse.mockResolvedValueOnce({ + sessions: [ + { + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + clientCount: 0, + hasActivePrompt: false, + branch: { name: 'foreign', baseBranch: 'main' }, + }, + ], + }); + const harness = createHarness(); + + await expect(harness.service.list()).resolves.toEqual({ sessions: [] }); + }); + + it('drops a catalog item deleted before its shared revalidation', async () => { + listWorkspaceSessionsForResponse.mockResolvedValueOnce({ + sessions: [ + { + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + clientCount: 0, + hasActivePrompt: false, + }, + ], + }); + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + const harness = createHarness(); + + await expect(harness.service.list()).resolves.toEqual({ sessions: [] }); + }); + it('maps case-only persisted ambiguity to a standalone conflict', async () => { vi.spyOn( SessionService.prototype, @@ -610,6 +1296,139 @@ describe('StandaloneSessionService', () => { expect(harness.bridge.getSessionSummary).not.toHaveBeenCalled(); }); + it('does not restore a child transcript through the top-level API', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(sessionId); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'active', + ); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadataIfReadable', + ).mockResolvedValue({ + sourceType: 'standalone', + parentSessionId: '22222222-2222-4222-8222-222222222222', + }); + const harness = createHarness(); + + await expect(harness.service.load(sessionId)).rejects.toMatchObject({ + code: 'standalone_session_not_found', + sessionId, + }); + await expect(harness.service.resume(sessionId)).rejects.toMatchObject({ + code: 'standalone_session_not_found', + sessionId, + }); + expect(harness.bridge.restoreStandaloneSession).not.toHaveBeenCalled(); + }); + + it('restores a child transcript for an internal task', async () => { + const parentSessionId = '22222222-2222-4222-8222-222222222222'; + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(sessionId); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'active', + ); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadataIfReadable', + ).mockImplementation(async (targetSessionId) => + targetSessionId === sessionId + ? { + sourceType: 'standalone', + parentSessionId, + } + : undefined, + ); + const harness = createHarness(); + harness.bridge.restoreStandaloneSession.mockResolvedValueOnce({ + sessionId, + workspaceCwd: root.canonicalRoot, + sourceType: 'standalone', + parentSessionId, + attached: false, + hasActivePrompt: false, + clientCount: 0, + }); + harness.bridge.getSessionSummary + .mockImplementationOnce(() => { + throw new SessionNotFoundError(sessionId); + }) + .mockReturnValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + parentSessionId, + clientCount: 0, + hasActivePrompt: false, + }); + + await expect( + harness.service.resumeForInternalTask(sessionId), + ).resolves.toMatchObject({ + sessionId, + sourceType: 'standalone', + parentSessionId, + context: { kind: 'standalone' }, + }); + expect(harness.bridge.restoreStandaloneSession).toHaveBeenCalledWith( + 'resume', + { + sessionId, + workspaceCwd: root.canonicalRoot, + parentSessionId, + }, + ); + }); + + it('repairs through prompt-less resume and detaches its response client', async () => { + mockActiveStandalone(); + const harness = createHarness(); + + await expect(harness.service.repairDirectory(sessionId)).resolves.toEqual({ + sessionId, + projectlessOutputDirectory: identity.canonicalPath, + workingDirectory: { state: 'ready' }, + }); + + expect(harness.bridge.restoreStandaloneSession).toHaveBeenCalledWith( + 'resume', + expect.not.objectContaining({ historyReplay: expect.anything() }), + ); + expect(harness.bridge.detachClient).toHaveBeenCalledWith( + sessionId, + expect.any(String), + ); + expect(harness.bridge.sendPrompt).not.toHaveBeenCalled(); + }); + + it('preserves the data-loss warning when repair recreates a directory', async () => { + mockActiveStandalone(); + const harness = createHarness(); + harness.inspectStandaloneDirectory.mockResolvedValueOnce({ + status: 'missing', + }); + + await expect(harness.service.repairDirectory(sessionId)).resolves.toEqual({ + sessionId, + projectlessOutputDirectory: identity.canonicalPath, + workingDirectory: { + state: 'recreated', + warnings: [expect.stringContaining('could not be recovered')], + }, + }); + + expect(harness.bridge.detachClient).toHaveBeenCalledWith( + sessionId, + expect.any(String), + ); + }); + it('cold-loads, binds, and reports a recreated missing directory', async () => { mockActiveStandalone(sessionId.toUpperCase()); const harness = createHarness(); @@ -1305,6 +2124,34 @@ describe('StandaloneSessionService', () => { expect(harness.reservation.release).not.toHaveBeenCalled(); }); + it('closes a fresh creation carrying foreign isolation state before quarantining', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + const harness = createHarness(); + harness.bridge.spawnStandaloneSession.mockResolvedValueOnce({ + sessionId, + workspaceCwd: root.canonicalRoot, + attached: false, + sourceType: 'standalone', + sourcePersisted: true, + worktree: { slug: 'foreign', path: '/foreign', branch: 'foreign' }, + }); + + await expect( + harness.service.createWithInitialPrompt({ sessionId }, 'do the task'), + ).rejects.toMatchObject({ + code: 'standalone_creation_outcome_unknown', + }); + + expect(harness.bridge.killSession).toHaveBeenCalledWith(sessionId, { + requireZeroAttaches: true, + }); + expect(harness.quarantineRuntime).toHaveBeenCalledWith(harness.runtime); + expect(harness.reservation.release).not.toHaveBeenCalled(); + }); + it('detaches an unexpected attach result before quarantining', async () => { vi.spyOn( SessionService.prototype, diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index 6408746bb67..eea9e0779ad 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -23,9 +23,13 @@ import { STANDALONE_SESSION_SOURCE_TYPE } from '@qwen-code/acp-bridge/sessionSou import { readSessionPrs, SessionIdCaseConflictError, + SessionTranscriptChangedError, + SessionWriterUnavailableError, type ApprovalMode, type SessionArchiveState, type SessionListItem, + type SessionService, + type SessionWriterLease, } from '@qwen-code/qwen-code-core'; import { parseCallerSuppliedSessionId, @@ -37,11 +41,17 @@ import { type LoadableConversationSession, } from '../../runtime/live-session-source.js'; import { + ConversationDirectoryIdentityError, isSameConversationPath, type ConversationDirectoryIdentity, } from '../../utils/conversation-directory-identity.js'; import type { RequestedSessionIdAdmission } from '../session-id-admission.js'; import type { SessionArchiveCoordinator } from '../server/session-archive.js'; +import { + exportSessionTranscript, + type SessionExportFormat, + type SessionExportResult, +} from '../server/session-export.js'; import { listWorkspaceSessionsForResponse } from '../server/session-list.js'; import { createWorkspaceRuntimeSessionService, @@ -49,17 +59,27 @@ import { } from '../workspace-runtime-storage.js'; import type { WorkspaceRuntime } from '../workspace-registry.js'; import type { ConversationWorkspace } from './conversation-workspace.js'; +import { + StandaloneDeletionJournalError, + type StandaloneDeletionJournal, + type StandaloneDeletionRecordV1, +} from './standalone-deletion-journal.js'; export type StandaloneSessionServiceErrorCode = | 'invalid_request' | 'standalone_session_not_found' | 'standalone_session_conflict' + | 'standalone_session_operation_failed' | 'session_archived' | 'session_busy' | 'standalone_creation_rolled_back' | 'standalone_creation_outcome_unknown' | 'working_directory_missing' - | 'working_directory_compromised'; + | 'working_directory_compromised' + | 'deletion_recovery_compromised' + | 'transcript_deletion_failed' + | 'transcript_deletion_outcome_unknown' + | 'working_directory_recovery_failed'; export class StandaloneSessionServiceError extends Error { override readonly name = 'StandaloneSessionServiceError'; @@ -92,6 +112,47 @@ export interface CreatedStandaloneSession { workingDirectory: { state: 'ready' }; } +export interface StandaloneSessionDirectoryResult { + sessionId: string; + projectlessOutputDirectory: string; + workingDirectory: { + state: 'ready' | 'recreated'; + warnings?: string[]; + }; +} + +export interface StandaloneSessionMetadataResult { + sessionId: string; + displayName: string; +} + +export interface StandaloneBatchError { + sessionId: string; + code: StandaloneSessionServiceErrorCode; + message: string; +} + +export interface ArchiveStandaloneSessionsResult { + archived: string[]; + alreadyArchived: string[]; + notFound: string[]; + errors: StandaloneBatchError[]; +} + +export interface UnarchiveStandaloneSessionsResult { + unarchived: string[]; + alreadyActive: string[]; + notFound: string[]; + errors: StandaloneBatchError[]; +} + +export interface DeleteStandaloneSessionsResult { + removed: string[]; + notFound: string[]; + errors: StandaloneBatchError[]; + fileCleanupPending: string[]; +} + export interface CreatedStandaloneChildSession extends CreatedStandaloneSession { initialPrompt: { @@ -101,6 +162,10 @@ export interface CreatedStandaloneChildSession }; } +type CreatedStandaloneSessionInternal = CreatedStandaloneSession & { + initialPrompt?: CreatedStandaloneChildSession['initialPrompt']; +}; + export interface StandaloneSessionSummary extends BridgeSessionSummary { sessionId: string; sourceType: typeof STANDALONE_SESSION_SOURCE_TYPE; @@ -163,15 +228,26 @@ export interface StandaloneSessionServiceOptions { | 'prepareStandaloneDirectory' | 'inspectStandaloneDirectory' | 'ensureStandaloneDirectory' + | 'inspectStandaloneDeletionPaths' + | 'createStandaloneDeletionExpectation' + | 'stageStandaloneDirectory' + | 'restoreStagedStandaloneDirectory' + | 'removeStagedStandaloneDirectory' >; + deletionJournal: StandaloneDeletionJournal; lifecycle: SessionArchiveCoordinator; requestedSessionIdAdmission: RequestedSessionIdAdmission; + hasForeignSessionOwner( + runtime: WorkspaceRuntime, + sessionId: string, + ): Promise; invalidateSessionListCache(runtime: WorkspaceRuntime): void; } interface CreatingEntry { readonly canonicalSessionId: string; readonly runtime: WorkspaceRuntime; + readonly scope: 'top-level' | 'child'; state: 'running' | 'quarantine-frozen'; reservation?: { release(): void }; } @@ -189,6 +265,17 @@ interface PreparedRestoreDirectory { state: 'ready' | 'recreated'; } +type StoredStandaloneState = + | { kind: 'missing' } + | { kind: 'conflict' } + | { kind: 'foreign' } + | { + kind: 'standalone'; + storageSessionId: string; + location: SessionArchiveState; + source: LoadableConversationSession; + }; + class TerminalQuarantineSignal extends Error { constructor(readonly completion: Promise) { super('Terminal Conversations runtime quarantine started'); @@ -221,6 +308,8 @@ function serviceError( standalone_session_not_found: 'The standalone session was not found.', standalone_session_conflict: 'The standalone session id or durable state conflicts with an existing session.', + standalone_session_operation_failed: + 'The standalone session operation failed.', session_archived: 'The standalone session is archived.', session_busy: 'The standalone session is busy.', standalone_creation_rolled_back: @@ -230,6 +319,14 @@ function serviceError( working_directory_missing: 'The standalone working directory is missing.', working_directory_compromised: 'The standalone working directory identity is compromised.', + deletion_recovery_compromised: + 'Standalone session deletion recovery is compromised.', + transcript_deletion_failed: + 'The standalone transcript could not be deleted.', + transcript_deletion_outcome_unknown: + 'The standalone transcript deletion outcome is unknown.', + working_directory_recovery_failed: + 'The standalone working directory could not be recovered.', }; return new StandaloneSessionServiceError( code, @@ -359,6 +456,10 @@ function isSameDirectoryIdentity( export class StandaloneSessionService { private readonly creating = new Map(); private readonly directoryStates = new Map(); + private readonly reconciliations = new WeakMap< + WorkspaceRuntime, + Promise + >(); private terminal = false; constructor(private readonly options: StandaloneSessionServiceOptions) {} @@ -372,9 +473,13 @@ export class StandaloneSessionService { async get(rawSessionId: string): Promise { const { sessionId } = parseRequiredSessionId(rawSessionId); - if (this.creating.has(sessionId)) { + const creating = this.creating.get(sessionId); + if (creating?.scope === 'top-level') { return { sessionId, state: 'creating' }; } + if (creating) { + throw serviceError('standalone_session_not_found', sessionId); + } const runtime = await this.options.ensureRuntime(); try { return await this.options.runRuntimeActivity(runtime, async () => { @@ -387,6 +492,9 @@ export class StandaloneSessionService { runtime, sessionId, ); + if (await this.options.deletionJournal.hasRecord(sessionId)) { + throw serviceError('standalone_session_conflict', sessionId, true); + } this.options.assertRuntimeCurrent(runtime); if (persisted.isArchived) return persisted; let live: BridgeSessionSummary; @@ -400,7 +508,9 @@ export class StandaloneSessionService { live.sourceType !== STANDALONE_SESSION_SOURCE_TYPE || live.sourceId !== undefined || normalizeSessionIdForLookup(live.parentSessionId ?? '') !== - normalizeSessionIdForLookup(persisted.parentSessionId ?? '') + normalizeSessionIdForLookup(persisted.parentSessionId ?? '') || + live.worktree !== undefined || + live.branch !== undefined ) { throw serviceError('standalone_session_conflict', sessionId); } @@ -446,6 +556,14 @@ export class StandaloneSessionService { for (const raw of result.sessions) { const parsed = parseCallerSuppliedSessionId(raw.sessionId); if (parsed.kind !== 'valid') continue; + if ( + raw.sourceId !== undefined || + raw.parentSessionId !== undefined || + raw.worktree !== undefined || + raw.branch !== undefined + ) { + continue; + } if (conflictedIds.has(parsed.sessionId)) continue; if (byCanonicalId.has(parsed.sessionId)) { byCanonicalId.delete(parsed.sessionId); @@ -465,8 +583,32 @@ export class StandaloneSessionService { context: { kind: 'standalone' }, }); } + const sessions: StandaloneSessionSummary[] = []; + for (const summary of byCanonicalId.values()) { + const stable = await this.options.lifecycle.runSharedMany( + [summary.sessionId], + async () => { + if ( + await this.options.deletionJournal.hasRecord(summary.sessionId) + ) { + return false; + } + const durable = await this.inspectStoredStandalone( + runtime, + summary.sessionId, + ); + return ( + durable.kind === 'standalone' && + durable.source.metadata.parentSessionId === undefined && + (durable.location === 'archived') === + (summary.isArchived === true) + ); + }, + ); + if (stable) sessions.push(summary); + } return { - sessions: [...byCanonicalId.values()], + sessions, ...(result.nextCursor !== undefined ? { nextCursor: result.nextCursor } : {}), @@ -476,6 +618,156 @@ export class StandaloneSessionService { }); } + async repairDirectory( + rawSessionId: string, + ): Promise { + const clientId = randomUUID(); + const restored = await this.restore('resume', rawSessionId, { clientId }); + try { + const runtime = await this.options.ensureRuntime(); + await this.options.runRuntimeActivity(runtime, async () => { + this.options.assertRuntimeCurrent(runtime); + await runtime.bridge.detachClient( + restored.sessionId, + restored.clientId, + ); + }); + } catch { + throw serviceError( + 'working_directory_recovery_failed', + restored.sessionId, + true, + ); + } + return { + sessionId: restored.sessionId, + projectlessOutputDirectory: restored.projectlessOutputDirectory, + workingDirectory: restored.workingDirectory, + }; + } + + async rename( + rawSessionId: string, + displayName: string, + clientId?: string, + ): Promise { + const { sessionId } = parseRequiredSessionId(rawSessionId); + this.assertDisplayName(displayName); + const runtime = await this.options.ensureRuntime(); + return this.options.runRuntimeActivity(runtime, async () => { + this.options.assertRuntimeCurrent(runtime); + await this.options.workspace.assertExactRoot(runtime.workspaceCwd); + await this.reconcilePendingDeletions(runtime); + return this.options.lifecycle.runExclusiveAfterShared( + sessionId, + async () => { + await this.reconcileDeletionUnderExclusive(runtime, sessionId); + const durable = await this.requireTopLevelStoredStandalone( + runtime, + sessionId, + ); + let live: BridgeSessionSummary | undefined; + try { + live = runtime.bridge.getSessionSummary(sessionId); + } catch (error) { + if (!(error instanceof SessionNotFoundError)) throw error; + } + if (live) { + this.assertMatchingLiveStandalone(live, durable, sessionId); + runtime.bridge.updateSessionMetadata( + sessionId, + { displayName }, + clientId !== undefined ? { clientId } : undefined, + ); + } else { + await runWithWorkspaceRuntimeStorage(runtime, async () => { + const service = createWorkspaceRuntimeSessionService(runtime); + const lease = await this.acquireLifecycleLease( + service, + durable.storageSessionId, + ); + try { + await lease.assertOwnedAndUnchanged(); + const renamed = await service.renameSession( + durable.storageSessionId, + displayName, + 'manual', + durable.location, + ); + await lease.assertOwnedAndUnchanged(); + if (!renamed) { + throw serviceError('standalone_session_not_found', sessionId); + } + } finally { + await lease.release(); + } + }); + runtime.bridge.markSessionCatalogChanged(); + } + this.options.invalidateSessionListCache(runtime); + return { sessionId, displayName }; + }, + ); + }); + } + + async export( + rawSessionId: string, + format: SessionExportFormat, + ): Promise { + const { sessionId } = parseRequiredSessionId(rawSessionId); + const runtime = await this.options.ensureRuntime(); + return this.options.runRuntimeActivity(runtime, async () => { + this.options.assertRuntimeCurrent(runtime); + await this.options.workspace.assertExactRoot(runtime.workspaceCwd); + return this.options.lifecycle.runSharedMany([sessionId], async () => { + const durable = await this.requireTopLevelStoredStandalone( + runtime, + sessionId, + ); + if (await this.options.deletionJournal.hasRecord(sessionId)) { + throw serviceError('standalone_session_conflict', sessionId, true); + } + return exportSessionTranscript({ + workspaceCwd: runtime.workspaceCwd, + sessionId: durable.storageSessionId, + format, + archiveState: durable.location, + runtimeBaseDir: runtime.sessionRuntimeBaseDir, + }); + }); + }); + } + + archive(rawSessionIds: string[]): Promise { + const sessionIds = this.parseSessionIds(rawSessionIds); + return this.archiveMany(sessionIds); + } + + unarchive( + rawSessionIds: string[], + ): Promise { + const sessionIds = this.parseSessionIds(rawSessionIds); + return this.unarchiveMany(sessionIds); + } + + delete(rawSessionIds: string[]): Promise { + const sessionIds = this.parseSessionIds(rawSessionIds); + return this.deleteMany(sessionIds); + } + + async detachResponseClient( + rawSessionId: string, + clientId: string, + ): Promise { + const { sessionId } = parseRequiredSessionId(rawSessionId); + const runtime = await this.options.ensureRuntime(); + await this.options.runRuntimeActivity(runtime, async () => { + this.options.assertRuntimeCurrent(runtime); + await runtime.bridge.detachClient(sessionId, clientId); + }); + } + load( rawSessionId: string, options: RestoreStandaloneSessionOptions = {}, @@ -490,12 +782,19 @@ export class StandaloneSessionService { return this.restore('resume', rawSessionId, options); } + resumeForInternalTask( + rawSessionId: string, + options: RestoreStandaloneSessionOptions = {}, + ): Promise { + return this.restore('resume', rawSessionId, options, 'any', 'any'); + } + restoreLegacyForCompatibility( action: 'load' | 'resume', rawSessionId: string, options: RestoreStandaloneSessionOptions = {}, ): Promise { - return this.restore(action, rawSessionId, options, 'legacy'); + return this.restore(action, rawSessionId, options, 'legacy', 'any'); } async assertCwdReadyUnderShared( @@ -509,6 +808,8 @@ export class StandaloneSessionService { const durable = await this.assertActiveStandaloneSession( expectedRuntime, sessionId, + 'any', + 'any', ); const state = this.directoryStates.get(sessionId); if (!state) throw serviceError('working_directory_missing', sessionId); @@ -595,11 +896,782 @@ export class StandaloneSessionService { }); } + private reconcilePendingDeletions(runtime: WorkspaceRuntime): Promise { + const existing = this.reconciliations.get(runtime); + if (existing) return existing; + const pending = (async () => { + const sessionIds = await this.options.deletionJournal.listSessionIds(32); + for (const sessionId of sessionIds) { + try { + await this.options.lifecycle.runExclusiveAfterShared( + sessionId, + async () => { + await this.reconcileDeletionUnderExclusive(runtime, sessionId); + }, + ); + } catch { + // A compromised or temporarily unavailable record is isolated to + // its UUID. The exact operation for that UUID will surface the + // structured failure while other sessions remain usable. + } + } + })(); + this.reconciliations.set(runtime, pending); + return pending; + } + + private async reconcileDeletionUnderExclusive( + runtime: WorkspaceRuntime, + sessionId: string, + ): Promise<'none' | 'rolled-back' | 'committed'> { + this.options.assertRuntimeCurrent(runtime); + const root = await this.options.workspace.assertExactRoot( + runtime.workspaceCwd, + ); + let journal; + try { + journal = await this.options.deletionJournal.read(sessionId, root); + } catch (error) { + if (error instanceof StandaloneDeletionJournalError) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + throw error; + } + if (!journal) return 'none'; + + if (await this.options.hasForeignSessionOwner(runtime, sessionId)) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + + try { + runtime.bridge.getSessionSummary(sessionId); + throw serviceError('deletion_recovery_compromised', sessionId); + } catch (error) { + if (!(error instanceof SessionNotFoundError)) throw error; + } + + const record = journal.prepared; + const expected = + record.directory.kind === 'present' + ? await this.options.workspace.createStandaloneDeletionExpectation( + sessionId, + record.directory, + ) + : undefined; + const paths = await this.options.workspace.inspectStandaloneDeletionPaths( + sessionId, + expected, + ); + if (paths.status === 'compromised') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + + return runWithWorkspaceRuntimeStorage(runtime, async () => { + const service = createWorkspaceRuntimeSessionService(runtime); + const lease = await this.acquireLifecycleLease( + service, + record.storageSessionId, + ); + try { + const durable = await this.inspectStoredStandalone(runtime, sessionId); + await lease.assertOwnedAndUnchanged(); + if (durable.kind === 'conflict' || durable.kind === 'foreign') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + if (durable.kind === 'standalone') { + if ( + durable.storageSessionId !== record.storageSessionId || + durable.location !== record.transcriptLocation || + durable.source.metadata.parentSessionId !== undefined + ) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + if (record.directory.kind === 'absent') { + if (paths.status !== 'absent') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + } else if (paths.status === 'staged') { + await this.options.workspace.restoreStagedStandaloneDirectory( + sessionId, + expected!, + ); + } else if (paths.status !== 'normal') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + await lease.assertOwnedAndUnchanged(); + await this.options.deletionJournal.clear(sessionId, root); + return 'rolled-back'; + } + + if (record.directory.kind === 'absent') { + if (paths.status !== 'absent') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + } else if (paths.status === 'staged') { + await this.options.workspace.removeStagedStandaloneDirectory( + sessionId, + expected!, + ); + } else if (paths.status !== 'absent') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + await service.cleanupRemovedSessionState(record.storageSessionId, { + assertCanMutate: () => this.options.assertRuntimeCurrent(runtime), + }); + await runtime.bridge.deleteSessionAttachments(sessionId, { + assertCanCommit: () => this.options.assertRuntimeCurrent(runtime), + }); + await this.options.deletionJournal.clear(sessionId, root); + this.directoryStates.delete(sessionId); + runtime.bridge.markSessionCatalogChanged(); + this.options.invalidateSessionListCache(runtime); + return 'committed'; + } catch (error) { + if (error instanceof StandaloneSessionServiceError) throw error; + if ( + error instanceof ConversationDirectoryIdentityError || + error instanceof StandaloneDeletionJournalError + ) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + throw serviceError( + 'working_directory_recovery_failed', + sessionId, + true, + ); + } finally { + await lease.release(); + } + }); + } + + private async inspectStoredStandalone( + runtime: WorkspaceRuntime, + sessionId: string, + ): Promise { + try { + return await runWithWorkspaceRuntimeStorage(runtime, async () => { + const service = createWorkspaceRuntimeSessionService(runtime); + const storageSessionId = + await service.findSessionIdIgnoringCase(sessionId); + if (storageSessionId === undefined) return { kind: 'missing' }; + const location = await service.getSessionLocation(storageSessionId); + if (location === 'conflict') return { kind: 'conflict' }; + if (location !== 'active' && location !== 'archived') { + return { kind: 'missing' }; + } + const source = await readLoadableConversationSession( + storageSessionId, + service, + ); + if (source?.kind !== 'standalone') return { kind: 'foreign' }; + return { + kind: 'standalone', + storageSessionId, + location, + source, + }; + }); + } catch (error) { + if (error instanceof SessionIdCaseConflictError) { + return { kind: 'conflict' }; + } + throw error; + } + } + + private async acquireLifecycleLease( + service: SessionService, + storageSessionId: string, + ): Promise { + const leaseOptions = { + processKind: 'daemon' as const, + reclaimPolicy: 'never' as const, + takeoverPolicy: 'certified' as const, + }; + try { + return await service.acquireSessionWriterLease( + storageSessionId, + leaseOptions, + ); + } catch (error) { + if ( + !(error instanceof SessionWriterUnavailableError) && + !(error instanceof SessionTranscriptChangedError) + ) { + throw error; + } + return service.acquireSessionMaintenanceLease( + storageSessionId, + leaseOptions, + ); + } + } + + private parseSessionIds(rawSessionIds: string[]): string[] { + if (rawSessionIds.length === 0 || rawSessionIds.length > 100) { + throw invalidRequest(); + } + const sessionIds: string[] = []; + const seen = new Set(); + for (const rawSessionId of rawSessionIds) { + const { sessionId } = parseRequiredSessionId(rawSessionId); + if (!seen.has(sessionId)) { + seen.add(sessionId); + sessionIds.push(sessionId); + } + } + return sessionIds; + } + + private assertDisplayName(displayName: string): void { + if ( + typeof displayName !== 'string' || + displayName.trim().length === 0 || + displayName.length > 256 || + /\p{Cc}/u.test(displayName) + ) { + throw new StandaloneSessionServiceError( + 'invalid_request', + undefined, + '`displayName` must be a non-empty string of at most 256 characters without control characters.', + ); + } + } + + private async requireTopLevelStoredStandalone( + runtime: WorkspaceRuntime, + sessionId: string, + ): Promise> { + const durable = await this.inspectStoredStandalone(runtime, sessionId); + if (durable.kind === 'missing') { + throw serviceError('standalone_session_not_found', sessionId); + } + if ( + durable.kind !== 'standalone' || + durable.storageSessionId.toLowerCase() !== sessionId || + durable.source.metadata.parentSessionId !== undefined + ) { + throw serviceError('standalone_session_conflict', sessionId); + } + return durable; + } + + private assertMatchingLiveStandalone( + live: BridgeSessionSummary, + durable: Extract, + sessionId: string, + ): void { + if ( + live.sourceType !== STANDALONE_SESSION_SOURCE_TYPE || + live.sourceId !== undefined || + live.parentSessionId !== undefined || + durable.source.metadata.parentSessionId !== undefined || + live.worktree !== undefined || + live.branch !== undefined + ) { + throw serviceError('standalone_session_conflict', sessionId); + } + } + + private async closeLiveStandaloneIfPresent( + runtime: WorkspaceRuntime, + durable: Extract, + sessionId: string, + ): Promise { + let live: BridgeSessionSummary; + try { + live = runtime.bridge.getSessionSummary(sessionId); + } catch (error) { + if (error instanceof SessionNotFoundError) return; + throw error; + } + this.assertMatchingLiveStandalone(live, durable, sessionId); + if (live.hasActivePrompt || live.clientCount > 0) { + throw serviceError('session_busy', sessionId, true); + } + try { + const closed = await runtime.bridge.killSession(sessionId, { + requireZeroAttaches: true, + }); + if (!closed) throw serviceError('session_busy', sessionId, true); + } catch (error) { + if (error instanceof StandaloneSessionServiceError) throw error; + throw serviceError('session_busy', sessionId, true); + } + try { + runtime.bridge.getSessionSummary(sessionId); + throw serviceError('session_busy', sessionId, true); + } catch (error) { + if (!(error instanceof SessionNotFoundError)) throw error; + } + const directory = this.directoryStates.get(sessionId); + if (directory) { + this.directoryStates.set(sessionId, { pinned: directory.pinned }); + } + } + + private batchError(sessionId: string, error: unknown): StandaloneBatchError { + if (error instanceof StandaloneSessionServiceError) { + return { sessionId, code: error.code, message: error.message }; + } + const mapped = serviceError( + 'standalone_session_operation_failed', + sessionId, + ); + return { sessionId, code: mapped.code, message: mapped.message }; + } + + private async archiveMany( + sessionIds: string[], + ): Promise { + const result: ArchiveStandaloneSessionsResult = { + archived: [], + alreadyArchived: [], + notFound: [], + errors: [], + }; + const runtime = await this.options.ensureRuntime(); + await this.options.runRuntimeActivity(runtime, async () => { + await this.options.workspace.assertExactRoot(runtime.workspaceCwd); + await this.reconcilePendingDeletions(runtime); + for (const sessionId of sessionIds) { + try { + const outcome = await this.options.lifecycle.runExclusiveAfterShared( + sessionId, + async () => { + await this.reconcileDeletionUnderExclusive(runtime, sessionId); + const durable = await this.requireTopLevelStoredStandalone( + runtime, + sessionId, + ); + if (durable.location === 'archived') return 'alreadyArchived'; + await this.closeLiveStandaloneIfPresent( + runtime, + durable, + sessionId, + ); + return runWithWorkspaceRuntimeStorage(runtime, async () => { + const service = createWorkspaceRuntimeSessionService(runtime); + const lease = await this.acquireLifecycleLease( + service, + durable.storageSessionId, + ); + try { + const locked = await this.requireTopLevelStoredStandalone( + runtime, + sessionId, + ); + if (locked.location === 'archived') return 'alreadyArchived'; + await lease.assertOwnedAndUnchanged(); + const archived = await service.archiveSessions( + [locked.storageSessionId], + { + knownLocation: 'active', + resolveConflicts: false, + assertStorageUnchanged: () => + lease.assertOwnedAndUnchanged(), + assertCanMutate: () => + this.options.assertRuntimeCurrent(runtime), + }, + ); + if (archived.errors[0]) throw archived.errors[0].error; + return archived.archived.length > 0 + ? 'archived' + : 'alreadyArchived'; + } finally { + await lease.release(); + } + }); + }, + ); + result[outcome].push(sessionId); + runtime.bridge.markSessionCatalogChanged(); + this.options.invalidateSessionListCache(runtime); + } catch (error) { + if ( + error instanceof StandaloneSessionServiceError && + error.code === 'standalone_session_not_found' + ) { + result.notFound.push(sessionId); + } else { + result.errors.push(this.batchError(sessionId, error)); + } + } + } + }); + return result; + } + + private async unarchiveMany( + sessionIds: string[], + ): Promise { + const result: UnarchiveStandaloneSessionsResult = { + unarchived: [], + alreadyActive: [], + notFound: [], + errors: [], + }; + const runtime = await this.options.ensureRuntime(); + await this.options.runRuntimeActivity(runtime, async () => { + await this.options.workspace.assertExactRoot(runtime.workspaceCwd); + await this.reconcilePendingDeletions(runtime); + for (const sessionId of sessionIds) { + try { + const outcome = await this.options.lifecycle.runExclusiveAfterShared( + sessionId, + async () => { + await this.reconcileDeletionUnderExclusive(runtime, sessionId); + const durable = await this.requireTopLevelStoredStandalone( + runtime, + sessionId, + ); + if (durable.location === 'active') return 'alreadyActive'; + try { + runtime.bridge.getSessionSummary(sessionId); + throw serviceError('standalone_session_conflict', sessionId); + } catch (error) { + if (!(error instanceof SessionNotFoundError)) throw error; + } + return runWithWorkspaceRuntimeStorage(runtime, async () => { + const service = createWorkspaceRuntimeSessionService(runtime); + const lease = await this.acquireLifecycleLease( + service, + durable.storageSessionId, + ); + try { + const locked = await this.requireTopLevelStoredStandalone( + runtime, + sessionId, + ); + if (locked.location === 'active') return 'alreadyActive'; + await lease.assertOwnedAndUnchanged(); + const unarchived = await service.unarchiveSessions( + [locked.storageSessionId], + { + knownLocation: 'archived', + resolveConflicts: false, + assertStorageUnchanged: () => + lease.assertOwnedAndUnchanged(), + assertCanMutate: () => + this.options.assertRuntimeCurrent(runtime), + }, + ); + if (unarchived.errors[0]) throw unarchived.errors[0].error; + return unarchived.unarchived.length > 0 + ? 'unarchived' + : 'alreadyActive'; + } finally { + await lease.release(); + } + }); + }, + ); + result[outcome].push(sessionId); + runtime.bridge.markSessionCatalogChanged(); + this.options.invalidateSessionListCache(runtime); + } catch (error) { + if ( + error instanceof StandaloneSessionServiceError && + error.code === 'standalone_session_not_found' + ) { + result.notFound.push(sessionId); + } else { + result.errors.push(this.batchError(sessionId, error)); + } + } + } + }); + return result; + } + + private async deleteMany( + sessionIds: string[], + ): Promise { + const result: DeleteStandaloneSessionsResult = { + removed: [], + notFound: [], + errors: [], + fileCleanupPending: [], + }; + const runtime = await this.options.ensureRuntime(); + await this.options.runRuntimeActivity(runtime, async () => { + await this.options.workspace.assertExactRoot(runtime.workspaceCwd); + await this.reconcilePendingDeletions(runtime); + for (const sessionId of sessionIds) { + try { + const outcome = await this.options.lifecycle.runExclusiveAfterShared( + sessionId, + () => this.deleteUnderExclusive(runtime, sessionId), + ); + if (outcome === 'notFound') { + result.notFound.push(sessionId); + } else { + result.removed.push(sessionId); + if (outcome === 'cleanupPending') { + result.fileCleanupPending.push(sessionId); + } + } + } catch (error) { + if ( + error instanceof StandaloneSessionServiceError && + error.code === 'standalone_session_not_found' + ) { + result.notFound.push(sessionId); + } else { + result.errors.push(this.batchError(sessionId, error)); + } + } + } + }); + return result; + } + + private async deleteUnderExclusive( + runtime: WorkspaceRuntime, + sessionId: string, + ): Promise<'removed' | 'cleanupPending' | 'notFound'> { + const recovered = await this.reconcileDeletionUnderExclusive( + runtime, + sessionId, + ); + if (recovered === 'committed') return 'removed'; + let durable: Extract; + try { + durable = await this.requireTopLevelStoredStandalone(runtime, sessionId); + } catch (error) { + if ( + error instanceof StandaloneSessionServiceError && + error.code === 'standalone_session_not_found' + ) { + return 'notFound'; + } + throw error; + } + if (await this.options.hasForeignSessionOwner(runtime, sessionId)) { + throw serviceError('standalone_session_conflict', sessionId); + } + await this.closeLiveStandaloneIfPresent(runtime, durable, sessionId); + const root = await this.options.workspace.assertExactRoot( + runtime.workspaceCwd, + ); + + return runWithWorkspaceRuntimeStorage(runtime, async () => { + const service = createWorkspaceRuntimeSessionService(runtime); + const lease = await this.acquireLifecycleLease( + service, + durable.storageSessionId, + ); + let transcriptCommitted = false; + let directoryWasStaged = false; + let cleanupPending = false; + let leaseReleased = false; + try { + const locked = await this.requireTopLevelStoredStandalone( + runtime, + sessionId, + ); + if ( + locked.storageSessionId !== durable.storageSessionId || + locked.location !== durable.location + ) { + throw serviceError('standalone_session_conflict', sessionId); + } + await lease.assertOwnedAndUnchanged(); + const pinned = this.directoryStates.get(sessionId)?.pinned; + const paths = + await this.options.workspace.inspectStandaloneDeletionPaths( + sessionId, + pinned, + ); + if (paths.status === 'compromised' || paths.status === 'staged') { + throw serviceError('working_directory_compromised', sessionId); + } + const directory: StandaloneDeletionRecordV1['directory'] = + paths.status === 'normal' + ? { + kind: 'present', + normalName: paths.identity.name, + stagedName: `${paths.identity.name}.deleting`, + device: paths.identity.device, + inode: paths.identity.inode, + inodeVerifiable: paths.identity.inode !== 0, + } + : { kind: 'absent' }; + const prepared: StandaloneDeletionRecordV1 = { + version: 1, + phase: 'prepared', + sessionId, + storageSessionId: locked.storageSessionId, + transcriptLocation: locked.location, + root: { + canonicalPath: root.canonicalRoot, + device: root.device, + inode: root.inode, + inodeVerifiable: root.inodeVerifiable, + }, + directory, + }; + await this.options.deletionJournal.writePrepared(prepared, root); + if (paths.status === 'normal') { + try { + await this.options.workspace.stageStandaloneDirectory( + sessionId, + paths.identity, + ); + directoryWasStaged = true; + await this.options.deletionJournal.writeStaged( + { ...prepared, phase: 'staged' }, + root, + ); + } catch (error) { + await this.rollbackPreparedDeletion( + sessionId, + root, + paths.identity, + ); + throw error; + } + } + + try { + transcriptCommitted = + await service.removeSessionTranscriptForLifecycle( + locked.storageSessionId, + locked.location, + { + assertStorageUnchanged: () => lease.assertOwnedAndUnchanged(), + assertCanMutate: () => + this.options.assertRuntimeCurrent(runtime), + }, + ); + } catch { + const after = await this.inspectStoredStandalone(runtime, sessionId); + if ( + after.kind === 'standalone' && + after.storageSessionId === locked.storageSessionId && + after.location === locked.location + ) { + await this.rollbackPreparedDeletion( + sessionId, + root, + paths.status === 'normal' ? paths.identity : undefined, + ); + throw serviceError('transcript_deletion_failed', sessionId, true); + } + if (after.kind === 'missing') { + transcriptCommitted = true; + } else { + throw serviceError( + 'transcript_deletion_outcome_unknown', + sessionId, + ); + } + } + if (!transcriptCommitted) { + const after = await this.inspectStoredStandalone(runtime, sessionId); + if (after.kind === 'missing') { + transcriptCommitted = true; + } else if ( + after.kind === 'standalone' && + after.storageSessionId === locked.storageSessionId && + after.location === locked.location + ) { + await this.rollbackPreparedDeletion( + sessionId, + root, + paths.status === 'normal' ? paths.identity : undefined, + ); + throw serviceError('transcript_deletion_failed', sessionId, true); + } else { + throw serviceError( + 'transcript_deletion_outcome_unknown', + sessionId, + ); + } + } + + try { + await service.cleanupRemovedSessionState(locked.storageSessionId, { + assertCanMutate: () => this.options.assertRuntimeCurrent(runtime), + }); + } catch { + cleanupPending = true; + } + try { + await runtime.bridge.deleteSessionAttachments(sessionId, { + assertCanCommit: () => this.options.assertRuntimeCurrent(runtime), + }); + } catch { + cleanupPending = true; + } + if (directoryWasStaged && paths.status === 'normal') { + try { + await this.options.workspace.removeStagedStandaloneDirectory( + sessionId, + paths.identity, + ); + } catch { + cleanupPending = true; + } + } + try { + await lease.release(); + } catch { + cleanupPending = true; + } + leaseReleased = true; + if (!cleanupPending) { + try { + await this.options.deletionJournal.clear(sessionId, root); + } catch { + cleanupPending = true; + } + } + this.directoryStates.delete(sessionId); + runtime.bridge.markSessionCatalogChanged(); + this.options.invalidateSessionListCache(runtime); + return cleanupPending ? 'cleanupPending' : 'removed'; + } finally { + if (!leaseReleased) await lease.release(); + } + }); + } + + private async rollbackPreparedDeletion( + sessionId: string, + root: ConversationDirectoryIdentity['root'], + expected?: ConversationDirectoryIdentity, + ): Promise { + try { + if (expected) { + const paths = + await this.options.workspace.inspectStandaloneDeletionPaths( + sessionId, + expected, + ); + if (paths.status === 'staged') { + await this.options.workspace.restoreStagedStandaloneDirectory( + sessionId, + expected, + ); + } else if (paths.status !== 'normal') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + } + await this.options.deletionJournal.clear(sessionId, root); + } catch (error) { + if (error instanceof StandaloneSessionServiceError) throw error; + throw serviceError('working_directory_recovery_failed', sessionId, true); + } + } + private async restore( action: 'load' | 'resume', rawSessionId: string, options: RestoreStandaloneSessionOptions, requiredPersistence: 'any' | 'legacy' = 'any', + requiredScope: 'top-level' | 'any' = 'top-level', ): Promise { const { sessionId } = parseRequiredSessionId(rawSessionId); const runtime = await this.options.ensureRuntime(); @@ -609,6 +1681,8 @@ export class StandaloneSessionService { this.options.assertRuntimeCurrent(runtime); await this.options.workspace.assertExactRoot(runtime.workspaceCwd); this.options.assertRuntimeCurrent(runtime); + await this.reconcilePendingDeletions(runtime); + this.options.assertRuntimeCurrent(runtime); reservation = this.options.requestedSessionIdAdmission.reserveRestore( sessionId, { @@ -621,10 +1695,12 @@ export class StandaloneSessionService { return this.options.lifecycle.runExclusiveAfterShared( sessionId, async () => { + await this.reconcileDeletionUnderExclusive(runtime, sessionId); const durable = await this.assertActiveStandaloneSession( runtime, sessionId, requiredPersistence, + requiredScope, ); let existing: BridgeSessionSummary | undefined; @@ -888,7 +1964,7 @@ export class StandaloneSessionService { request: CreateStandaloneSessionRequest, prompt: string, ): Promise { - const created = await this.createWithInitialPromptInternal(request, prompt); + const created = await this.createInternal(request, prompt); return { session: created.session, projectlessOutputDirectory: created.projectlessOutputDirectory, @@ -896,6 +1972,12 @@ export class StandaloneSessionService { }; } + create( + request: CreateStandaloneSessionRequest, + ): Promise { + return this.createInternal(request); + } + async createChildWithInitialPrompt( request: CreateStandaloneChildSessionRequest, prompt: string, @@ -906,20 +1988,27 @@ export class StandaloneSessionService { if (parentSessionId === normalizeSessionIdForLookup(request.sessionId)) { throw serviceError('standalone_session_conflict', parentSessionId); } - return this.createWithInitialPromptInternal( + const created = await this.createInternal( request, prompt, parentSessionId, request.promptId, ); + if (!created.initialPrompt) { + throw serviceError( + 'standalone_creation_outcome_unknown', + normalizeSessionIdForLookup(request.sessionId), + ); + } + return { ...created, initialPrompt: created.initialPrompt }; } - private async createWithInitialPromptInternal( + private async createInternal( request: CreateStandaloneSessionRequest, - prompt: string, + prompt?: string, parentSessionId?: string, promptId: string = randomUUID(), - ): Promise { + ): Promise { const { sessionId } = parseRequiredSessionId(request.sessionId); let entry: CreatingEntry | undefined; try { @@ -928,7 +2017,13 @@ export class StandaloneSessionService { this.options.assertRuntimeCurrent(runtime); await this.options.workspace.assertExactRoot(runtime.workspaceCwd); this.options.assertRuntimeCurrent(runtime); - const creatingEntry = this.insertCreating(sessionId, runtime); + await this.reconcilePendingDeletions(runtime); + this.options.assertRuntimeCurrent(runtime); + const creatingEntry = this.insertCreating( + sessionId, + runtime, + parentSessionId === undefined ? 'top-level' : 'child', + ); entry = creatingEntry; creatingEntry.reservation = await this.options.requestedSessionIdAdmission.reserveCreate( @@ -994,6 +2089,7 @@ export class StandaloneSessionService { private insertCreating( canonicalSessionId: string, runtime: WorkspaceRuntime, + scope: 'top-level' | 'child', ): CreatingEntry { if (this.terminal) { this.options.assertRuntimeCurrent(runtime); @@ -1012,6 +2108,7 @@ export class StandaloneSessionService { const entry: CreatingEntry = { canonicalSessionId, runtime, + scope, state: 'running', }; this.creating.set(canonicalSessionId, entry); @@ -1022,10 +2119,12 @@ export class StandaloneSessionService { runtime: WorkspaceRuntime, sessionId: string, request: CreateStandaloneSessionRequest, - prompt: string, + prompt: string | undefined, promptId: string, parentSessionId?: string, - ): Promise { + ): Promise { + this.options.assertRuntimeCurrent(runtime); + await this.reconcileDeletionUnderExclusive(runtime, sessionId); this.options.assertRuntimeCurrent(runtime); await this.assertPersistedSessionAbsent(runtime, sessionId); this.options.assertRuntimeCurrent(runtime); @@ -1063,7 +2162,12 @@ export class StandaloneSessionService { session.attached || session.sessionId !== sessionId || session.sourceType !== STANDALONE_SESSION_SOURCE_TYPE || - (parentSessionId !== undefined && session.parentSessionPersisted !== true) + session.sourceId !== undefined || + session.worktree !== undefined || + session.branch !== undefined || + (parentSessionId === undefined + ? session.parentSessionPersisted !== undefined + : session.parentSessionPersisted !== true) ) { await this.discardUntrustedSpawnResult(runtime.bridge, session); this.beginTerminalQuarantine(runtime); @@ -1084,15 +2188,19 @@ export class StandaloneSessionService { this.beginTerminalQuarantine(runtime); } this.assertRuntimeCurrentOrQuarantine(runtime); - let initialPrompt: CreatedStandaloneChildSession['initialPrompt']; + let initialPrompt: + | CreatedStandaloneChildSession['initialPrompt'] + | undefined; try { await this.bindAndRelease(runtime, sessionId, prepared.identity); - initialPrompt = await this.admitInitialPrompt( - runtime.bridge, - sessionId, - prompt, - promptId, - ); + if (prompt !== undefined) { + initialPrompt = await this.admitInitialPrompt( + runtime.bridge, + sessionId, + prompt, + promptId, + ); + } this.assertRuntimeCurrentOrQuarantine(runtime); } catch (error) { if (error instanceof TerminalQuarantineSignal) throw error; @@ -1110,7 +2218,7 @@ export class StandaloneSessionService { session, projectlessOutputDirectory: prepared.identity.canonicalPath, workingDirectory: { state: 'ready' }, - initialPrompt, + ...(initialPrompt ? { initialPrompt } : {}), }; } @@ -1227,6 +2335,7 @@ export class StandaloneSessionService { runtime: WorkspaceRuntime, sessionId: string, requiredPersistence: 'any' | 'legacy' = 'any', + requiredScope: 'top-level' | 'any' = 'top-level', ): Promise<{ storageSessionId: string; source: LoadableConversationSession; @@ -1247,7 +2356,9 @@ export class StandaloneSessionService { ); if ( source?.kind !== 'standalone' || - (requiredPersistence === 'legacy' && source.persistence !== 'legacy') + (requiredPersistence === 'legacy' && source.persistence !== 'legacy') || + (requiredScope === 'top-level' && + source.metadata.parentSessionId !== undefined) ) { return { kind: 'not-found' } as const; } @@ -1355,6 +2466,7 @@ export class StandaloneSessionService { if (source?.kind !== 'standalone') { return undefined; } + if (source.metadata.parentSessionId !== undefined) return undefined; const item = await service.getSessionListItem(storageSessionId, location); if (!item) return undefined; let prs: Awaited>; 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 316d071e8d3..206edbf3428 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -379,7 +379,7 @@ function makeHarness() { workingDirectory: { state: 'ready' as const }, }), ), - resume: vi.fn(async (sessionId: string) => { + resumeForInternalTask: vi.fn(async (sessionId: string) => { const canonicalSessionId = normalizeSessionIdForLookup(sessionId); resident.add(canonicalSessionId); return { @@ -1147,9 +1147,9 @@ describe('LiveTaskService', () => { }); expect(result).toEqual({ threadId: 'task-1' }); - expect(harness.standaloneSessionService.resume).toHaveBeenCalledWith( - 'task-1', - ); + expect( + harness.standaloneSessionService.resumeForInternalTask, + ).toHaveBeenCalledWith('task-1'); expect(harness.bridge.resumeSession).not.toHaveBeenCalled(); expect(harness.bridge.changeSessionCwd).not.toHaveBeenCalled(); expect(harness.sendPrompt).toHaveBeenCalledOnce(); @@ -1298,9 +1298,9 @@ describe('LiveTaskService', () => { }, }); - expect(harness.standaloneSessionService.resume).toHaveBeenCalledWith( - sessionId, - ); + expect( + harness.standaloneSessionService.resumeForInternalTask, + ).toHaveBeenCalledWith(sessionId); expect(harness.bridge.resumeSession).not.toHaveBeenCalled(); expect(harness.materializeConversationDirectory).not.toHaveBeenCalled(); expect(harness.bridge.changeSessionCwd).not.toHaveBeenCalled(); @@ -1370,9 +1370,9 @@ describe('LiveTaskService', () => { arguments: { threadId: 'task-1', prompt: 'continue standalone' }, }); - expect(harness.standaloneSessionService.resume).toHaveBeenCalledWith( - 'task-1', - ); + expect( + harness.standaloneSessionService.resumeForInternalTask, + ).toHaveBeenCalledWith('task-1'); expect( harness.standaloneSessionService.dispatchPrompt, ).toHaveBeenCalledWith('task-1', expect.any(Function)); diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index ba7dbaa955e..467b160a4f5 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -108,7 +108,7 @@ export interface LiveTaskServiceOptions { ensureConversationRuntime: () => Promise; standaloneSessionService?: Pick< StandaloneSessionService, - 'createWithInitialPrompt' | 'get' | 'list' | 'resume' + 'createWithInitialPrompt' | 'get' | 'list' | 'resumeForInternalTask' > & { dispatchPrompt( sessionId: string, @@ -1099,7 +1099,9 @@ export class LiveTaskService { if (!(error instanceof SessionNotFoundError)) throw error; } if (isReservedStandaloneSessionSourceType(task.summary.sourceType)) { - await this.getStandaloneSessionService().resume(task.summary.sessionId); + await this.getStandaloneSessionService().resumeForInternalTask( + task.summary.sessionId, + ); return; } const service = createWorkspaceRuntimeSessionService(task.runtime); diff --git a/packages/cli/src/serve/routes/standalone-sessions.test.ts b/packages/cli/src/serve/routes/standalone-sessions.test.ts new file mode 100644 index 00000000000..18f42c148d3 --- /dev/null +++ b/packages/cli/src/serve/routes/standalone-sessions.test.ts @@ -0,0 +1,404 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import express, { type RequestHandler } from 'express'; +import { request as httpRequest } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + StandaloneSessionServiceError, + type ListStandaloneSessionsOptions, + type StandaloneSessionService, +} from '../conversations/standalone-session-service.js'; +import { sendBridgeError } from '../server/error-response.js'; +import { registerStandaloneSessionRoutes } from './standalone-sessions.js'; + +const sessionId = '11111111-1111-4111-8111-111111111111'; + +function createHarness() { + const service = { + create: vi.fn(async () => ({ + session: { + sessionId, + clientId: 'client-1', + workspaceCwd: '/conversations', + attached: false, + sourceType: 'standalone', + }, + projectlessOutputDirectory: '/conversations/conversation-child', + workingDirectory: { state: 'ready' as const }, + })), + list: vi.fn(async (_options: ListStandaloneSessionsOptions = {}) => ({ + sessions: [], + })), + get: vi.fn(async () => ({ + sessionId, + workspaceCwd: '/conversations', + sourceType: 'standalone', + context: { kind: 'standalone' as const }, + clientCount: 0, + hasActivePrompt: false, + isArchived: false, + })), + load: vi.fn(async () => ({ sessionId, action: 'load' })), + resume: vi.fn(async () => ({ sessionId, action: 'resume' })), + repairDirectory: vi.fn(async () => ({ + sessionId, + projectlessOutputDirectory: '/conversations/conversation-child', + workingDirectory: { state: 'ready' as const }, + })), + rename: vi.fn(async (_id, displayName: string) => ({ + sessionId, + displayName, + })), + export: vi.fn(async () => ({ + format: 'md' as const, + filename: 'session.md', + mimeType: 'text/markdown; charset=utf-8', + content: '# Session', + })), + archive: vi.fn(async () => ({ + archived: [sessionId], + alreadyArchived: [], + notFound: [], + errors: [], + })), + unarchive: vi.fn(async () => ({ + unarchived: [sessionId], + alreadyActive: [], + notFound: [], + errors: [], + })), + delete: vi.fn(async () => ({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [], + })), + detachResponseClient: vi.fn(async () => undefined), + }; + const mutate = vi.fn(() => ((_req, _res, next) => next()) as RequestHandler); + const app = express(); + app.use(express.json()); + registerStandaloneSessionRoutes(app, { + service: service as unknown as StandaloneSessionService, + mutate, + sendBridgeError, + }); + return { app, service, mutate }; +} + +describe('standalone session routes', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('creates a prompt-less standalone session without workspace inputs', async () => { + const { app, service } = createHarness(); + + const response = await request(app) + .post('/standalone/sessions') + .send({ sessionId, approvalMode: 'default' }); + + expect(response.status).toBe(200); + expect(service.create).toHaveBeenCalledWith({ + sessionId, + approvalMode: 'default', + }); + expect(response.body).toMatchObject({ + sessionId, + sourceType: 'standalone', + context: { kind: 'standalone' }, + workingDirectory: { state: 'ready' }, + }); + }); + + it('detaches only the response client when creation outlives the request', async () => { + const { app, service } = createHarness(); + let finishCreate!: () => void; + service.create.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCreate = () => + resolve({ + session: { + sessionId, + clientId: 'client-1', + workspaceCwd: '/conversations', + attached: false, + sourceType: 'standalone', + }, + projectlessOutputDirectory: '/conversations/conversation-child', + workingDirectory: { state: 'ready' }, + }); + }), + ); + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + const body = JSON.stringify({ sessionId }); + const operation = httpRequest({ + hostname: '127.0.0.1', + port, + path: '/standalone/sessions', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }); + operation.on('error', () => undefined); + operation.end(body); + await vi.waitFor(() => expect(service.create).toHaveBeenCalledOnce()); + + operation.destroy(); + await vi.waitFor(async () => { + const connections = await new Promise((resolve, reject) => + server.getConnections((error, count) => + error ? reject(error) : resolve(count), + ), + ); + expect(connections).toBe(0); + }); + finishCreate(); + + await vi.waitFor(() => + expect(service.detachResponseClient).toHaveBeenCalledWith( + sessionId, + 'client-1', + ), + ); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it.each([ + [{ sessionId, cwd: '/tmp' }, 'unknown fields'], + [[], 'JSON object'], + [{ sessionId, approvalMode: 'unknown' }, 'known approval mode'], + ])( + 'rejects invalid create bodies before calling the service', + async (body, message) => { + const { app, service } = createHarness(); + + const response = await request(app) + .post('/standalone/sessions') + .send(body); + + expect(response.status).toBe(400); + expect(response.body.error).toContain(message); + expect(service.create).not.toHaveBeenCalled(); + }, + ); + + it('returns 202 while an exact session id is still creating', async () => { + const { app, service } = createHarness(); + service.get.mockResolvedValueOnce({ + sessionId, + state: 'creating', + } as never); + + const response = await request(app).get( + `/standalone/sessions/${sessionId}`, + ); + + expect(response.status).toBe(202); + expect(response.body).toEqual({ sessionId, state: 'creating' }); + }); + + it('validates and forwards list query options', async () => { + const { app, service } = createHarness(); + + const response = await request(app).get( + '/standalone/sessions?cursor=next&size=25&archiveState=archived', + ); + + expect(response.status).toBe(200); + expect(service.list).toHaveBeenCalledWith( + expect.objectContaining({ + cursor: 'next', + size: 25, + archiveState: 'archived', + signal: expect.any(AbortSignal), + }), + ); + + const invalid = await request(app).get('/standalone/sessions?cwd=/tmp'); + expect(invalid.status).toBe(400); + expect(service.list).toHaveBeenCalledOnce(); + }); + + it('aborts a standalone catalog scan when the request disconnects', async () => { + const { app, service } = createHarness(); + let listSignal: AbortSignal | undefined; + service.list.mockImplementationOnce( + async (options: ListStandaloneSessionsOptions = {}) => { + listSignal = options.signal; + await new Promise((resolve) => + options.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + return { sessions: [] }; + }, + ); + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + const operation = httpRequest({ + hostname: '127.0.0.1', + port, + path: '/standalone/sessions', + method: 'GET', + }); + operation.on('error', () => undefined); + operation.end(); + await vi.waitFor(() => expect(service.list).toHaveBeenCalledOnce()); + + operation.destroy(); + + await vi.waitFor(() => expect(listSignal?.aborted).toBe(true)); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it.each(['load', 'resume'] as const)( + 'accepts only restore options on %s', + async (action) => { + const { app, service } = createHarness(); + + const response = await request(app) + .post(`/standalone/sessions/${sessionId}/${action}`) + .set('X-Qwen-Client-Id', 'client-1') + .send({ + historyPageSize: 20, + liveReplayMode: 'summary', + hideInheritedHistory: true, + }); + + expect(response.status).toBe(200); + expect(service[action]).toHaveBeenCalledWith(sessionId, { + clientId: 'client-1', + historyPageSize: 20, + liveReplayMode: 'summary', + hideInheritedHistory: true, + }); + + const forbidden = await request(app) + .post(`/standalone/sessions/${sessionId}/${action}`) + .send({ workspaceCwd: '/tmp' }); + expect(forbidden.status).toBe(400); + expect(service[action]).toHaveBeenCalledOnce(); + }, + ); + + it('repairs an exact directory only with an empty object body', async () => { + const { app, service } = createHarness(); + + const response = await request(app).post( + `/standalone/sessions/${sessionId}/repair-directory`, + ); + + expect(response.status).toBe(200); + expect(service.repairDirectory).toHaveBeenCalledWith(sessionId); + + const invalid = await request(app) + .post(`/standalone/sessions/${sessionId}/repair-directory`) + .send({ prompt: 'do not replay' }); + expect(invalid.status).toBe(400); + expect(service.repairDirectory).toHaveBeenCalledOnce(); + }); + + it('rejects prototype keys instead of dropping them during body cleanup', async () => { + const { app, service } = createHarness(); + + const response = await request(app) + .post('/standalone/sessions') + .set('Content-Type', 'application/json') + .send(`{"sessionId":"${sessionId}","__proto__":{"cwd":"/tmp"}}`); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('unknown fields'); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('renames with the request client identity', async () => { + const { app, service } = createHarness(); + + const response = await request(app) + .patch(`/standalone/sessions/${sessionId}/metadata`) + .set('X-Qwen-Client-Id', 'client-1') + .send({ displayName: 'Named session' }); + + expect(response.status).toBe(200); + expect(service.rename).toHaveBeenCalledWith( + sessionId, + 'Named session', + 'client-1', + ); + }); + + it('streams an export with the existing renderer metadata', async () => { + const { app, service } = createHarness(); + + const response = await request(app).get( + `/standalone/sessions/${sessionId}/export?format=md`, + ); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/markdown'); + expect(response.headers['content-disposition']).toBe( + 'attachment; filename="session.md"', + ); + expect(response.text).toBe('# Session'); + expect(service.export).toHaveBeenCalledWith(sessionId, 'md'); + }); + + it.each(['archive', 'unarchive', 'delete'] as const)( + 'forwards validated %s batches', + async (action) => { + const { app, service } = createHarness(); + + const response = await request(app) + .post(`/standalone/sessions/${action}`) + .send({ sessionIds: [sessionId, sessionId] }); + + expect(response.status).toBe(200); + expect(service[action]).toHaveBeenCalledWith([sessionId, sessionId]); + + const invalid = await request(app) + .post(`/standalone/sessions/${action}`) + .send({ sessionIds: [] }); + expect(invalid.status).toBe(400); + expect(service[action]).toHaveBeenCalledOnce(); + }, + ); + + it('uses the centralized standalone error serializer', async () => { + const { app, service } = createHarness(); + service.get.mockRejectedValueOnce( + new StandaloneSessionServiceError( + 'deletion_recovery_compromised', + sessionId, + 'Deletion recovery is compromised.', + ), + ); + + const response = await request(app).get( + `/standalone/sessions/${sessionId}`, + ); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ + error: 'Deletion recovery is compromised.', + code: 'deletion_recovery_compromised', + errorKind: 'deletion_recovery_compromised', + retryable: false, + sessionId, + }); + }); +}); diff --git a/packages/cli/src/serve/routes/standalone-sessions.ts b/packages/cli/src/serve/routes/standalone-sessions.ts new file mode 100644 index 00000000000..a809db1ab11 --- /dev/null +++ b/packages/cli/src/serve/routes/standalone-sessions.ts @@ -0,0 +1,466 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + APPROVAL_MODES, + SESSION_TRANSCRIPT_MAX_LIMIT, + type ApprovalMode, + type SessionArchiveState, +} from '@qwen-code/qwen-code-core'; +import type { Application, Request, RequestHandler, Response } from 'express'; +import type { + CreateStandaloneSessionRequest, + RestoreStandaloneSessionOptions, + StandaloneSessionService, +} from '../conversations/standalone-session-service.js'; +import type { SendBridgeError } from '../server/error-response.js'; +import { + parseSessionExportFormat, + sessionExportFormatValues, +} from '../server/session-export.js'; +import { parseClientIdHeader, safeBody } from '../server/request-helpers.js'; + +const MAX_PAGE_SIZE = 100; + +export interface RegisterStandaloneSessionRoutesDeps { + service: StandaloneSessionService; + mutate: (options?: { strict?: boolean }) => RequestHandler; + sendBridgeError: SendBridgeError; +} + +function sendInvalidRequest(res: Response, message: string): void { + res.status(400).json({ + error: message, + code: 'invalid_request', + errorKind: 'invalid_request', + retryable: false, + }); +} + +function objectBody( + req: Request, + res: Response, +): Record | undefined { + if (req.body === undefined) return {}; + if ( + typeof req.body !== 'object' || + req.body === null || + Array.isArray(req.body) + ) { + sendInvalidRequest(res, 'The request body must be a JSON object.'); + return undefined; + } + return safeBody(req); +} + +function hasOnlyKeys( + body: Record, + allowed: readonly string[], +): boolean { + const allowedKeys = new Set(allowed); + return Object.keys(body).every((key) => allowedKeys.has(key)); +} + +function requireExactBody( + req: Request, + res: Response, + allowed: readonly string[], +): Record | undefined { + if ( + req.body !== undefined && + typeof req.body === 'object' && + req.body !== null && + !Array.isArray(req.body) && + !hasOnlyKeys(req.body as Record, allowed) + ) { + sendInvalidRequest(res, 'The request body contains unknown fields.'); + return undefined; + } + const body = objectBody(req, res); + if (!body) return undefined; + if (!hasOnlyKeys(body, allowed)) { + sendInvalidRequest(res, 'The request body contains unknown fields.'); + return undefined; + } + return body; +} + +function parseApprovalMode(value: unknown): ApprovalMode | undefined | null { + if (value === undefined) return undefined; + return typeof value === 'string' && + APPROVAL_MODES.includes(value as ApprovalMode) + ? (value as ApprovalMode) + : null; +} + +function parseRestoreOptions( + req: Request, + res: Response, +): RestoreStandaloneSessionOptions | undefined { + const body = requireExactBody(req, res, [ + 'historyPageSize', + 'liveReplayMode', + 'hideInheritedHistory', + 'approvalMode', + ]); + if (!body) return undefined; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return undefined; + const historyPageSize = body['historyPageSize']; + if ( + historyPageSize !== undefined && + (!Number.isSafeInteger(historyPageSize) || + (historyPageSize as number) < 1 || + (historyPageSize as number) > SESSION_TRANSCRIPT_MAX_LIMIT) + ) { + sendInvalidRequest( + res, + `\`historyPageSize\` must be between 1 and ${SESSION_TRANSCRIPT_MAX_LIMIT}.`, + ); + return undefined; + } + const liveReplayMode = body['liveReplayMode']; + if ( + liveReplayMode !== undefined && + liveReplayMode !== 'full' && + liveReplayMode !== 'summary' + ) { + sendInvalidRequest(res, '`liveReplayMode` must be `full` or `summary`.'); + return undefined; + } + const hideInheritedHistory = body['hideInheritedHistory']; + if ( + hideInheritedHistory !== undefined && + typeof hideInheritedHistory !== 'boolean' + ) { + sendInvalidRequest(res, '`hideInheritedHistory` must be a boolean.'); + return undefined; + } + const approvalMode = parseApprovalMode(body['approvalMode']); + if (approvalMode === null) { + sendInvalidRequest(res, '`approvalMode` must be a known approval mode.'); + return undefined; + } + return { + ...(clientId !== undefined ? { clientId } : {}), + ...(historyPageSize !== undefined + ? { historyPageSize: historyPageSize as number } + : {}), + ...(liveReplayMode !== undefined ? { liveReplayMode } : {}), + ...(hideInheritedHistory !== undefined ? { hideInheritedHistory } : {}), + ...(approvalMode !== undefined ? { approvalMode } : {}), + }; +} + +function parseBatchSessionIds( + req: Request, + res: Response, +): string[] | undefined { + const body = requireExactBody(req, res, ['sessionIds']); + if (!body) return undefined; + const sessionIds = body['sessionIds']; + if ( + !Array.isArray(sessionIds) || + sessionIds.length < 1 || + sessionIds.length > 100 || + !sessionIds.every((sessionId) => typeof sessionId === 'string') + ) { + sendInvalidRequest( + res, + '`sessionIds` must contain between 1 and 100 session id strings.', + ); + return undefined; + } + return sessionIds; +} + +function parseListOptions( + req: Request, + res: Response, +): + | { + cursor?: string; + size?: number; + archiveState?: SessionArchiveState; + signal: AbortSignal; + } + | undefined { + if ( + !Object.keys(req.query).every((key) => + ['cursor', 'size', 'archiveState'].includes(key), + ) + ) { + sendInvalidRequest(res, 'The request query contains unknown fields.'); + return undefined; + } + const cursor = req.query['cursor']; + if ( + cursor !== undefined && + (typeof cursor !== 'string' || cursor.length === 0) + ) { + sendInvalidRequest(res, '`cursor` must be a non-empty string.'); + return undefined; + } + const rawSize = req.query['size']; + let size: number | undefined; + if (rawSize !== undefined) { + if (typeof rawSize !== 'string' || !/^\d+$/u.test(rawSize)) { + sendInvalidRequest(res, '`size` must be an integer between 1 and 100.'); + return undefined; + } + size = Number(rawSize); + if (!Number.isSafeInteger(size) || size < 1 || size > MAX_PAGE_SIZE) { + sendInvalidRequest(res, '`size` must be an integer between 1 and 100.'); + return undefined; + } + } + const archiveState = req.query['archiveState']; + if ( + archiveState !== undefined && + archiveState !== 'active' && + archiveState !== 'archived' + ) { + sendInvalidRequest(res, '`archiveState` must be `active` or `archived`.'); + return undefined; + } + const controller = new AbortController(); + const abort = () => controller.abort(); + req.once('aborted', abort); + res.once('close', () => { + if (!res.writableEnded) abort(); + }); + return { + ...(cursor !== undefined ? { cursor } : {}), + ...(size !== undefined ? { size } : {}), + ...(archiveState !== undefined ? { archiveState } : {}), + signal: controller.signal, + }; +} + +export function registerStandaloneSessionRoutes( + app: Application, + deps: RegisterStandaloneSessionRoutesDeps, +): void { + const handle = async ( + route: string, + req: Request, + res: Response, + operation: () => Promise, + ): Promise => { + try { + await operation(); + } catch (error) { + deps.sendBridgeError(res, error, { + route, + ...(req.params['id'] ? { sessionId: req.params['id'] } : {}), + }); + } + }; + + app.post('/standalone/sessions', deps.mutate({ strict: true }), (req, res) => + handle('POST /standalone/sessions', req, res, async () => { + const body = requireExactBody(req, res, [ + 'sessionId', + 'modelServiceId', + 'approvalMode', + ]); + if (!body) return; + if (typeof body['sessionId'] !== 'string') { + sendInvalidRequest(res, '`sessionId` must be an RFC UUID v1-v5.'); + return; + } + if ( + body['modelServiceId'] !== undefined && + (typeof body['modelServiceId'] !== 'string' || + body['modelServiceId'].length === 0 || + body['modelServiceId'].length > 256) + ) { + sendInvalidRequest( + res, + '`modelServiceId` must be a non-empty string of at most 256 characters.', + ); + return; + } + const approvalMode = parseApprovalMode(body['approvalMode']); + if (approvalMode === null) { + sendInvalidRequest( + res, + '`approvalMode` must be a known approval mode.', + ); + return; + } + const request: CreateStandaloneSessionRequest = { + sessionId: body['sessionId'], + ...(body['modelServiceId'] !== undefined + ? { modelServiceId: body['modelServiceId'] as string } + : {}), + ...(approvalMode !== undefined ? { approvalMode } : {}), + }; + let disconnected = req.aborted; + let responseClient: { sessionId: string; clientId: string } | undefined; + let detach: Promise | undefined; + const detachResponseClient = (): Promise | undefined => { + if (!responseClient) return undefined; + detach ??= deps.service + .detachResponseClient( + responseClient.sessionId, + responseClient.clientId, + ) + .catch(() => undefined); + return detach; + }; + const markDisconnected = () => { + disconnected = true; + void detachResponseClient(); + }; + req.once('aborted', markDisconnected); + res.once('close', () => { + if (!res.writableEnded) markDisconnected(); + }); + const created = await deps.service.create(request); + if (created.session.clientId !== undefined) { + responseClient = { + sessionId: created.session.sessionId, + clientId: created.session.clientId, + }; + } + if (disconnected || !res.writable || res.destroyed) { + await detachResponseClient(); + return; + } + res.status(200).json({ + ...created.session, + sourceType: 'standalone', + context: { kind: 'standalone' }, + projectlessOutputDirectory: created.projectlessOutputDirectory, + workingDirectory: created.workingDirectory, + }); + }), + ); + + app.get('/standalone/sessions', (req, res) => + handle('GET /standalone/sessions', req, res, async () => { + const options = parseListOptions(req, res); + if (!options) return; + res.status(200).json(await deps.service.list(options)); + }), + ); + + app.get('/standalone/sessions/:id', (req, res) => + handle('GET /standalone/sessions/:id', req, res, async () => { + const result = await deps.service.get(req.params['id'] ?? ''); + res.status('state' in result ? 202 : 200).json(result); + }), + ); + + for (const action of ['load', 'resume'] as const) { + app.post( + `/standalone/sessions/:id/${action}`, + deps.mutate({ strict: true }), + (req, res) => + handle( + `POST /standalone/sessions/:id/${action}`, + req, + res, + async () => { + const options = parseRestoreOptions(req, res); + if (!options) return; + res + .status(200) + .json( + await deps.service[action](req.params['id'] ?? '', options), + ); + }, + ), + ); + } + + app.post( + '/standalone/sessions/:id/repair-directory', + deps.mutate({ strict: true }), + (req, res) => + handle( + 'POST /standalone/sessions/:id/repair-directory', + req, + res, + async () => { + const body = requireExactBody(req, res, []); + if (!body) return; + res + .status(200) + .json(await deps.service.repairDirectory(req.params['id'] ?? '')); + }, + ), + ); + + app.patch( + '/standalone/sessions/:id/metadata', + deps.mutate({ strict: true }), + (req, res) => + handle('PATCH /standalone/sessions/:id/metadata', req, res, async () => { + const body = requireExactBody(req, res, ['displayName']); + if (!body) return; + if (typeof body['displayName'] !== 'string') { + sendInvalidRequest(res, '`displayName` must be a string.'); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res + .status(200) + .json( + await deps.service.rename( + req.params['id'] ?? '', + body['displayName'], + clientId, + ), + ); + }), + ); + + app.get('/standalone/sessions/:id/export', (req, res) => + handle('GET /standalone/sessions/:id/export', req, res, async () => { + if (!Object.keys(req.query).every((key) => key === 'format')) { + sendInvalidRequest(res, 'The request query contains unknown fields.'); + return; + } + const format = parseSessionExportFormat(req.query['format']); + if (!format) { + sendInvalidRequest( + res, + `\`format\` must be one of ${sessionExportFormatValues().join(', ')}.`, + ); + return; + } + const exported = await deps.service.export( + req.params['id'] ?? '', + format, + ); + res.set('Content-Type', exported.mimeType); + res.set( + 'Content-Disposition', + `attachment; filename="${exported.filename}"`, + ); + res.status(200).send(exported.content); + }), + ); + + const registerBatch = (action: 'archive' | 'unarchive' | 'delete'): void => { + app.post( + `/standalone/sessions/${action}`, + deps.mutate({ strict: true }), + (req, res) => + handle(`POST /standalone/sessions/${action}`, req, res, async () => { + const sessionIds = parseBatchSessionIds(req, res); + if (!sessionIds) return; + res.status(200).json(await deps.service[action](sessionIds)); + }), + ); + }; + registerBatch('archive'); + registerBatch('unarchive'); + registerBatch('delete'); +} diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 9086f2595da..c81b09d3a51 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -694,6 +694,9 @@ const EXPECTED_REGISTERED_FEATURES = [ if (feature === 'session_tasks') { return [feature, 'scheduled_task_session_reuse']; } + if (feature === 'session_export') { + return [feature, 'standalone_sessions_v1']; + } return [feature]; }).filter( (f) => @@ -2851,6 +2854,20 @@ describe('createServeApp', () => { ); continue; } + if (feature === 'standalone_sessions_v1') { + expect(predicate({ standaloneSessionsAvailable: true })).toBe(true); + expect(predicate({ standaloneSessionsAvailable: false })).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + standaloneSessionsAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } if ( feature === 'mcp_workspace_pool' || feature === 'mcp_pool_restart' @@ -34736,6 +34753,36 @@ describe('Live conversation runtime lifecycle', () => { }; } + it('mounts and advertises standalone sessions only with complete runtime dependencies', async () => { + const partial = createServeApp(baseOpts, undefined, { + bridge: fakeBridge(), + }); + const partialRoute = await request(partial) + .get('/standalone/sessions?cwd=/tmp') + .set('Host', `127.0.0.1:${baseOpts.port}`); + const partialCapabilities = await request(partial) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(partialRoute.status).toBe(404); + expect(partialCapabilities.body.features).not.toContain( + 'standalone_sessions_v1', + ); + + const complete = setupLiveRuntime(); + const completeRoute = await supertest(complete.app) + .get('/standalone/sessions?cwd=/tmp') + .set('Host', `127.0.0.1:${baseOpts.port}`); + const completeCapabilities = await supertest(complete.app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(completeRoute.status).toBe(400); + expect(completeCapabilities.body.features).toContain( + 'standalone_sessions_v1', + ); + }); + it('blocks REST close and archive for active Live sessions until the call stops', async () => { const restoreLiveSettings = await disableLiveVoiceAtBoot(); const bridge = fakeBridge(); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f6e31f7ba0a..3d908808eee 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -70,6 +70,7 @@ import { createSpawnChannelFactory, MAX_SESSION_RESTORE_TIMEOUT_MS, resolveSessionRestoreTimeoutMs, + SessionNotFoundError, type AcpSessionBridge, } from './acp-session-bridge.js'; import { @@ -123,7 +124,11 @@ import { import { registerPermissionRoutes } from './routes/permission.js'; import { registerSessionRoutes } from './routes/session.js'; import { registerSessionPrBackfillRoutes } from './routes/session-pr-backfill.js'; -import { createRequestedSessionIdAdmission } from './session-id-admission.js'; +import { registerStandaloneSessionRoutes } from './routes/standalone-sessions.js'; +import { + createRequestedSessionIdAdmission, + requestedSessionIdPersistenceExists, +} from './session-id-admission.js'; import { registerScheduledTasksRoutes, registerWorkspaceQualifiedScheduledTasksRoutes, @@ -301,6 +306,7 @@ import { } from './conversations/conversation-runtime-errors.js'; import { ConversationRuntimeManager } from './conversations/conversation-runtime-manager.js'; import { StandaloneSessionService } from './conversations/standalone-session-service.js'; +import { StandaloneDeletionJournal } from './conversations/standalone-deletion-journal.js'; import { createConversationRuntimeOwnership, type ConversationRuntimeOwnership, @@ -919,6 +925,7 @@ export function createServeApp( } return () => guard.assertOpen(); }; + let standaloneSessionsAvailable = false; const { languageCodes, currentServeFeatures, invalidateServeFeaturesCache } = createServeFeatures({ opts, @@ -994,6 +1001,7 @@ export function createServeApp( realtimeVoiceEnabled: () => (app.locals as { liveVoiceEnabled?: boolean }).liveVoiceEnabled === true, + standaloneSessionsAvailable: () => standaloneSessionsAvailable, acpHttpEnabled: acpHttpEnabledAtBoot, workspaceRuntimeRemovalAvailable: deps.workspaceRuntimeRemoval !== undefined, @@ -1252,11 +1260,12 @@ export function createServeApp( ); (app.locals as { workspaceRegistry?: WorkspaceRegistry }).workspaceRegistry = workspaceRegistry; + const getSessionBridges = + deps.getSessionBridges ?? + (() => workspaceRegistry.listManaged().map((runtime) => runtime.bridge)); const requestedSessionIdAdmission = createRequestedSessionIdAdmission({ archiveCoordinator, - getBridges: - deps.getSessionBridges ?? - (() => workspaceRegistry.listManaged().map((runtime) => runtime.bridge)), + getBridges: getSessionBridges, getPersistenceTargets: () => workspaceRegistry.listManaged().map((runtime) => ({ workspaceCwd: runtime.workspaceCwd, @@ -1566,6 +1575,11 @@ export function createServeApp( conversationRuntimeActivity && deps.liveConversationWorkspace ) { + const deletionJournal = new StandaloneDeletionJournal( + path.resolve( + deps.liveDiscoveryStableBaseDir ?? getStableLiveDiscoveryBaseDir(), + ), + ); standaloneSessionService = new StandaloneSessionService({ ensureRuntime: ensureConversationRuntimeWithLifecycle, assertRuntimeCurrent: (runtime) => { @@ -1579,13 +1593,38 @@ export function createServeApp( runRuntimeActivity: (_runtime, operation) => conversationRuntimeActivity.run(operation), workspace: deps.liveConversationWorkspace, + deletionJournal, lifecycle: archiveCoordinator, requestedSessionIdAdmission, + hasForeignSessionOwner: async (runtime, sessionId) => { + for (const candidate of new Set(getSessionBridges())) { + if (candidate === runtime.bridge) continue; + try { + candidate.getSessionSummary(sessionId); + return true; + } catch (error) { + if (error instanceof SessionNotFoundError) continue; + throw error; + } + } + for (const candidate of workspaceRegistry.listManaged()) { + if (candidate === runtime) continue; + if ( + await requestedSessionIdPersistenceExists( + createWorkspaceRuntimeSessionService(candidate), + sessionId, + ) + ) { + return true; + } + } + return false; + }, invalidateSessionListCache: (runtime) => invalidateWorkspaceSessionListCache({ runtimeBaseDir: runtime.sessionRuntimeBaseDir, workspaceCwd: runtime.workspaceCwd, - archiveStates: ['active'], + archiveStates: ['active', 'archived'], }), }); ( @@ -2585,6 +2624,15 @@ export function createServeApp( : {}), }); + if (standaloneSessionService) { + registerStandaloneSessionRoutes(app, { + service: standaloneSessionService, + mutate, + sendBridgeError, + }); + standaloneSessionsAvailable = true; + } + registerWorkspaceMcpControlRoutes(app, { boundWorkspace: primaryBoundWorkspace, bridge: primaryBridge, diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index acd290884ee..f3b61878be9 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -98,8 +98,13 @@ describe('sendBridgeError session writer errors', () => { ['standalone_session_not_found', 404, false], ['session_busy', 409, true], ['working_directory_compromised', 409, false], + ['deletion_recovery_compromised', 409, false], + ['standalone_session_operation_failed', 500, false], ['standalone_creation_rolled_back', 500, false], ['standalone_creation_outcome_unknown', 500, false], + ['transcript_deletion_failed', 500, true], + ['transcript_deletion_outcome_unknown', 500, false], + ['working_directory_recovery_failed', 500, true], ] as const)( 'maps standalone service %s to %i', (code, expectedStatus, retryable) => { diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 0a6cbcf938e..ae8ff458955 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -252,7 +252,11 @@ export function sendBridgeError( : err.code === 'standalone_session_not_found' ? 404 : err.code === 'standalone_creation_outcome_unknown' || - err.code === 'standalone_creation_rolled_back' + err.code === 'standalone_creation_rolled_back' || + err.code === 'standalone_session_operation_failed' || + err.code === 'transcript_deletion_failed' || + err.code === 'transcript_deletion_outcome_unknown' || + err.code === 'working_directory_recovery_failed' ? 500 : 409; if (status === 500) recordExpectedBridgeError(err, ctx, daemonLog); diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index 58da5769f67..fab63a6eb67 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -56,6 +56,7 @@ interface CreateServeFeaturesDeps { persistentWorkspaceRegistrationAvailable: boolean; scratchWorkspaceRegistrationAvailable: () => boolean; realtimeVoiceEnabled: () => boolean; + standaloneSessionsAvailable?: () => boolean; acpHttpEnabled?: boolean; workspaceRuntimeRemovalAvailable?: boolean; workspaceTrustHotReloadAvailable?: boolean; @@ -91,6 +92,7 @@ export function createServeFeatures( persistentWorkspaceRegistrationAvailable, scratchWorkspaceRegistrationAvailable, realtimeVoiceEnabled, + standaloneSessionsAvailable, acpHttpEnabled, workspaceRuntimeRemovalAvailable, workspaceTrustHotReloadAvailable, @@ -149,6 +151,7 @@ export function createServeFeatures( workspaceTrustHotReloadAvailable, acpHttpEnabled: currentAcpHttpEnabled, realtimeVoiceEnabled: realtimeVoiceEnabled(), + standaloneSessionsAvailable: standaloneSessionsAvailable?.() === true, clientMcpOverWsEnabled: opts.clientMcpOverWs === true, cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true, browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable( diff --git a/packages/cli/src/serve/server/session-export.ts b/packages/cli/src/serve/server/session-export.ts index 50952c94d7c..90159760692 100644 --- a/packages/cli/src/serve/server/session-export.ts +++ b/packages/cli/src/serve/server/session-export.ts @@ -77,9 +77,14 @@ export async function exportSessionTranscript(params: { format: SessionExportFormat; archiveState?: SessionArchiveState; config?: ExportConfig; + runtimeBaseDir?: string; }): Promise { const { workspaceCwd, sessionId, format } = params; - const service = new SessionService(workspaceCwd); + const service = new SessionService(workspaceCwd, { + ...(params.runtimeBaseDir !== undefined + ? { runtimeBaseDir: params.runtimeBaseDir } + : {}), + }); const sessionData = params.archiveState === 'archived' ? await service.loadArchivedSession(sessionId, { diff --git a/packages/cli/src/serve/session-id-admission.ts b/packages/cli/src/serve/session-id-admission.ts index a3bf7589c25..e2991bd61ce 100644 --- a/packages/cli/src/serve/session-id-admission.ts +++ b/packages/cli/src/serve/session-id-admission.ts @@ -69,7 +69,7 @@ interface PendingRestore { type PendingAdmission = PendingCreate | PendingRestore; -async function persistedSessionExists( +export async function requestedSessionIdPersistenceExists( sessionService: SessionService, sessionId: string, ): Promise { @@ -260,7 +260,7 @@ export function createRequestedSessionIdAdmission({ ); return { entry, - exists: await persistedSessionExists( + exists: await requestedSessionIdPersistenceExists( sessionService, sessionId, ), diff --git a/packages/cli/src/utils/conversation-directory-identity.ts b/packages/cli/src/utils/conversation-directory-identity.ts index 1ae709120d3..9e02892a95d 100644 --- a/packages/cli/src/utils/conversation-directory-identity.ts +++ b/packages/cli/src/utils/conversation-directory-identity.ts @@ -161,14 +161,15 @@ function hasExpectedDirectoryIdentity( identity: ConversationDirectoryIdentity, expected: ConversationDirectoryIdentity, ): boolean { - const inodesProvable = - hasVerifiableInode(identity.inode) && hasVerifiableInode(expected.inode); + const identityInodeVerifiable = hasVerifiableInode(identity.inode); + const expectedInodeVerifiable = hasVerifiableInode(expected.inode); return ( identity.storageSessionId === expected.storageSessionId && identity.name === expected.name && isSameConversationPath(identity.canonicalPath, expected.canonicalPath) && identity.device === expected.device && - (!inodesProvable || identity.inode === expected.inode) && + identityInodeVerifiable === expectedInodeVerifiable && + (!identityInodeVerifiable || identity.inode === expected.inode) && isSameConversationPath( identity.root.configuredRoot, expected.root.configuredRoot, @@ -177,10 +178,6 @@ function hasExpectedDirectoryIdentity( identity.root.canonicalRoot, expected.root.canonicalRoot, ) && - isSameConversationPath( - identity.root.configuredRoot, - expected.root.configuredRoot, - ) && identity.root.device === expected.root.device && identity.root.inodeVerifiable === expected.root.inodeVerifiable && (!identity.root.inodeVerifiable || @@ -404,8 +401,8 @@ export function isSameConversationDirectoryObject( expected.root.canonicalRoot, ) && identity.root.device === expected.root.device && + identity.root.inodeVerifiable === expected.root.inodeVerifiable && (!identity.root.inodeVerifiable || - !expected.root.inodeVerifiable || identity.root.inode === expected.root.inode) ); } From 90c5f557154ae08c9dc0d2a2c8bfc13799bd17da Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 27 Aug 2026 02:48:45 +0800 Subject: [PATCH 5/8] fix(daemon): harden standalone session lifecycle Co-authored-by: Qwen-Coder --- .../acp-bridge/src/sessionAttachments.test.ts | 250 +++++++ packages/acp-bridge/src/sessionAttachments.ts | 167 ++++- .../conversation-workspace.test.ts | 55 ++ .../conversations/conversation-workspace.ts | 69 +- .../standalone-deletion-journal.test.ts | 215 +++++- .../standalone-deletion-journal.ts | 256 +++++-- .../standalone-session-service.test.ts | 631 +++++++++++++++++- .../standalone-session-service.ts | 606 +++++++++++++---- .../serve/routes/standalone-sessions.test.ts | 136 +++- .../src/serve/routes/standalone-sessions.ts | 104 ++- .../src/serve/server/error-response.test.ts | 2 +- packages/core/src/config/config.test.ts | 43 ++ packages/core/src/config/config.ts | 58 +- .../src/services/chatRecordingService.test.ts | 32 + .../core/src/services/chatRecordingService.ts | 21 +- .../src/services/session-writer-lease.test.ts | 210 +++++- .../core/src/services/session-writer-lease.ts | 206 +++++- .../services/sessionService.rename.test.ts | 63 ++ .../core/src/services/sessionService.test.ts | 273 ++++++++ packages/core/src/services/sessionService.ts | 312 ++++++++- 20 files changed, 3438 insertions(+), 271 deletions(-) diff --git a/packages/acp-bridge/src/sessionAttachments.test.ts b/packages/acp-bridge/src/sessionAttachments.test.ts index 5fec06a73dc..53fc3382b25 100644 --- a/packages/acp-bridge/src/sessionAttachments.test.ts +++ b/packages/acp-bridge/src/sessionAttachments.test.ts @@ -821,6 +821,256 @@ describe('SessionAttachmentStore', () => { } }); + it('retries durable deletion after the attachment parent sync fails', async () => { + const root = await fs.mkdtemp( + path.join(tmpdir(), 'qwen-attachment-durability-'), + ); + const store = new SessionAttachmentStore(root, 'session-a'); + await store.putAttachment( + Uint8Array.of(1), + 'application/octet-stream', + 'old.bin', + ); + const originalOpen = fs.open.bind(fs); + let failSync = true; + const open = vi.spyOn(fs, 'open').mockImplementation(async (...args) => { + const handle = await originalOpen(...args); + if (String(args[0]) === root) { + const originalSync = handle.sync.bind(handle); + handle.sync = async () => { + if (failSync) { + failSync = false; + throw Object.assign(new Error('directory sync failure'), { + code: 'EIO', + }); + } + await originalSync(); + }; + } + return handle; + }); + + try { + await expect(store.delete()).rejects.toMatchObject({ code: 'EIO' }); + await expect( + fs.stat(path.join(root, 'session-session-a')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + + await expect(store.delete()).resolves.toBeUndefined(); + } finally { + open.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('rejects attachment parent disappearance after opening it', async () => { + const root = await fs.mkdtemp( + path.join(tmpdir(), 'qwen-attachment-parent-vanished-'), + ); + const store = new SessionAttachmentStore(root, 'session-a'); + const reference = await store.putAttachment( + Uint8Array.of(1), + 'application/octet-stream', + 'kept.bin', + ); + const originalStat = fs.stat.bind(fs); + const vanished = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + let rejectParentStat = true; + const stat = vi.spyOn(fs, 'stat').mockImplementation(async (...args) => { + if (String(args[0]) === root && rejectParentStat) { + rejectParentStat = false; + throw vanished; + } + return originalStat(...args); + }); + + try { + await expect(store.delete()).rejects.toBe(vanished); + await expect( + fs.stat(path.join(root, 'session-session-a', reference.attachmentId)), + ).resolves.toBeDefined(); + } finally { + stat.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('retries a claimed attachment tombstone without deleting a successor', async () => { + const root = await fs.mkdtemp( + path.join(tmpdir(), 'qwen-attachment-tombstone-retry-'), + ); + const store = new SessionAttachmentStore(root, 'session-a'); + await store.putAttachment( + Uint8Array.of(1), + 'application/octet-stream', + 'old.bin', + ); + const directory = path.join(root, 'session-session-a'); + const tombstone = path.join(root, '.session-session-a.deleting'); + const removeError = Object.assign(new Error('remove failed'), { + code: 'EIO', + }); + const remove = vi.spyOn(fs, 'rm').mockRejectedValueOnce(removeError); + + try { + await expect(store.delete()).rejects.toBe(removeError); + await expect(fs.stat(tombstone)).resolves.toBeDefined(); + await expect(fs.stat(directory)).rejects.toMatchObject({ + code: 'ENOENT', + }); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + path.join(directory, 'successor.bin'), + Uint8Array.of(2), + ); + + const recovery = new SessionAttachmentStore(root, 'session-a'); + await expect(recovery.delete()).resolves.toBeUndefined(); + + await expect(fs.stat(tombstone)).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect( + fs.stat(path.join(directory, 'successor.bin')), + ).resolves.toBeDefined(); + } finally { + remove.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('rechecks the runtime generation after opening the attachment parent', async () => { + const root = await fs.mkdtemp( + path.join(tmpdir(), 'qwen-attachment-parent-fence-'), + ); + const store = new SessionAttachmentStore(root, 'session-a'); + await store.putAttachment( + Uint8Array.of(1), + 'application/octet-stream', + 'kept.bin', + ); + const originalOpen = fs.open.bind(fs); + let continueOpen: (() => void) | undefined; + const openPaused = new Promise((resolve) => { + continueOpen = resolve; + }); + let parentOpened: (() => void) | undefined; + const parentOpenStarted = new Promise((resolve) => { + parentOpened = resolve; + }); + const open = vi.spyOn(fs, 'open').mockImplementation(async (...args) => { + if (String(args[0]) === root) { + parentOpened?.(); + await openPaused; + } + return originalOpen(...args); + }); + const rename = vi.spyOn(fs, 'rename'); + const generationClosed = new Error('runtime generation closed'); + let generationCurrent = true; + + try { + const deleting = store.delete({ + assertCanCommit: () => { + if (!generationCurrent) throw generationClosed; + }, + }); + await parentOpenStarted; + generationCurrent = false; + continueOpen?.(); + + await expect(deleting).rejects.toBe(generationClosed); + expect(rename).not.toHaveBeenCalled(); + await expect( + fs.stat(path.join(root, 'session-session-a', 'kept.bin')), + ).resolves.toBeDefined(); + } finally { + continueOpen?.(); + open.mockRestore(); + rename.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('deletes attachments when the filesystem does not expose inodes', async () => { + const root = await fs.mkdtemp( + path.join(tmpdir(), 'qwen-attachment-zero-inode-'), + ); + const store = new SessionAttachmentStore(root, 'session-a'); + await store.putAttachment( + Uint8Array.of(1), + 'application/octet-stream', + 'old.bin', + ); + const originalOpen = fs.open.bind(fs); + const originalStat = fs.stat.bind(fs); + const open = vi.spyOn(fs, 'open').mockImplementation(async (...args) => { + const handle = await originalOpen(...args); + if (String(args[0]) === root) { + const handleStat = handle.stat.bind(handle); + handle.stat = (async (...statArgs) => { + const result = await handleStat(...statArgs); + Object.defineProperty(result, 'ino', { value: 0 }); + return result; + }) as typeof handle.stat; + } + return handle; + }); + const stat = vi.spyOn(fs, 'stat').mockImplementation(async (...args) => { + const result = await originalStat(...args); + if (String(args[0]) === root) { + Object.defineProperty(result, 'ino', { value: 0 }); + } + return result; + }); + + try { + await expect(store.delete()).resolves.toBeUndefined(); + await expect( + fs.stat(path.join(root, 'session-session-a')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + open.mockRestore(); + stat.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('rejects deletion when parent inode verifiability changes', async () => { + const root = await fs.mkdtemp( + path.join(tmpdir(), 'qwen-attachment-inode-transition-'), + ); + const store = new SessionAttachmentStore(root, 'session-a'); + await store.putAttachment( + Uint8Array.of(1), + 'application/octet-stream', + 'kept.bin', + ); + const originalStat = fs.stat.bind(fs); + const stat = vi.spyOn(fs, 'stat').mockImplementation(async (...args) => { + const result = await originalStat(...args); + if (String(args[0]) === root) { + Object.defineProperty(result, 'ino', { value: 0 }); + } + return result; + }); + const rename = vi.spyOn(fs, 'rename'); + + try { + await expect(store.delete()).rejects.toThrow( + 'Session attachment parent directory changed.', + ); + expect(rename).not.toHaveBeenCalled(); + await expect( + fs.stat(path.join(root, 'session-session-a', 'kept.bin')), + ).resolves.toBeDefined(); + } finally { + stat.mockRestore(); + rename.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + it('forgets attachments whose backing file disappeared', async () => { const root = await fs.mkdtemp(path.join(tmpdir(), 'qwen-attachment-gone-')); const store = new SessionAttachmentStore(root, 'session-a'); diff --git a/packages/acp-bridge/src/sessionAttachments.ts b/packages/acp-bridge/src/sessionAttachments.ts index 4ff8500ed69..195deb992a4 100644 --- a/packages/acp-bridge/src/sessionAttachments.ts +++ b/packages/acp-bridge/src/sessionAttachments.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { randomUUID } from 'node:crypto'; -import { promises as fs, statSync } from 'node:fs'; +import { constants as fsConstants, promises as fs, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import type { ContentBlock } from '@agentclientprotocol/sdk'; @@ -21,6 +20,116 @@ const SUPPORTED_IMAGE_MIME_TYPES = new Set([ 'image/webp', ]); +interface DurableAttachmentDirectory { + path: string; + handle: Awaited>; + dev: number; + ino: number; + inodeVerifiable: boolean; +} + +function hasVerifiableInode(ino: number): boolean { + return Number.isSafeInteger(ino) && ino > 0; +} + +async function openDurableAttachmentDirectory( + directory: string, + openedHandle?: Awaited>, +): Promise { + const handle = + openedHandle ?? + (await fs.open( + directory, + fsConstants.O_RDONLY | + (process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0)), + )); + try { + const opened = await handle.stat(); + const current = await fs.stat(directory); + const openedInodeVerifiable = hasVerifiableInode(opened.ino); + const currentInodeVerifiable = hasVerifiableInode(current.ino); + if ( + !opened.isDirectory() || + !current.isDirectory() || + opened.dev !== current.dev || + openedInodeVerifiable !== currentInodeVerifiable || + (openedInodeVerifiable && opened.ino !== current.ino) + ) { + throw new Error('Session attachment parent directory changed.'); + } + return { + path: directory, + handle, + dev: opened.dev, + ino: opened.ino, + inodeVerifiable: openedInodeVerifiable, + }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +} + +async function openDurableAttachmentDirectoryIfPresent( + directory: string, +): Promise { + let handle: Awaited>; + try { + handle = await fs.open( + directory, + fsConstants.O_RDONLY | + (process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0)), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + return openDurableAttachmentDirectory(directory, handle); +} + +async function syncDurableAttachmentDirectory( + expected: DurableAttachmentDirectory, +): Promise { + const opened = await expected.handle.stat(); + const openedInodeVerifiable = hasVerifiableInode(opened.ino); + const current = await fs.stat(expected.path); + const currentInodeVerifiable = hasVerifiableInode(current.ino); + if ( + !opened.isDirectory() || + !current.isDirectory() || + opened.dev !== expected.dev || + openedInodeVerifiable !== expected.inodeVerifiable || + (expected.inodeVerifiable && opened.ino !== expected.ino) || + current.dev !== expected.dev || + currentInodeVerifiable !== expected.inodeVerifiable || + (expected.inodeVerifiable && current.ino !== expected.ino) + ) { + throw new Error('Session attachment parent directory changed.'); + } + try { + await expected.handle.sync(); + } catch (error) { + if ( + process.platform !== 'win32' || + !['EACCES', 'EINVAL', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) { + throw error; + } + } + const after = await fs.stat(expected.path); + const afterInodeVerifiable = hasVerifiableInode(after.ino); + if ( + !after.isDirectory() || + after.dev !== expected.dev || + afterInodeVerifiable !== expected.inodeVerifiable || + (expected.inodeVerifiable && after.ino !== expected.ino) + ) { + throw new Error('Session attachment parent directory changed.'); + } +} + // Text the degrade paths substitute for an attachment the model will not receive. The // SDK's DaemonSessionClient.hydrateBlock and the web shell's degradation // detection carry their own copies; keep the wording in sync. @@ -572,17 +681,59 @@ export class SessionAttachmentStore { (await this.directoryPromise?.catch(() => undefined)); if (directory) { options.assertCanCommit?.(); + const parent = await openDurableAttachmentDirectoryIfPresent( + path.dirname(directory), + ); + if (!parent) return; const tombstone = path.join( path.dirname(directory), - `.${path.basename(directory)}.deleting-${randomUUID()}`, + `.${path.basename(directory)}.deleting`, ); try { - await fs.rename(directory, tombstone); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; - throw error; + options.assertCanCommit?.(); + let tombstoneExists = false; + try { + const stats = await fs.lstat(tombstone); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('Session attachment tombstone is not a directory.'); + } + tombstoneExists = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (tombstoneExists) { + options.assertCanCommit?.(); + await fs.rm(tombstone, { recursive: true, force: true }); + await syncDurableAttachmentDirectory(parent); + return; + } + options.assertCanCommit?.(); + try { + await fs.rename(directory, tombstone); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + try { + const stats = await fs.lstat(tombstone); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error( + 'Session attachment tombstone is not a directory.', + ); + } + options.assertCanCommit?.(); + await fs.rm(tombstone, { recursive: true, force: true }); + } catch (tombstoneError) { + if ((tombstoneError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw tombstoneError; + } + } + await syncDurableAttachmentDirectory(parent); + return; + } + await fs.rm(tombstone, { recursive: true, force: true }); + await syncDurableAttachmentDirectory(parent); + } finally { + await parent.handle.close().catch(() => undefined); } - await fs.rm(tombstone, { recursive: true, force: true }); } } diff --git a/packages/cli/src/serve/conversations/conversation-workspace.test.ts b/packages/cli/src/serve/conversations/conversation-workspace.test.ts index 6a2503f0f8e..bb3c91097f2 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.test.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.test.ts @@ -10,6 +10,7 @@ import { lstat, mkdir, mkdtemp, + open, rename, rm, symlink, @@ -68,6 +69,7 @@ vi.mock( const cleanup: string[] = []; afterEach(async () => { + vi.restoreAllMocks(); await Promise.all( cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true })), ); @@ -626,6 +628,59 @@ describe('Live conversation workspace root', () => { ).toBe(true); }); + it('surfaces root directory sync failures', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const root = await workspace.getRoot(); + const probe = await open(root.canonicalRoot, 'r'); + const prototype = Object.getPrototypeOf(probe) as { + sync(): Promise; + }; + await probe.close(); + const syncError = Object.assign(new Error('root sync failed'), { + code: 'EIO', + }); + vi.spyOn(prototype, 'sync').mockRejectedValueOnce(syncError); + + await expect(workspace.confirmStandaloneRootDurability(root)).rejects.toBe( + syncError, + ); + }); + + it('rejects a root whose inode verifiability changed', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const root = await workspace.getRoot(); + + await expect( + workspace.confirmStandaloneRootDurability({ + ...root, + inode: 0, + inodeVerifiable: false, + }), + ).rejects.toBeInstanceOf(ConversationDirectoryIdentityError); + }); + + it('does not treat root inspection failures as unsupported Windows sync', async () => { + const home = await tempHome(); + const workspace = new ConversationWorkspace({ homeDir: home }); + const root = await workspace.getRoot(); + const probe = await open(root.canonicalRoot, 'r'); + const prototype = Object.getPrototypeOf(probe) as { + stat(): ReturnType; + }; + await probe.close(); + const statError = Object.assign(new Error('root stat failed'), { + code: 'EACCES', + }); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.spyOn(prototype, 'stat').mockRejectedValueOnce(statError); + + await expect(workspace.confirmStandaloneRootDurability(root)).rejects.toBe( + statError, + ); + }); + it('fails closed when both normal and staged deletion paths exist', async () => { const home = await tempHome(); const workspace = new ConversationWorkspace({ homeDir: home }); diff --git a/packages/cli/src/serve/conversations/conversation-workspace.ts b/packages/cli/src/serve/conversations/conversation-workspace.ts index 1beabc96a0a..06b6f6814ae 100644 --- a/packages/cli/src/serve/conversations/conversation-workspace.ts +++ b/packages/cli/src/serve/conversations/conversation-workspace.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readdir, rename, rm, rmdir } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { open, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { @@ -451,6 +452,7 @@ export class ConversationWorkspace { ) { throw new ConversationDirectoryIdentityError('child', 'identity_changed'); } + await this.syncStandaloneRoot(root); return staged; } @@ -482,6 +484,7 @@ export class ConversationWorkspace { ) { throw new ConversationDirectoryIdentityError('child', 'identity_changed'); } + await this.syncStandaloneRoot(root); return restored; } @@ -508,6 +511,68 @@ export class ConversationWorkspace { throw new ConversationDirectoryIdentityError('child', 'identity_changed'); } await rm(confirmed.canonicalPath, { recursive: true }); - await revalidateConversationRootIdentity(confirmed.root); + await this.syncStandaloneRoot(confirmed.root); + } + + async confirmStandaloneRootDurability( + root: ConversationRootIdentity, + ): Promise { + await this.syncStandaloneRoot(root); + } + + private async syncStandaloneRoot( + root: ConversationRootIdentity, + ): Promise { + await revalidateConversationRootIdentity(root); + let handle: Awaited> | undefined; + try { + handle = await open( + root.canonicalRoot, + fsConstants.O_RDONLY | + (process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0)), + ); + const stats = await handle.stat(); + const inodeVerifiable = Number.isSafeInteger(stats.ino) && stats.ino > 0; + if ( + !stats.isDirectory() || + stats.dev !== root.device || + inodeVerifiable !== root.inodeVerifiable || + (root.inodeVerifiable && stats.ino !== root.inode) + ) { + throw new ConversationDirectoryIdentityError( + 'root', + 'identity_changed', + ); + } + try { + await handle.sync(); + } catch (error) { + if ( + process.platform !== 'win32' || + !['EACCES', 'EINVAL', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) { + throw error; + } + } + const current = await stat(root.canonicalRoot); + const currentInodeVerifiable = + Number.isSafeInteger(current.ino) && current.ino > 0; + if ( + !current.isDirectory() || + current.dev !== root.device || + currentInodeVerifiable !== root.inodeVerifiable || + (root.inodeVerifiable && current.ino !== root.inode) + ) { + throw new ConversationDirectoryIdentityError( + 'root', + 'identity_changed', + ); + } + } finally { + await handle?.close().catch(() => undefined); + } + await revalidateConversationRootIdentity(root); } } diff --git a/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts b/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts index afa290d771b..a22e002e971 100644 --- a/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts +++ b/packages/cli/src/serve/conversations/standalone-deletion-journal.test.ts @@ -5,17 +5,25 @@ */ import * as fs from 'node:fs/promises'; +import type { PathLike } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getConversationDirectoryName } from '../../utils/conversation-directory-identity.js'; import { ConversationWorkspace } from './conversation-workspace.js'; import { StandaloneDeletionJournal, StandaloneDeletionJournalError, - type StandaloneDeletionRecordV1, + type StandaloneDeletionRecordV2, } from './standalone-deletion-journal.js'; +const { openMock } = vi.hoisted(() => ({ openMock: vi.fn() })); +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + openMock.mockImplementation(actual.open); + return { ...actual, open: openMock }; +}); + const SESSION_ID = '550e8400-e29b-41d4-a716-446655440000'; describe('StandaloneDeletionJournal', () => { @@ -38,6 +46,7 @@ describe('StandaloneDeletionJournal', () => { }); afterEach(async () => { + vi.clearAllMocks(); await fs.rm(homeDir, { recursive: true, force: true }); }); @@ -50,15 +59,20 @@ describe('StandaloneDeletionJournal', () => { const makeRecord = async ( phase: 'prepared' | 'staged', - directory: StandaloneDeletionRecordV1['directory'] = { kind: 'absent' }, - ): Promise => { + directory: StandaloneDeletionRecordV2['directory'] = { kind: 'absent' }, + ): Promise => { const root = await workspace.getRoot(); return { - version: 1, + version: 2, phase, sessionId: SESSION_ID, storageSessionId: SESSION_ID.toUpperCase(), transcriptLocation: 'active', + transcriptParent: { + device: 11, + inode: 12, + inodeVerifiable: true, + }, root: { canonicalPath: root.canonicalRoot, device: root.device, @@ -87,6 +101,156 @@ describe('StandaloneDeletionJournal', () => { } }); + it('reads the exact legacy V1 record without inventing parent proof', async () => { + const root = await workspace.getRoot(); + const current = await makeRecord('prepared'); + const { transcriptParent: _transcriptParent, ...legacyFields } = current; + const legacy = { ...legacyFields, version: 1 as const }; + const journalDirectory = path.dirname(journalPath('prepared')); + await fs.mkdir(journalDirectory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') { + await fs.chmod(journalDirectory, 0o700); + } + await fs.writeFile(journalPath('prepared'), JSON.stringify(legacy), { + mode: 0o600, + }); + + await expect(journal.read(SESSION_ID, root)).resolves.toEqual({ + prepared: legacy, + }); + }); + + it('rejects journal directory replacement during phase sync', async () => { + const root = await workspace.getRoot(); + const record = await makeRecord('prepared'); + const journalDirectory = path.dirname(journalPath('prepared')); + const originalDirectory = `${journalDirectory}.original`; + const originalOpen = openMock.getMockImplementation(); + if (!originalOpen) throw new Error('expected fs.open implementation'); + let replaced = false; + openMock.mockImplementation(async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (String(args[0]) === journalDirectory) { + const sync = handle.sync.bind(handle); + handle.sync = async () => { + if (!replaced) { + replaced = true; + await fs.rename(journalDirectory, originalDirectory); + await fs.mkdir(journalDirectory, { mode: 0o700 }); + } + await sync(); + }; + } + return handle; + }); + + try { + await expect(journal.writePrepared(record, root)).rejects.toMatchObject({ + reason: 'compromised', + }); + await expect( + fs.stat( + path.join(originalDirectory, path.basename(journalPath('prepared'))), + ), + ).resolves.toBeDefined(); + await expect(fs.lstat(journalPath('prepared'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + openMock.mockImplementation(originalOpen); + await fs.rmdir(journalDirectory); + await fs.rename(originalDirectory, journalDirectory); + await journal.clear(SESSION_ID, root); + } + }); + + it('retains a same-session fence until clear durability is confirmed', async () => { + const root = await workspace.getRoot(); + const prepared = await makeRecord('prepared'); + await journal.writePrepared(prepared, root); + const journalDirectory = path.dirname(journalPath('prepared')); + const originalOpen = openMock.getMockImplementation(); + if (!originalOpen) throw new Error('expected fs.open implementation'); + let failSync = true; + openMock.mockImplementation(async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (String(args[0]) === journalDirectory) { + const sync = handle.sync.bind(handle); + handle.sync = async () => { + if (failSync) { + failSync = false; + throw Object.assign(new Error('directory sync failed'), { + code: 'EIO', + }); + } + await sync(); + }; + } + return handle; + }); + + try { + await expect(journal.clear(SESSION_ID, root)).rejects.toMatchObject({ + code: 'EIO', + }); + await expect(journal.hasRecord(SESSION_ID)).resolves.toBe(true); + await expect(journal.read(SESSION_ID, root)).resolves.toEqual({ + prepared, + }); + await expect(journal.writePrepared(prepared, root)).rejects.toMatchObject( + { reason: 'conflict' }, + ); + + await expect(journal.clear(SESSION_ID, root)).resolves.toBeUndefined(); + await expect(journal.hasRecord(SESSION_ID)).resolves.toBe(false); + } finally { + openMock.mockImplementation(originalOpen); + } + }); + + it('requires the journal owner directory to sync before writing a phase', async () => { + const root = await workspace.getRoot(); + const record = await makeRecord('prepared'); + const ownerStats = await fs.stat(ownerDirectory); + const syncError = Object.assign(new Error('owner sync failed'), { + code: 'EIO', + }); + openMock.mockImplementationOnce(async (filePath: PathLike) => { + expect(filePath.toString()).toBe(ownerDirectory); + return { + stat: async () => ownerStats, + sync: async () => Promise.reject(syncError), + close: async () => undefined, + } as unknown as fs.FileHandle; + }); + + await expect(journal.writePrepared(record, root)).rejects.toBe(syncError); + await expect(fs.lstat(journalPath('prepared'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('does not hide Windows journal owner open failures', async () => { + const root = await workspace.getRoot(); + const record = await makeRecord('prepared'); + const openError = Object.assign(new Error('owner open failed'), { + code: 'EACCES', + }); + const platform = vi + .spyOn(process, 'platform', 'get') + .mockReturnValue('win32'); + openMock.mockRejectedValueOnce(openError); + + try { + await expect(journal.writePrepared(record, root)).rejects.toBe(openError); + await expect(fs.lstat(journalPath('prepared'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + platform.mockRestore(); + } + }); + it('writes matching immutable phases and clears staged before prepared', async () => { const root = await workspace.getRoot(); const prepared = await makeRecord('prepared'); @@ -105,6 +269,45 @@ describe('StandaloneDeletionJournal', () => { await expect(journal.read(SESSION_ID, root)).resolves.toBeUndefined(); }); + it('rejects journal directory replacement while clearing phases', async () => { + const root = await workspace.getRoot(); + const prepared = await makeRecord('prepared'); + await journal.writePrepared(prepared, root); + const journalDirectory = path.dirname(journalPath('prepared')); + const originalDirectory = `${journalDirectory}.original`; + const originalOpen = openMock.getMockImplementation(); + if (!originalOpen) throw new Error('expected fs.open implementation'); + let replaced = false; + openMock.mockImplementation(async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (String(args[0]) === journalDirectory) { + const sync = handle.sync.bind(handle); + handle.sync = async () => { + if (!replaced) { + replaced = true; + await fs.rename(journalDirectory, originalDirectory); + await fs.mkdir(journalDirectory, { mode: 0o700 }); + } + await sync(); + }; + } + return handle; + }); + + try { + await expect(journal.clear(SESSION_ID, root)).rejects.toMatchObject({ + reason: 'compromised', + }); + await expect( + fs.lstat( + path.join(originalDirectory, path.basename(journalPath('prepared'))), + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + openMock.mockImplementation(originalOpen); + } + }); + it('refuses to overwrite an immutable prepared phase', async () => { const root = await workspace.getRoot(); const record = await makeRecord('prepared'); @@ -209,7 +412,7 @@ describe('StandaloneDeletionJournal', () => { const root = await workspace.getRoot(); const secondId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; const first = await makeRecord('prepared'); - const second: StandaloneDeletionRecordV1 = { + const second: StandaloneDeletionRecordV2 = { ...first, sessionId: secondId, storageSessionId: secondId, diff --git a/packages/cli/src/serve/conversations/standalone-deletion-journal.ts b/packages/cli/src/serve/conversations/standalone-deletion-journal.ts index b94bcf341fd..808ff1813be 100644 --- a/packages/cli/src/serve/conversations/standalone-deletion-journal.ts +++ b/packages/cli/src/serve/conversations/standalone-deletion-journal.ts @@ -29,6 +29,12 @@ export interface StandaloneDeletionRootIdentity { inodeVerifiable: boolean; } +export interface StandaloneDeletionTranscriptParentIdentity { + device: number; + inode: number; + inodeVerifiable: boolean; +} + export type StandaloneDeletionDirectory = | { kind: 'absent' } | { @@ -40,8 +46,7 @@ export type StandaloneDeletionDirectory = inodeVerifiable: boolean; }; -export interface StandaloneDeletionRecordV1 { - version: 1; +interface StandaloneDeletionRecordBase { phase: 'prepared' | 'staged'; sessionId: string; storageSessionId: string; @@ -50,9 +55,24 @@ export interface StandaloneDeletionRecordV1 { directory: StandaloneDeletionDirectory; } +export interface StandaloneDeletionRecordV1 + extends StandaloneDeletionRecordBase { + version: 1; +} + +export interface StandaloneDeletionRecordV2 + extends StandaloneDeletionRecordBase { + version: 2; + transcriptParent: StandaloneDeletionTranscriptParentIdentity; +} + +export type StandaloneDeletionRecord = + | StandaloneDeletionRecordV1 + | StandaloneDeletionRecordV2; + export interface StandaloneDeletionJournalEntry { - prepared: StandaloneDeletionRecordV1; - staged?: StandaloneDeletionRecordV1; + prepared: StandaloneDeletionRecord; + staged?: StandaloneDeletionRecord; } export type StandaloneDeletionJournalErrorReason = 'conflict' | 'compromised'; @@ -71,6 +91,17 @@ interface DirectoryIdentity { inodeVerifiable: boolean; } +interface DurableDirectory { + path: string; + identity: DirectoryIdentity; + handle: fs.FileHandle; +} + +interface PendingJournalClear { + entry: StandaloneDeletionJournalEntry; + directory: DurableDirectory; +} + function isMissing(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === 'ENOENT'; } @@ -152,9 +183,11 @@ function parseRecord( expectedSessionId: string, expectedPhase: 'prepared' | 'staged', currentRoot: ConversationRootIdentity, -): StandaloneDeletionRecordV1 { +): StandaloneDeletionRecord { + const version = isRecord(value) ? value['version'] : undefined; if ( !isRecord(value) || + (version !== 1 && version !== 2) || !exactKeys(value, [ 'directory', 'phase', @@ -162,9 +195,9 @@ function parseRecord( 'sessionId', 'storageSessionId', 'transcriptLocation', + ...(version === 2 ? ['transcriptParent'] : []), 'version', ]) || - value['version'] !== 1 || value['phase'] !== expectedPhase || value['sessionId'] !== expectedSessionId || typeof value['storageSessionId'] !== 'string' || @@ -194,6 +227,22 @@ function parseRecord( throw new StandaloneDeletionJournalError('compromised'); } + const transcriptParent = + version === 2 ? parseIdentity(value['transcriptParent'], false) : undefined; + if (version === 2) { + if ( + !transcriptParent || + !isRecord(value['transcriptParent']) || + !exactKeys(value['transcriptParent'], [ + 'device', + 'inode', + 'inodeVerifiable', + ]) + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + } + const rawDirectory = value['directory']; let directory: StandaloneDeletionDirectory; if ( @@ -232,20 +281,29 @@ function parseRecord( }; } - return { - version: 1, + const base = { phase: expectedPhase, sessionId: expectedSessionId, storageSessionId: value['storageSessionId'], - transcriptLocation: value['transcriptLocation'], + transcriptLocation: value['transcriptLocation'] as 'active' | 'archived', root, directory, }; + if (version === 1) return { version: 1, ...base }; + return { + version: 2, + ...base, + transcriptParent: { + device: transcriptParent!.device, + inode: transcriptParent!.inode, + inodeVerifiable: transcriptParent!.inodeVerifiable, + }, + }; } function sameImmutableRecord( - prepared: StandaloneDeletionRecordV1, - staged: StandaloneDeletionRecordV1, + prepared: StandaloneDeletionRecord, + staged: StandaloneDeletionRecord, ): boolean { return ( JSON.stringify({ ...prepared, phase: 'staged' }) === JSON.stringify(staged) @@ -255,6 +313,7 @@ function sameImmutableRecord( export class StandaloneDeletionJournal { private readonly ownerDirectory: string; private readonly journalDirectory: string; + private readonly pendingClears = new Map(); constructor(stableBaseDir: string) { if (!path.isAbsolute(stableBaseDir)) { @@ -268,6 +327,7 @@ export class StandaloneDeletionJournal { async hasRecord(rawSessionId: string): Promise { const sessionId = parseSessionId(rawSessionId); + if (this.pendingClears.has(sessionId)) return true; const identity = await this.inspectJournalDirectory(); if (!identity) return false; const exists = @@ -278,17 +338,21 @@ export class StandaloneDeletionJournal { } async listSessionIds(limit = 32): Promise { + const ids = new Set(this.pendingClears.keys()); const identity = await this.inspectJournalDirectory(); - if (!identity) return []; + if (!identity) { + return [...ids].sort().slice(0, Math.max(0, limit)); + } let names: string[]; try { names = await fs.readdir(this.journalDirectory); } catch (error) { - if (isMissing(error)) return []; + if (isMissing(error)) { + return [...ids].sort().slice(0, Math.max(0, limit)); + } throw error; } await this.assertDirectoryIdentity(this.journalDirectory, identity); - const ids = new Set(); for (const name of names) { const match = JOURNAL_FILE_PATTERN.exec(name); if (match?.[1]) ids.add(match[1]); @@ -301,6 +365,10 @@ export class StandaloneDeletionJournal { currentRoot: ConversationRootIdentity, ): Promise { const sessionId = parseSessionId(rawSessionId); + const pending = this.pendingClears.get(sessionId); + if (pending) { + return this.validatePendingEntry(pending.entry, currentRoot); + } const identity = await this.inspectJournalDirectory(); if (!identity) return undefined; const prepared = await this.readPhase(sessionId, 'prepared', currentRoot); @@ -314,7 +382,7 @@ export class StandaloneDeletionJournal { } async writePrepared( - record: StandaloneDeletionRecordV1, + record: StandaloneDeletionRecordV2, currentRoot: ConversationRootIdentity, ): Promise { const validated = parseRecord( @@ -323,6 +391,9 @@ export class StandaloneDeletionJournal { 'prepared', currentRoot, ); + if (validated.version !== 2) { + throw new StandaloneDeletionJournalError('compromised'); + } if (await this.hasRecord(validated.sessionId)) { throw new StandaloneDeletionJournalError('conflict'); } @@ -330,11 +401,14 @@ export class StandaloneDeletionJournal { } async writeStaged( - record: StandaloneDeletionRecordV1, + record: StandaloneDeletionRecordV2, currentRoot: ConversationRootIdentity, ): Promise { const sessionId = parseSessionId(record.sessionId); const validated = parseRecord(record, sessionId, 'staged', currentRoot); + if (validated.version !== 2) { + throw new StandaloneDeletionJournalError('compromised'); + } const existing = await this.read(sessionId, currentRoot); if (!existing || existing.staged) { throw new StandaloneDeletionJournalError('conflict'); @@ -350,14 +424,61 @@ export class StandaloneDeletionJournal { currentRoot: ConversationRootIdentity, ): Promise { const sessionId = parseSessionId(rawSessionId); + const pending = this.pendingClears.get(sessionId); + if (pending) { + this.validatePendingEntry(pending.entry, currentRoot); + await this.syncDurableDirectory(pending.directory); + this.pendingClears.delete(sessionId); + await pending.directory.handle.close().catch(() => undefined); + return; + } const existing = await this.read(sessionId, currentRoot); if (!existing) return; const identity = await this.inspectJournalDirectory(); if (!identity) throw new StandaloneDeletionJournalError('compromised'); - await this.unlinkIfExists(this.recordPath(sessionId, 'staged')); - await this.unlinkIfExists(this.recordPath(sessionId, 'prepared')); - await this.assertDirectoryIdentity(this.journalDirectory, identity); - await this.syncDirectory(); + const directory = await this.openDurableDirectory( + this.journalDirectory, + identity, + ); + let unlinksComplete = false; + let retained = false; + try { + await this.assertDirectoryIdentity(this.journalDirectory, identity); + await this.unlinkIfExists(this.recordPath(sessionId, 'staged')); + await this.unlinkIfExists(this.recordPath(sessionId, 'prepared')); + unlinksComplete = true; + await this.syncDurableDirectory(directory); + } catch (error) { + if (unlinksComplete) { + this.pendingClears.set(sessionId, { entry: existing, directory }); + retained = true; + } + throw error; + } finally { + if (!retained) { + await directory.handle.close().catch(() => undefined); + } + } + } + + private validatePendingEntry( + entry: StandaloneDeletionJournalEntry, + currentRoot: ConversationRootIdentity, + ): StandaloneDeletionJournalEntry { + const sessionId = entry.prepared.sessionId; + const prepared = parseRecord( + entry.prepared, + sessionId, + 'prepared', + currentRoot, + ); + const staged = entry.staged + ? parseRecord(entry.staged, sessionId, 'staged', currentRoot) + : undefined; + if (staged && !sameImmutableRecord(prepared, staged)) { + throw new StandaloneDeletionJournalError('compromised'); + } + return { prepared, ...(staged ? { staged } : {}) }; } private recordPath(sessionId: string, phase: 'prepared' | 'staged'): string { @@ -371,7 +492,7 @@ export class StandaloneDeletionJournal { sessionId: string, phase: 'prepared' | 'staged', currentRoot: ConversationRootIdentity, - ): Promise { + ): Promise { const filePath = this.recordPath(sessionId, phase); let pathStat: Awaited>; try { @@ -417,7 +538,7 @@ export class StandaloneDeletionJournal { } } - private async writePhase(record: StandaloneDeletionRecordV1): Promise { + private async writePhase(record: StandaloneDeletionRecordV2): Promise { const serialized = `${JSON.stringify(record)}\n`; if (Buffer.byteLength(serialized) > MAX_RECORD_BYTES) { throw new StandaloneDeletionJournalError('compromised'); @@ -429,8 +550,13 @@ export class StandaloneDeletionJournal { this.journalDirectory, `.${path.basename(target)}.${randomUUID()}.tmp`, ); + const directory = await this.openDurableDirectory( + this.journalDirectory, + identity, + ); let handle: fs.FileHandle | undefined; try { + await this.assertDirectoryIdentity(this.journalDirectory, identity); handle = await fs.open( temporary, fsConstants.O_CREAT | @@ -447,8 +573,7 @@ export class StandaloneDeletionJournal { await this.assertPathAbsent(target); await fs.rename(temporary, target); if (process.platform !== 'win32') await fs.chmod(target, 0o600); - await this.assertDirectoryIdentity(this.journalDirectory, identity); - await this.syncDirectory(); + await this.syncDurableDirectory(directory); } catch (error) { await handle?.close().catch(() => undefined); await fs.unlink(temporary).catch(() => undefined); @@ -456,22 +581,32 @@ export class StandaloneDeletionJournal { throw new StandaloneDeletionJournalError('conflict'); } throw error; + } finally { + await directory.handle.close().catch(() => undefined); } } private async ensureJournalDirectory(): Promise { const owner = await this.inspectPrivateDirectory(this.ownerDirectory); - let created = false; + const ownerHandle = await this.openDurableDirectory( + this.ownerDirectory, + owner, + ); try { - await fs.mkdir(this.journalDirectory, { mode: 0o700 }); - created = true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; - } - if (created && process.platform !== 'win32') { - await fs.chmod(this.journalDirectory, 0o700); + let created = false; + try { + await fs.mkdir(this.journalDirectory, { mode: 0o700 }); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + if (created && process.platform !== 'win32') { + await fs.chmod(this.journalDirectory, 0o700); + } + await this.syncDurableDirectory(ownerHandle); + } finally { + await ownerHandle.handle.close().catch(() => undefined); } - await this.assertDirectoryIdentity(this.ownerDirectory, owner); return this.inspectPrivateDirectory(this.journalDirectory); } @@ -561,11 +696,57 @@ export class StandaloneDeletionJournal { } } - private async syncDirectory(): Promise { + private async openDurableDirectory( + directory: string, + expected: DirectoryIdentity, + ): Promise { let handle: fs.FileHandle | undefined; try { - handle = await fs.open(this.journalDirectory, fsConstants.O_RDONLY); - await handle.sync(); + handle = await fs.open( + directory, + fsConstants.O_RDONLY | + (process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0)), + ); + const opened = await handle.stat(); + const openedIdentity = { + device: opened.dev, + inode: opened.ino, + inodeVerifiable: hasVerifiableInode(opened.ino), + }; + if ( + !opened.isDirectory() || + !sameDirectoryIdentity(openedIdentity, expected) + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + await this.assertDirectoryIdentity(directory, expected); + return { path: directory, identity: expected, handle }; + } catch (error) { + await handle?.close().catch(() => undefined); + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new StandaloneDeletionJournalError('compromised'); + } + throw error; + } + } + + private async syncDurableDirectory( + directory: DurableDirectory, + ): Promise { + const opened = await directory.handle.stat(); + const openedIdentity = { + device: opened.dev, + inode: opened.ino, + inodeVerifiable: hasVerifiableInode(opened.ino), + }; + if ( + !opened.isDirectory() || + !sameDirectoryIdentity(openedIdentity, directory.identity) + ) { + throw new StandaloneDeletionJournalError('compromised'); + } + try { + await directory.handle.sync(); } catch (error) { if ( process.platform !== 'win32' || @@ -575,8 +756,7 @@ export class StandaloneDeletionJournal { ) { throw error; } - } finally { - await handle?.close().catch(() => undefined); } + await this.assertDirectoryIdentity(directory.path, directory.identity); } } diff --git a/packages/cli/src/serve/conversations/standalone-session-service.test.ts b/packages/cli/src/serve/conversations/standalone-session-service.test.ts index 99cd68ecf68..9573bbbb48b 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.test.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.test.ts @@ -16,6 +16,8 @@ import { ApprovalMode, SessionIdCaseConflictError, SessionService, + SessionStorageEntryError, + SessionTranscriptDurabilityError, writeSessionPrs, } from '@qwen-code/qwen-code-core'; import { promises as fs } from 'node:fs'; @@ -31,6 +33,7 @@ import { import type { StandaloneDeletionJournal, StandaloneDeletionJournalEntry, + StandaloneDeletionRecordV2, } from './standalone-deletion-journal.js'; const { listWorkspaceSessionsForResponse } = vi.hoisted(() => ({ @@ -105,6 +108,7 @@ interface Harness { stageStandaloneDirectory: ReturnType; restoreStagedStandaloneDirectory: ReturnType; removeStagedStandaloneDirectory: ReturnType; + confirmStandaloneRootDurability: ReturnType; } function createHarness(): Harness { @@ -209,6 +213,7 @@ function createHarness(): Harness { })); const restoreStagedStandaloneDirectory = vi.fn(async () => identity); const removeStagedStandaloneDirectory = vi.fn(async () => undefined); + const confirmStandaloneRootDurability = vi.fn(async () => undefined); const lifecycle = new SessionArchiveCoordinator(); const options: StandaloneSessionServiceOptions = { ensureRuntime, @@ -234,6 +239,7 @@ function createHarness(): Harness { stageStandaloneDirectory, restoreStagedStandaloneDirectory, removeStagedStandaloneDirectory, + confirmStandaloneRootDurability, }, deletionJournal: deletionJournal as unknown as StandaloneDeletionJournal, lifecycle, @@ -263,6 +269,7 @@ function createHarness(): Harness { stageStandaloneDirectory, restoreStagedStandaloneDirectory, removeStagedStandaloneDirectory, + confirmStandaloneRootDurability, }; } @@ -324,10 +331,14 @@ function mockArchivedStandalone(storageSessionId = sessionId): void { function mockWriterLease(): { assertOwnedAndUnchanged: ReturnType; release: ReturnType; + isReleased: boolean; + isReleaseDurabilityPending: boolean; } { const lease = { assertOwnedAndUnchanged: vi.fn(async () => undefined), release: vi.fn(async () => undefined), + isReleased: false, + isReleaseDurabilityPending: false, }; vi.spyOn( SessionService.prototype, @@ -337,18 +348,36 @@ function mockWriterLease(): { SessionService.prototype, 'acquireSessionMaintenanceLease', ).mockResolvedValue(lease as never); + vi.spyOn( + SessionService.prototype, + 'confirmSessionTranscriptDeletionForLifecycle', + ).mockResolvedValue(); + vi.spyOn( + SessionService.prototype, + 'getSessionTranscriptParentIdentityForLifecycle', + ).mockResolvedValue({ device: 11, inode: 12, inodeVerifiable: true }); + vi.spyOn( + SessionService.prototype, + 'getSessionTranscriptLocationForLifecycle', + ).mockResolvedValue(undefined); return lease; } -function deletionEntry( - phase: 'prepared' | 'staged' = 'staged', -): StandaloneDeletionJournalEntry { +function deletionEntry(phase: 'prepared' | 'staged' = 'staged'): { + prepared: StandaloneDeletionRecordV2; + staged?: StandaloneDeletionRecordV2; +} { const prepared = { - version: 1 as const, + version: 2 as const, phase: 'prepared' as const, sessionId, storageSessionId: sessionId, transcriptLocation: 'active' as const, + transcriptParent: { + device: 11, + inode: 12, + inodeVerifiable: true, + }, root: { canonicalPath: root.canonicalRoot, device: root.device, @@ -369,6 +398,30 @@ function deletionEntry( : { prepared, staged: { ...prepared, phase: 'staged' } }; } +function legacyDeletionEntry(): StandaloneDeletionJournalEntry { + const current = deletionEntry().prepared; + return { + prepared: { + version: 1, + phase: 'prepared', + sessionId: current.sessionId, + storageSessionId: current.storageSessionId, + transcriptLocation: current.transcriptLocation, + root: current.root, + directory: current.directory, + }, + staged: { + version: 1, + phase: 'staged', + sessionId: current.sessionId, + storageSessionId: current.storageSessionId, + transcriptLocation: current.transcriptLocation, + root: current.root, + directory: current.directory, + }, + }; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -381,7 +434,11 @@ describe('StandaloneSessionService', () => { const created = await harness.service.create({ sessionId }); expect(created).toMatchObject({ - session: { sessionId, sourceType: 'standalone' }, + session: { + sessionId, + sourceType: 'standalone', + currentCwd: identity.canonicalPath, + }, projectlessOutputDirectory: identity.canonicalPath, workingDirectory: { state: 'ready' }, }); @@ -390,6 +447,52 @@ describe('StandaloneSessionService', () => { expect(harness.reservation.release).toHaveBeenCalledOnce(); }); + it('detaches a create response client from its origin runtime after rollover', async () => { + mockDurableStandalone(); + const harness = createHarness(); + harness.bridge.spawnStandaloneSession.mockResolvedValueOnce({ + sessionId, + clientId: 'response-client', + workspaceCwd: root.canonicalRoot, + attached: false, + sourceType: 'standalone', + sourcePersisted: true, + }); + + const created = await harness.service.create({ sessionId }); + await harness.quarantineRuntime(harness.runtime); + await harness.service.cleanupDisconnectedCreate(created); + + expect(harness.bridge.detachClient).toHaveBeenCalledWith( + sessionId, + 'response-client', + ); + }); + + it('retains the create origin when response cleanup must be retried', async () => { + mockDurableStandalone(); + const harness = createHarness(); + harness.bridge.spawnStandaloneSession.mockResolvedValueOnce({ + sessionId, + clientId: 'response-client', + workspaceCwd: root.canonicalRoot, + attached: false, + sourceType: 'standalone', + sourcePersisted: true, + }); + harness.bridge.detachClient.mockRejectedValueOnce(new Error('transient')); + + const created = await harness.service.create({ sessionId }); + await expect( + harness.service.cleanupDisconnectedCreate(created), + ).rejects.toThrow('transient'); + await expect( + harness.service.cleanupDisconnectedCreate(created), + ).resolves.toBeUndefined(); + + expect(harness.bridge.detachClient).toHaveBeenCalledTimes(2); + }); + it('fails read-only exact lookup closed while deletion is journaled', async () => { mockActiveStandalone(); vi.spyOn(SessionService.prototype, 'getSessionListItem').mockResolvedValue({ @@ -434,6 +537,37 @@ describe('StandaloneSessionService', () => { ); }); + it('renames a cold standalone through the fenced lifecycle primitive', async () => { + mockActiveStandalone(); + const harness = createHarness(); + const lease = mockWriterLease(); + const rename = vi + .spyOn(SessionService.prototype, 'renameSessionForLifecycle') + .mockImplementation(async (_id, _title, _source, _location, options) => { + await options?.assertStorageUnchanged?.(); + options?.assertCanMutate?.(); + return true; + }); + const ordinaryRename = vi.spyOn(SessionService.prototype, 'renameSession'); + + await expect( + harness.service.rename(sessionId, 'Named session'), + ).resolves.toEqual({ sessionId, displayName: 'Named session' }); + + expect(rename).toHaveBeenCalledWith( + sessionId, + 'Named session', + 'manual', + 'active', + expect.objectContaining({ + assertStorageUnchanged: expect.any(Function), + assertCanMutate: expect.any(Function), + }), + ); + expect(lease.assertOwnedAndUnchanged).toHaveBeenCalledOnce(); + expect(ordinaryRename).not.toHaveBeenCalled(); + }); + it.each(['', ' ', 'bad\nname', 'x'.repeat(257)])( 'rejects an invalid standalone display name before runtime work', async (displayName) => { @@ -518,7 +652,7 @@ describe('StandaloneSessionService', () => { ).mockResolvedValue(true); vi.spyOn( SessionService.prototype, - 'cleanupRemovedSessionState', + 'cleanupRemovedSessionStateForLifecycle', ).mockResolvedValue(); await expect(harness.service.delete([sessionId])).resolves.toEqual({ @@ -575,7 +709,7 @@ describe('StandaloneSessionService', () => { ).mockResolvedValue(true); vi.spyOn( SessionService.prototype, - 'cleanupRemovedSessionState', + 'cleanupRemovedSessionStateForLifecycle', ).mockRejectedValue(new Error('sidecar I/O failed')); await expect(harness.service.delete([sessionId])).resolves.toEqual({ @@ -599,7 +733,7 @@ describe('StandaloneSessionService', () => { ).mockResolvedValue(true); vi.spyOn( SessionService.prototype, - 'cleanupRemovedSessionState', + 'cleanupRemovedSessionStateForLifecycle', ).mockResolvedValue(); harness.bridge.deleteSessionAttachments.mockRejectedValueOnce( new Error('attachment I/O failed'), @@ -615,6 +749,58 @@ describe('StandaloneSessionService', () => { expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); }); + it('retries a durability-pending lease before exact deletion recovery', async () => { + mockActiveStandalone(); + const harness = createHarness(); + const lease = mockWriterLease(); + lease.release + .mockImplementationOnce(async () => { + lease.isReleased = true; + lease.isReleaseDurabilityPending = true; + throw new Error('release I/O failed'); + }) + .mockRejectedValueOnce(new Error('release I/O failed')) + .mockImplementation(async () => { + lease.isReleaseDurabilityPending = false; + }); + vi.spyOn( + SessionService.prototype, + 'removeSessionTranscriptForLifecycle', + ).mockResolvedValue(true); + vi.spyOn( + SessionService.prototype, + 'cleanupRemovedSessionStateForLifecycle', + ).mockResolvedValue(); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [sessionId], + }); + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + + vi.mocked( + SessionService.prototype.findSessionIdIgnoringCase, + ).mockResolvedValue(undefined); + harness.deletionJournal.read.mockResolvedValueOnce( + deletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'absent', + }); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [], + }); + + expect(lease.release).toHaveBeenCalledTimes(4); + expect(harness.deletionJournal.clear).toHaveBeenCalledOnce(); + }); + it('rolls back a staged directory when recovery finds the transcript intact', async () => { mockActiveStandalone(); const harness = createHarness(); @@ -648,7 +834,7 @@ describe('StandaloneSessionService', () => { 'findSessionIdIgnoringCase', ).mockResolvedValue(undefined); const cleanup = vi - .spyOn(SessionService.prototype, 'cleanupRemovedSessionState') + .spyOn(SessionService.prototype, 'cleanupRemovedSessionStateForLifecycle') .mockResolvedValue(); const harness = createHarness(); mockWriterLease(); @@ -686,6 +872,300 @@ describe('StandaloneSessionService', () => { expect(harness.deletionJournal.clear).toHaveBeenCalledWith(sessionId, root); }); + it('deletes an unreadable transcript before completing journal recovery', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + const harness = createHarness(); + const lease = mockWriterLease(); + vi.spyOn( + SessionService.prototype, + 'getSessionTranscriptLocationForLifecycle', + ) + .mockResolvedValueOnce('active') + .mockResolvedValue(undefined); + const remove = vi + .spyOn(SessionService.prototype, 'removeSessionTranscriptForLifecycle') + .mockImplementation(async (_id, _location, _parent, options) => { + await options?.assertStorageUnchanged?.(); + options?.assertCanMutate?.(); + return true; + }); + harness.deletionJournal.read.mockResolvedValueOnce( + deletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'staged', + identity: { + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + }, + }); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [], + }); + + expect(remove).toHaveBeenCalledWith( + sessionId, + 'active', + deletionEntry().prepared.transcriptParent, + expect.objectContaining({ + assertStorageUnchanged: expect.any(Function), + assertCanMutate: expect.any(Function), + }), + ); + expect( + SessionService.prototype.confirmSessionTranscriptDeletionForLifecycle, + ).toHaveBeenCalledWith('active', deletionEntry().prepared.transcriptParent); + expect(harness.deletionJournal.clear).toHaveBeenCalledOnce(); + expect(lease.assertOwnedAndUnchanged).toHaveBeenCalledTimes(2); + }); + + it('rolls back a legacy V1 journal while the transcript still exists', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + const harness = createHarness(); + mockWriterLease(); + const remove = vi + .spyOn(SessionService.prototype, 'removeSessionTranscriptForLifecycle') + .mockResolvedValue(true); + vi.mocked( + SessionService.prototype.getSessionTranscriptLocationForLifecycle, + ).mockResolvedValue('active'); + harness.deletionJournal.read.mockResolvedValueOnce( + legacyDeletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'staged', + identity: { + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + }, + }); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [], + notFound: [sessionId], + errors: [], + fileCleanupPending: [], + }); + + expect(harness.restoreStagedStandaloneDirectory).toHaveBeenCalledOnce(); + expect(harness.deletionJournal.clear).toHaveBeenCalledOnce(); + expect(remove).not.toHaveBeenCalled(); + }); + + it('keeps a legacy V1 journal when transcript deletion is already ambiguous', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + const harness = createHarness(); + mockWriterLease(); + harness.deletionJournal.read.mockResolvedValueOnce( + legacyDeletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'staged', + identity: { + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + }, + }); + + await expect(harness.service.delete([sessionId])).resolves.toMatchObject({ + removed: [], + errors: [ + { + sessionId, + code: 'transcript_deletion_outcome_unknown', + }, + ], + }); + + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + }); + + it('fails recovery closed for a foreign physical transcript', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + const harness = createHarness(); + mockWriterLease(); + vi.spyOn( + SessionService.prototype, + 'getSessionTranscriptLocationForLifecycle', + ).mockRejectedValue( + new SessionStorageEntryError(sessionId, 'foreign_project'), + ); + harness.deletionJournal.read.mockResolvedValueOnce( + deletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'staged', + identity: { + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + }, + }); + + await expect(harness.service.delete([sessionId])).resolves.toMatchObject({ + removed: [], + errors: [{ code: 'deletion_recovery_compromised', sessionId }], + }); + + expect(harness.removeStagedStandaloneDirectory).not.toHaveBeenCalled(); + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + }); + + it('reports committed recovery cleanup failures as file cleanup pending', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + vi.spyOn( + SessionService.prototype, + 'cleanupRemovedSessionStateForLifecycle', + ).mockRejectedValue(new Error('sidecar I/O failed')); + const harness = createHarness(); + mockWriterLease(); + harness.deletionJournal.read.mockResolvedValueOnce( + deletionEntry() as never, + ); + harness.inspectStandaloneDeletionPaths.mockResolvedValueOnce({ + status: 'staged', + identity: { + ...identity, + name: `${identity.name}.deleting`, + canonicalPath: `${identity.canonicalPath}.deleting`, + }, + }); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [sessionId], + }); + + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + }); + + it('reconfirms a restored normal directory before clearing recovery evidence', async () => { + mockActiveStandalone(); + const harness = createHarness(); + mockWriterLease(); + harness.deletionJournal.read + .mockResolvedValueOnce(deletionEntry() as never) + .mockResolvedValueOnce(deletionEntry() as never); + harness.inspectStandaloneDeletionPaths.mockResolvedValue({ + status: 'normal', + identity, + }); + harness.confirmStandaloneRootDurability + .mockRejectedValueOnce(new Error('root fsync failed')) + .mockResolvedValueOnce(undefined); + + await expect(harness.service.create({ sessionId })).rejects.toMatchObject({ + code: 'working_directory_recovery_failed', + }); + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + + await expect(harness.service.create({ sessionId })).rejects.toMatchObject({ + code: 'standalone_session_conflict', + }); + expect(harness.deletionJournal.clear).toHaveBeenCalledWith(sessionId, root); + }); + + it('reconfirms an absent staged directory before completing cleanup retry', async () => { + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockResolvedValue(undefined); + vi.spyOn( + SessionService.prototype, + 'cleanupRemovedSessionStateForLifecycle', + ).mockResolvedValue(); + const harness = createHarness(); + mockWriterLease(); + harness.deletionJournal.read + .mockResolvedValueOnce(deletionEntry() as never) + .mockResolvedValueOnce(deletionEntry() as never); + harness.inspectStandaloneDeletionPaths.mockResolvedValue({ + status: 'absent', + }); + harness.confirmStandaloneRootDurability + .mockRejectedValueOnce(new Error('root fsync failed')) + .mockResolvedValueOnce(undefined); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [sessionId], + }); + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + + await expect(harness.service.delete([sessionId])).resolves.toEqual({ + removed: [sessionId], + notFound: [], + errors: [], + fileCleanupPending: [], + }); + expect(harness.deletionJournal.clear).toHaveBeenCalledWith(sessionId, root); + }); + + it('keeps deletion outcome unknown when transcript directory sync fails after unlink', async () => { + const find = vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ); + find + .mockResolvedValueOnce(sessionId) + .mockResolvedValueOnce(sessionId) + .mockResolvedValue(undefined); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'active', + ); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadataIfReadable', + ).mockResolvedValue({ sourceType: 'standalone' }); + vi.spyOn( + SessionService.prototype, + 'removeSessionTranscriptForLifecycle', + ).mockRejectedValue( + new SessionTranscriptDurabilityError({ cause: new Error('EIO') }), + ); + const harness = createHarness(); + mockWriterLease(); + + await expect(harness.service.delete([sessionId])).resolves.toMatchObject({ + removed: [], + errors: [ + { + sessionId, + code: 'transcript_deletion_outcome_unknown', + }, + ], + fileCleanupPending: [], + }); + + expect(harness.deletionJournal.clear).not.toHaveBeenCalled(); + }); + it('fails recovery closed when another runtime owns the journaled id', async () => { const harness = createHarness(); harness.deletionJournal.read.mockResolvedValueOnce( @@ -730,6 +1210,9 @@ describe('StandaloneSessionService', () => { mockActiveStandalone(); const harness = createHarness(); mockWriterLease(); + vi.mocked( + SessionService.prototype.getSessionTranscriptLocationForLifecycle, + ).mockResolvedValue('active'); vi.spyOn( SessionService.prototype, 'removeSessionTranscriptForLifecycle', @@ -1282,6 +1765,45 @@ describe('StandaloneSessionService', () => { await expect(harness.service.list()).resolves.toEqual({ sessions: [] }); }); + it('aborts while a catalog row is waiting for durable verification', async () => { + listWorkspaceSessionsForResponse.mockResolvedValueOnce({ + sessions: [ + { + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + clientCount: 0, + hasActivePrompt: false, + }, + ], + }); + const harness = createHarness(); + let releaseVerification!: () => void; + const verificationStarted = new Promise((resolve) => { + harness.deletionJournal.hasRecord.mockImplementationOnce( + () => + new Promise((release) => { + releaseVerification = () => release(false); + resolve(); + }), + ); + }); + const inspect = vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ); + const controller = new AbortController(); + + const listing = harness.service.list({ signal: controller.signal }); + await verificationStarted; + controller.abort(new DOMException('Request aborted', 'AbortError')); + releaseVerification(); + + await expect(listing).rejects.toMatchObject({ name: 'AbortError' }); + expect(inspect).not.toHaveBeenCalled(); + }); + it('maps case-only persisted ambiguity to a standalone conflict', async () => { vi.spyOn( SessionService.prototype, @@ -1407,6 +1929,20 @@ describe('StandaloneSessionService', () => { expect(harness.bridge.sendPrompt).not.toHaveBeenCalled(); }); + it('retries transient response cleanup during directory repair', async () => { + mockActiveStandalone(); + const harness = createHarness(); + harness.bridge.detachClient.mockRejectedValueOnce(new Error('transient')); + + await expect(harness.service.repairDirectory(sessionId)).resolves.toEqual({ + sessionId, + projectlessOutputDirectory: identity.canonicalPath, + workingDirectory: { state: 'ready' }, + }); + + expect(harness.bridge.detachClient).toHaveBeenCalledTimes(2); + }); + it('preserves the data-loss warning when repair recreates a directory', async () => { mockActiveStandalone(); const harness = createHarness(); @@ -1558,6 +2094,73 @@ describe('StandaloneSessionService', () => { expect(harness.restoreReservation.release).toHaveBeenCalledOnce(); }); + it('detaches the response client from a captured cold restore', async () => { + mockActiveStandalone(); + const harness = createHarness(); + + const restored = await harness.service.load(sessionId, { + clientId: 'response-client', + }); + await harness.quarantineRuntime(harness.runtime); + await harness.service.cleanupDisconnectedRestore(restored); + + expect(harness.bridge.detachClient).toHaveBeenCalledWith( + sessionId, + 'response-client', + ); + expect(harness.bridge.killSession).not.toHaveBeenCalled(); + }); + + it('retains the restore origin when response cleanup must be retried', async () => { + mockActiveStandalone(); + const harness = createHarness(); + harness.bridge.detachClient.mockRejectedValueOnce(new Error('transient')); + + const restored = await harness.service.load(sessionId, { + clientId: 'response-client', + }); + await expect( + harness.service.cleanupDisconnectedRestore(restored), + ).rejects.toThrow('transient'); + await expect( + harness.service.cleanupDisconnectedRestore(restored), + ).resolves.toBeUndefined(); + + expect(harness.bridge.detachClient).toHaveBeenCalledTimes(2); + }); + + it('detaches only the response client from a captured warm restore', async () => { + mockDurableStandalone(); + const harness = createHarness(); + await harness.service.createWithInitialPrompt({ sessionId }, 'first task'); + harness.bridge.getSessionSummary.mockReturnValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + clientCount: 1, + hasActivePrompt: false, + }); + harness.bridge.restoreStandaloneSession.mockResolvedValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + currentCwd: identity.canonicalPath, + attached: true, + clientId: 'response-client', + sourceType: 'standalone', + state: {}, + }); + + const restored = await harness.service.load(sessionId); + await harness.service.cleanupDisconnectedRestore(restored); + + expect(harness.bridge.detachClient).toHaveBeenCalledWith( + sessionId, + 'response-client', + ); + expect(harness.bridge.killSession).not.toHaveBeenCalled(); + }); + it('normalizes only legacy standalone source through the compatibility restore', async () => { mockActiveLegacyStandalone(); const harness = createHarness(); @@ -1986,7 +2589,10 @@ describe('StandaloneSessionService', () => { await expect( harness.service.createWithInitialPrompt({ sessionId }, 'do the task'), - ).rejects.toMatchObject({ code: 'standalone_creation_rolled_back' }); + ).rejects.toMatchObject({ + code: 'standalone_creation_rolled_back', + retryable: true, + }); expect(harness.bridge.killSession).not.toHaveBeenCalled(); expect(harness.quarantineRuntime).not.toHaveBeenCalled(); @@ -2029,7 +2635,10 @@ describe('StandaloneSessionService', () => { await expect( harness.service.createWithInitialPrompt({ sessionId }, 'do the task'), - ).rejects.toMatchObject({ code: 'standalone_creation_rolled_back' }); + ).rejects.toMatchObject({ + code: 'standalone_creation_rolled_back', + retryable: true, + }); expect(harness.bridge.killSession).toHaveBeenCalledWith(sessionId, { requireZeroAttaches: true, diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index eea9e0779ad..6111fd5e83c 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -23,6 +23,8 @@ import { STANDALONE_SESSION_SOURCE_TYPE } from '@qwen-code/acp-bridge/sessionSou import { readSessionPrs, SessionIdCaseConflictError, + SessionStorageEntryError, + SessionTranscriptDurabilityError, SessionTranscriptChangedError, SessionWriterUnavailableError, type ApprovalMode, @@ -62,7 +64,7 @@ import type { ConversationWorkspace } from './conversation-workspace.js'; import { StandaloneDeletionJournalError, type StandaloneDeletionJournal, - type StandaloneDeletionRecordV1, + type StandaloneDeletionRecordV2, } from './standalone-deletion-journal.js'; export type StandaloneSessionServiceErrorCode = @@ -233,6 +235,7 @@ export interface StandaloneSessionServiceOptions { | 'stageStandaloneDirectory' | 'restoreStagedStandaloneDirectory' | 'removeStagedStandaloneDirectory' + | 'confirmStandaloneRootDurability' >; deletionJournal: StandaloneDeletionJournal; lifecycle: SessionArchiveCoordinator; @@ -454,6 +457,18 @@ function isSameDirectoryIdentity( } export class StandaloneSessionService { + private readonly responseCreateRuntimes = new WeakMap< + CreatedStandaloneSession, + WorkspaceRuntime + >(); + private readonly responseRestoreRuntimes = new WeakMap< + RestoredStandaloneSession, + WorkspaceRuntime + >(); + private readonly pendingLifecycleLeaseReleases = new Map< + string, + SessionWriterLease + >(); private readonly creating = new Map(); private readonly directoryStates = new Map(); private readonly reconciliations = new WeakMap< @@ -585,18 +600,22 @@ export class StandaloneSessionService { } const sessions: StandaloneSessionSummary[] = []; for (const summary of byCanonicalId.values()) { + options.signal?.throwIfAborted(); const stable = await this.options.lifecycle.runSharedMany( [summary.sessionId], async () => { + options.signal?.throwIfAborted(); if ( await this.options.deletionJournal.hasRecord(summary.sessionId) ) { return false; } + options.signal?.throwIfAborted(); const durable = await this.inspectStoredStandalone( runtime, summary.sessionId, ); + options.signal?.throwIfAborted(); return ( durable.kind === 'standalone' && durable.source.metadata.parentSessionId === undefined && @@ -605,6 +624,7 @@ export class StandaloneSessionService { ); }, ); + options.signal?.throwIfAborted(); if (stable) sessions.push(summary); } return { @@ -624,20 +644,17 @@ export class StandaloneSessionService { const clientId = randomUUID(); const restored = await this.restore('resume', rawSessionId, { clientId }); try { - const runtime = await this.options.ensureRuntime(); - await this.options.runRuntimeActivity(runtime, async () => { - this.options.assertRuntimeCurrent(runtime); - await runtime.bridge.detachClient( + await this.cleanupDisconnectedRestore(restored); + } catch { + try { + await this.cleanupDisconnectedRestore(restored); + } catch { + throw serviceError( + 'working_directory_recovery_failed', restored.sessionId, - restored.clientId, + true, ); - }); - } catch { - throw serviceError( - 'working_directory_recovery_failed', - restored.sessionId, - true, - ); + } } return { sessionId: restored.sessionId, @@ -687,19 +704,35 @@ export class StandaloneSessionService { durable.storageSessionId, ); try { - await lease.assertOwnedAndUnchanged(); - const renamed = await service.renameSession( + const renamed = await service.renameSessionForLifecycle( durable.storageSessionId, displayName, 'manual', durable.location, + { + assertStorageUnchanged: () => + lease.assertOwnedAndUnchanged(), + assertCanMutate: () => + this.options.assertRuntimeCurrent(runtime), + }, ); - await lease.assertOwnedAndUnchanged(); if (!renamed) { throw serviceError('standalone_session_not_found', sessionId); } - } finally { - await lease.release(); + } catch (error) { + await this.releaseLifecycleLease( + lease, + durable.storageSessionId, + ); + throw error; + } + if ( + !(await this.releaseLifecycleLease( + lease, + durable.storageSessionId, + )) + ) { + throw new SessionWriterUnavailableError(); } }); runtime.bridge.markSessionCatalogChanged(); @@ -756,16 +789,35 @@ export class StandaloneSessionService { return this.deleteMany(sessionIds); } - async detachResponseClient( - rawSessionId: string, - clientId: string, + async cleanupDisconnectedCreate( + created: CreatedStandaloneSession, ): Promise { - const { sessionId } = parseRequiredSessionId(rawSessionId); - const runtime = await this.options.ensureRuntime(); - await this.options.runRuntimeActivity(runtime, async () => { - this.options.assertRuntimeCurrent(runtime); - await runtime.bridge.detachClient(sessionId, clientId); - }); + const runtime = this.responseCreateRuntimes.get(created); + if (!runtime || created.session.clientId === undefined) { + throw serviceError( + 'standalone_session_operation_failed', + created.session.sessionId, + ); + } + await runtime.bridge.detachClient( + created.session.sessionId, + created.session.clientId, + ); + this.responseCreateRuntimes.delete(created); + } + + async cleanupDisconnectedRestore( + restored: RestoredStandaloneSession, + ): Promise { + const runtime = this.responseRestoreRuntimes.get(restored); + if (!runtime) { + throw serviceError( + 'standalone_session_operation_failed', + restored.sessionId, + ); + } + await runtime.bridge.detachClient(restored.sessionId, restored.clientId); + this.responseRestoreRuntimes.delete(restored); } load( @@ -923,7 +975,8 @@ export class StandaloneSessionService { private async reconcileDeletionUnderExclusive( runtime: WorkspaceRuntime, sessionId: string, - ): Promise<'none' | 'rolled-back' | 'committed'> { + allowCommittedCleanupPending = false, + ): Promise<'none' | 'rolled-back' | 'committed' | 'cleanup-pending'> { this.options.assertRuntimeCurrent(runtime); const root = await this.options.workspace.assertExactRoot( runtime.workspaceCwd, @@ -972,12 +1025,76 @@ export class StandaloneSessionService { service, record.storageSessionId, ); + const mapRecoveryError = (error: unknown): never => { + if (error instanceof StandaloneSessionServiceError) throw error; + if (error instanceof SessionTranscriptDurabilityError) { + throw serviceError('transcript_deletion_outcome_unknown', sessionId); + } + if ( + error instanceof ConversationDirectoryIdentityError || + error instanceof SessionStorageEntryError || + error instanceof StandaloneDeletionJournalError + ) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + throw serviceError( + 'working_directory_recovery_failed', + sessionId, + true, + ); + }; + + let durable: StoredStandaloneState; try { - const durable = await this.inspectStoredStandalone(runtime, sessionId); + durable = await this.inspectStoredStandalone(runtime, sessionId); await lease.assertOwnedAndUnchanged(); if (durable.kind === 'conflict' || durable.kind === 'foreign') { throw serviceError('deletion_recovery_compromised', sessionId); } + if (record.version === 1 && durable.kind !== 'standalone') { + const physical = + await service.getSessionTranscriptLocationForLifecycle( + record.storageSessionId, + ); + if (physical === undefined) { + throw serviceError( + 'transcript_deletion_outcome_unknown', + sessionId, + ); + } + if (physical !== record.transcriptLocation) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + if (record.directory.kind === 'absent') { + if (paths.status !== 'absent') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + } else if (paths.status === 'staged') { + await this.options.workspace.restoreStagedStandaloneDirectory( + sessionId, + expected!, + ); + } else if (paths.status !== 'normal') { + throw serviceError('deletion_recovery_compromised', sessionId); + } + await this.options.workspace.confirmStandaloneRootDurability(root); + await lease.assertOwnedAndUnchanged(); + if ( + !(await this.releaseLifecycleLease(lease, record.storageSessionId)) + ) { + throw serviceError( + 'working_directory_recovery_failed', + sessionId, + true, + ); + } + try { + await this.options.deletionJournal.clear(sessionId, root); + } catch (error) { + return mapRecoveryError(error); + } + return 'rolled-back'; + } if (durable.kind === 'standalone') { if ( durable.storageSessionId !== record.storageSessionId || @@ -998,50 +1115,151 @@ export class StandaloneSessionService { } else if (paths.status !== 'normal') { throw serviceError('deletion_recovery_compromised', sessionId); } + await this.options.workspace.confirmStandaloneRootDurability(root); await lease.assertOwnedAndUnchanged(); + } else { + if (record.version !== 2) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + const physical = + await service.getSessionTranscriptLocationForLifecycle( + record.storageSessionId, + ); + if ( + physical === 'conflict' || + (physical !== undefined && physical !== record.transcriptLocation) + ) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + if (physical === record.transcriptLocation) { + const removed = await service.removeSessionTranscriptForLifecycle( + record.storageSessionId, + record.transcriptLocation, + record.transcriptParent, + { + assertStorageUnchanged: () => lease.assertOwnedAndUnchanged(), + assertCanMutate: () => + this.options.assertRuntimeCurrent(runtime), + }, + ); + if ( + !removed || + (await service.getSessionTranscriptLocationForLifecycle( + record.storageSessionId, + )) !== undefined + ) { + throw serviceError('deletion_recovery_compromised', sessionId); + } + } + await service.confirmSessionTranscriptDeletionForLifecycle( + record.transcriptLocation, + record.transcriptParent, + ); + } + } catch (error) { + await this.releaseLifecycleLease(lease, record.storageSessionId); + return mapRecoveryError(error); + } + + if (durable.kind === 'standalone') { + if ( + !(await this.releaseLifecycleLease(lease, record.storageSessionId)) + ) { + throw serviceError( + 'working_directory_recovery_failed', + sessionId, + true, + ); + } + try { await this.options.deletionJournal.clear(sessionId, root); - return 'rolled-back'; + } catch (error) { + return mapRecoveryError(error); } + return 'rolled-back'; + } - if (record.directory.kind === 'absent') { - if (paths.status !== 'absent') { + let cleanupPending = false; + if (record.directory.kind === 'absent') { + if (paths.status !== 'absent') { + await this.releaseLifecycleLease(lease, record.storageSessionId); + throw serviceError('deletion_recovery_compromised', sessionId); + } + try { + await this.options.workspace.confirmStandaloneRootDurability(root); + } catch (error) { + if (error instanceof ConversationDirectoryIdentityError) { + await this.releaseLifecycleLease(lease, record.storageSessionId); throw serviceError('deletion_recovery_compromised', sessionId); } - } else if (paths.status === 'staged') { + cleanupPending = true; + } + } else if (paths.status === 'staged') { + try { await this.options.workspace.removeStagedStandaloneDirectory( sessionId, expected!, ); - } else if (paths.status !== 'absent') { - throw serviceError('deletion_recovery_compromised', sessionId); + } catch (error) { + if (error instanceof ConversationDirectoryIdentityError) { + await this.releaseLifecycleLease(lease, record.storageSessionId); + throw serviceError('deletion_recovery_compromised', sessionId); + } + cleanupPending = true; } - await service.cleanupRemovedSessionState(record.storageSessionId, { - assertCanMutate: () => this.options.assertRuntimeCurrent(runtime), - }); + } else if (paths.status !== 'absent') { + await this.releaseLifecycleLease(lease, record.storageSessionId); + throw serviceError('deletion_recovery_compromised', sessionId); + } else { + try { + await this.options.workspace.confirmStandaloneRootDurability(root); + } catch (error) { + if (error instanceof ConversationDirectoryIdentityError) { + await this.releaseLifecycleLease(lease, record.storageSessionId); + throw serviceError('deletion_recovery_compromised', sessionId); + } + cleanupPending = true; + } + } + try { + await service.cleanupRemovedSessionStateForLifecycle( + record.storageSessionId, + { + assertCanMutate: () => this.options.assertRuntimeCurrent(runtime), + }, + ); + } catch { + cleanupPending = true; + } + try { await runtime.bridge.deleteSessionAttachments(sessionId, { assertCanCommit: () => this.options.assertRuntimeCurrent(runtime), }); - await this.options.deletionJournal.clear(sessionId, root); - this.directoryStates.delete(sessionId); - runtime.bridge.markSessionCatalogChanged(); - this.options.invalidateSessionListCache(runtime); - return 'committed'; - } catch (error) { - if (error instanceof StandaloneSessionServiceError) throw error; - if ( - error instanceof ConversationDirectoryIdentityError || - error instanceof StandaloneDeletionJournalError - ) { - throw serviceError('deletion_recovery_compromised', sessionId); + } catch { + cleanupPending = true; + } + if (!(await this.releaseLifecycleLease(lease, record.storageSessionId))) { + cleanupPending = true; + } + if (!cleanupPending) { + try { + await this.options.deletionJournal.clear(sessionId, root); + } catch { + cleanupPending = true; } + } + this.directoryStates.delete(sessionId); + runtime.bridge.markSessionCatalogChanged(); + this.options.invalidateSessionListCache(runtime); + if (cleanupPending) { + if (allowCommittedCleanupPending) return 'cleanup-pending'; throw serviceError( 'working_directory_recovery_failed', sessionId, true, ); - } finally { - await lease.release(); } + return 'committed'; }); } @@ -1084,6 +1302,16 @@ export class StandaloneSessionService { service: SessionService, storageSessionId: string, ): Promise { + const pendingKey = normalizeSessionIdForLookup(storageSessionId); + const pending = this.pendingLifecycleLeaseReleases.get(pendingKey); + if ( + pending && + !(await this.releaseLifecycleLease(pending, storageSessionId)) + ) { + throw new SessionWriterUnavailableError({ + message: 'A previous session writer lease is still being released.', + }); + } const leaseOptions = { processKind: 'daemon' as const, reclaimPolicy: 'never' as const, @@ -1108,6 +1336,30 @@ export class StandaloneSessionService { } } + private async releaseLifecycleLease( + lease: SessionWriterLease, + storageSessionId: string, + ): Promise { + const pendingKey = normalizeSessionIdForLookup(storageSessionId); + for (let attempt = 0; attempt < 2; attempt++) { + try { + await lease.release(); + if (this.pendingLifecycleLeaseReleases.get(pendingKey) === lease) { + this.pendingLifecycleLeaseReleases.delete(pendingKey); + } + return true; + } catch { + // The lease clears retryable terminal failures itself. + } + } + if (!lease.isReleased || lease.isReleaseDurabilityPending) { + this.pendingLifecycleLeaseReleases.set(pendingKey, lease); + } else if (this.pendingLifecycleLeaseReleases.get(pendingKey) === lease) { + this.pendingLifecycleLeaseReleases.delete(pendingKey); + } + return false; + } + private parseSessionIds(rawSessionIds: string[]): string[] { if (rawSessionIds.length === 0 || rawSessionIds.length > 100) { throw invalidRequest(); @@ -1257,31 +1509,49 @@ export class StandaloneSessionService { service, durable.storageSessionId, ); + let archiveOutcome: 'archived' | 'alreadyArchived'; try { const locked = await this.requireTopLevelStoredStandalone( runtime, sessionId, ); - if (locked.location === 'archived') return 'alreadyArchived'; - await lease.assertOwnedAndUnchanged(); - const archived = await service.archiveSessions( - [locked.storageSessionId], - { - knownLocation: 'active', - resolveConflicts: false, - assertStorageUnchanged: () => - lease.assertOwnedAndUnchanged(), - assertCanMutate: () => - this.options.assertRuntimeCurrent(runtime), - }, + if (locked.location === 'archived') { + archiveOutcome = 'alreadyArchived'; + } else { + await lease.assertOwnedAndUnchanged(); + const archived = await service.archiveSessions( + [locked.storageSessionId], + { + knownLocation: 'active', + resolveConflicts: false, + assertStorageUnchanged: () => + lease.assertOwnedAndUnchanged(), + assertCanMutate: () => + this.options.assertRuntimeCurrent(runtime), + }, + ); + if (archived.errors[0]) throw archived.errors[0].error; + archiveOutcome = + archived.archived.length > 0 + ? 'archived' + : 'alreadyArchived'; + } + } catch (error) { + await this.releaseLifecycleLease( + lease, + durable.storageSessionId, ); - if (archived.errors[0]) throw archived.errors[0].error; - return archived.archived.length > 0 - ? 'archived' - : 'alreadyArchived'; - } finally { - await lease.release(); + throw error; } + if ( + !(await this.releaseLifecycleLease( + lease, + durable.storageSessionId, + )) + ) { + throw new SessionWriterUnavailableError(); + } + return archiveOutcome; }); }, ); @@ -1339,31 +1609,49 @@ export class StandaloneSessionService { service, durable.storageSessionId, ); + let unarchiveOutcome: 'unarchived' | 'alreadyActive'; try { const locked = await this.requireTopLevelStoredStandalone( runtime, sessionId, ); - if (locked.location === 'active') return 'alreadyActive'; - await lease.assertOwnedAndUnchanged(); - const unarchived = await service.unarchiveSessions( - [locked.storageSessionId], - { - knownLocation: 'archived', - resolveConflicts: false, - assertStorageUnchanged: () => - lease.assertOwnedAndUnchanged(), - assertCanMutate: () => - this.options.assertRuntimeCurrent(runtime), - }, + if (locked.location === 'active') { + unarchiveOutcome = 'alreadyActive'; + } else { + await lease.assertOwnedAndUnchanged(); + const unarchived = await service.unarchiveSessions( + [locked.storageSessionId], + { + knownLocation: 'archived', + resolveConflicts: false, + assertStorageUnchanged: () => + lease.assertOwnedAndUnchanged(), + assertCanMutate: () => + this.options.assertRuntimeCurrent(runtime), + }, + ); + if (unarchived.errors[0]) throw unarchived.errors[0].error; + unarchiveOutcome = + unarchived.unarchived.length > 0 + ? 'unarchived' + : 'alreadyActive'; + } + } catch (error) { + await this.releaseLifecycleLease( + lease, + durable.storageSessionId, ); - if (unarchived.errors[0]) throw unarchived.errors[0].error; - return unarchived.unarchived.length > 0 - ? 'unarchived' - : 'alreadyActive'; - } finally { - await lease.release(); + throw error; } + if ( + !(await this.releaseLifecycleLease( + lease, + durable.storageSessionId, + )) + ) { + throw new SessionWriterUnavailableError(); + } + return unarchiveOutcome; }); }, ); @@ -1434,8 +1722,10 @@ export class StandaloneSessionService { const recovered = await this.reconcileDeletionUnderExclusive( runtime, sessionId, + true, ); if (recovered === 'committed') return 'removed'; + if (recovered === 'cleanup-pending') return 'cleanupPending'; let durable: Extract; try { durable = await this.requireTopLevelStoredStandalone(runtime, sessionId); @@ -1478,6 +1768,10 @@ export class StandaloneSessionService { throw serviceError('standalone_session_conflict', sessionId); } await lease.assertOwnedAndUnchanged(); + const transcriptParent = + await service.getSessionTranscriptParentIdentityForLifecycle( + locked.location, + ); const pinned = this.directoryStates.get(sessionId)?.pinned; const paths = await this.options.workspace.inspectStandaloneDeletionPaths( @@ -1487,7 +1781,7 @@ export class StandaloneSessionService { if (paths.status === 'compromised' || paths.status === 'staged') { throw serviceError('working_directory_compromised', sessionId); } - const directory: StandaloneDeletionRecordV1['directory'] = + const directory: StandaloneDeletionRecordV2['directory'] = paths.status === 'normal' ? { kind: 'present', @@ -1498,12 +1792,13 @@ export class StandaloneSessionService { inodeVerifiable: paths.identity.inode !== 0, } : { kind: 'absent' }; - const prepared: StandaloneDeletionRecordV1 = { - version: 1, + const prepared: StandaloneDeletionRecordV2 = { + version: 2, phase: 'prepared', sessionId, storageSessionId: locked.storageSessionId, transcriptLocation: locked.location, + transcriptParent, root: { canonicalPath: root.canonicalRoot, device: root.device, @@ -1539,19 +1834,32 @@ export class StandaloneSessionService { await service.removeSessionTranscriptForLifecycle( locked.storageSessionId, locked.location, + prepared.transcriptParent, { assertStorageUnchanged: () => lease.assertOwnedAndUnchanged(), assertCanMutate: () => this.options.assertRuntimeCurrent(runtime), }, ); - } catch { - const after = await this.inspectStoredStandalone(runtime, sessionId); - if ( - after.kind === 'standalone' && - after.storageSessionId === locked.storageSessionId && - after.location === locked.location - ) { + } catch (error) { + if (error instanceof SessionTranscriptDurabilityError) { + throw serviceError( + 'transcript_deletion_outcome_unknown', + sessionId, + ); + } + let physical: SessionArchiveState | 'conflict' | undefined; + try { + physical = await service.getSessionTranscriptLocationForLifecycle( + locked.storageSessionId, + ); + } catch { + throw serviceError( + 'transcript_deletion_outcome_unknown', + sessionId, + ); + } + if (physical === locked.location) { await this.rollbackPreparedDeletion( sessionId, root, @@ -1559,7 +1867,18 @@ export class StandaloneSessionService { ); throw serviceError('transcript_deletion_failed', sessionId, true); } - if (after.kind === 'missing') { + if (physical === undefined) { + try { + await service.confirmSessionTranscriptDeletionForLifecycle( + locked.location, + prepared.transcriptParent, + ); + } catch { + throw serviceError( + 'transcript_deletion_outcome_unknown', + sessionId, + ); + } transcriptCommitted = true; } else { throw serviceError( @@ -1569,14 +1888,31 @@ export class StandaloneSessionService { } } if (!transcriptCommitted) { - const after = await this.inspectStoredStandalone(runtime, sessionId); - if (after.kind === 'missing') { + let physical: SessionArchiveState | 'conflict' | undefined; + try { + physical = await service.getSessionTranscriptLocationForLifecycle( + locked.storageSessionId, + ); + } catch { + throw serviceError( + 'transcript_deletion_outcome_unknown', + sessionId, + ); + } + if (physical === undefined) { + try { + await service.confirmSessionTranscriptDeletionForLifecycle( + locked.location, + prepared.transcriptParent, + ); + } catch { + throw serviceError( + 'transcript_deletion_outcome_unknown', + sessionId, + ); + } transcriptCommitted = true; - } else if ( - after.kind === 'standalone' && - after.storageSessionId === locked.storageSessionId && - after.location === locked.location - ) { + } else if (physical === locked.location) { await this.rollbackPreparedDeletion( sessionId, root, @@ -1592,9 +1928,12 @@ export class StandaloneSessionService { } try { - await service.cleanupRemovedSessionState(locked.storageSessionId, { - assertCanMutate: () => this.options.assertRuntimeCurrent(runtime), - }); + await service.cleanupRemovedSessionStateForLifecycle( + locked.storageSessionId, + { + assertCanMutate: () => this.options.assertRuntimeCurrent(runtime), + }, + ); } catch { cleanupPending = true; } @@ -1615,9 +1954,9 @@ export class StandaloneSessionService { cleanupPending = true; } } - try { - await lease.release(); - } catch { + if ( + !(await this.releaseLifecycleLease(lease, durable.storageSessionId)) + ) { cleanupPending = true; } leaseReleased = true; @@ -1633,7 +1972,9 @@ export class StandaloneSessionService { this.options.invalidateSessionListCache(runtime); return cleanupPending ? 'cleanupPending' : 'removed'; } finally { - if (!leaseReleased) await lease.release(); + if (!leaseReleased) { + await this.releaseLifecycleLease(lease, durable.storageSessionId); + } } }); } @@ -1659,6 +2000,7 @@ export class StandaloneSessionService { throw serviceError('deletion_recovery_compromised', sessionId); } } + await this.options.workspace.confirmStandaloneRootDurability(root); await this.options.deletionJournal.clear(sessionId, root); } catch (error) { if (error instanceof StandaloneSessionServiceError) throw error; @@ -1923,7 +2265,7 @@ export class StandaloneSessionService { } } this.options.assertRuntimeCurrent(runtime); - return { + const response: RestoredStandaloneSession = { ...restored, sessionId, workspaceCwd: runtime.workspaceCwd, @@ -1942,6 +2284,8 @@ export class StandaloneSessionService { : {}), }, }; + this.responseRestoreRuntimes.set(response, runtime); + return response; }, ); }); @@ -2035,17 +2379,22 @@ export class StandaloneSessionService { }, ); this.options.assertRuntimeCurrent(runtime); - const create = (persistedParentSessionId?: string) => - this.options.lifecycle.runExclusiveAfterShared(sessionId, () => - this.createUnderExclusive( - runtime, - sessionId, - request, - prompt, - promptId, - persistedParentSessionId, - ), + const create = async (persistedParentSessionId?: string) => { + const created = await this.options.lifecycle.runExclusiveAfterShared( + sessionId, + () => + this.createUnderExclusive( + runtime, + sessionId, + request, + prompt, + promptId, + persistedParentSessionId, + ), ); + this.responseCreateRuntimes.set(created, runtime); + return created; + }; if (parentSessionId === undefined) return create(); return this.options.lifecycle.runSharedMany( [parentSessionId], @@ -2153,7 +2502,7 @@ export class StandaloneSessionService { } catch { this.beginTerminalQuarantine(runtime); } - throw serviceError('standalone_creation_rolled_back', sessionId); + throw serviceError('standalone_creation_rolled_back', sessionId, true); } this.beginTerminalQuarantine(runtime); } @@ -2174,7 +2523,7 @@ export class StandaloneSessionService { } if (session.sourcePersisted !== true) { await this.cleanRollbackBeforePersistence(runtime, sessionId); - throw serviceError('standalone_creation_rolled_back', sessionId); + throw serviceError('standalone_creation_rolled_back', sessionId, true); } try { @@ -2215,7 +2564,10 @@ export class StandaloneSessionService { // The durable session and admitted prompt are already committed. } return { - session, + session: { + ...session, + currentCwd: prepared.identity.canonicalPath, + }, projectlessOutputDirectory: prepared.identity.canonicalPath, workingDirectory: { state: 'ready' }, ...(initialPrompt ? { initialPrompt } : {}), diff --git a/packages/cli/src/serve/routes/standalone-sessions.test.ts b/packages/cli/src/serve/routes/standalone-sessions.test.ts index 18f42c148d3..d75ef8cb6b5 100644 --- a/packages/cli/src/serve/routes/standalone-sessions.test.ts +++ b/packages/cli/src/serve/routes/standalone-sessions.test.ts @@ -15,6 +15,7 @@ import { type StandaloneSessionService, } from '../conversations/standalone-session-service.js'; import { sendBridgeError } from '../server/error-response.js'; +import { InvalidCursorError } from '../server/session-list.js'; import { registerStandaloneSessionRoutes } from './standalone-sessions.js'; const sessionId = '11111111-1111-4111-8111-111111111111'; @@ -79,7 +80,8 @@ function createHarness() { errors: [], fileCleanupPending: [], })), - detachResponseClient: vi.fn(async () => undefined), + cleanupDisconnectedCreate: vi.fn(async () => undefined), + cleanupDisconnectedRestore: vi.fn(async () => undefined), }; const mutate = vi.fn(() => ((_req, _res, next) => next()) as RequestHandler); const app = express(); @@ -117,6 +119,9 @@ describe('standalone session routes', () => { it('detaches only the response client when creation outlives the request', async () => { const { app, service } = createHarness(); + service.cleanupDisconnectedCreate.mockRejectedValueOnce( + new Error('transient'), + ); let finishCreate!: () => void; service.create.mockImplementationOnce( () => @@ -165,11 +170,16 @@ describe('standalone session routes', () => { finishCreate(); await vi.waitFor(() => - expect(service.detachResponseClient).toHaveBeenCalledWith( - sessionId, - 'client-1', + expect(service.cleanupDisconnectedCreate).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + sessionId, + clientId: 'client-1', + }), + }), ), ); + expect(service.cleanupDisconnectedCreate).toHaveBeenCalledTimes(2); await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); @@ -231,6 +241,21 @@ describe('standalone session routes', () => { expect(service.list).toHaveBeenCalledOnce(); }); + it('returns invalid_cursor for an invalid standalone list cursor', async () => { + const { app, service } = createHarness(); + service.list.mockRejectedValueOnce(new InvalidCursorError('invalid')); + + const response = await request(app).get( + '/standalone/sessions?cursor=invalid', + ); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'Invalid cursor: "invalid" is not a valid numeric cursor', + code: 'invalid_cursor', + }); + }); + it('aborts a standalone catalog scan when the request disconnects', async () => { const { app, service } = createHarness(); let listSignal: AbortSignal | undefined; @@ -296,6 +321,109 @@ describe('standalone session routes', () => { }, ); + it.each([ + ['load', false], + ['resume', true], + ] as const)( + 'cleans up a %s restore that finishes after the request disconnects', + async (action, attached) => { + const { app, service } = createHarness(); + service.cleanupDisconnectedRestore.mockRejectedValueOnce( + new Error('transient'), + ); + let finishRestore!: () => void; + const restored = { + sessionId, + clientId: 'response-client', + workspaceCwd: '/conversations', + currentCwd: '/conversations/conversation-child', + attached, + sourceType: 'standalone' as const, + context: { kind: 'standalone' as const }, + projectlessOutputDirectory: '/conversations/conversation-child', + workingDirectory: { state: 'ready' as const }, + state: {}, + }; + service[action].mockImplementationOnce( + () => + new Promise((resolve) => { + finishRestore = () => resolve(restored as never); + }), + ); + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + const operation = httpRequest({ + hostname: '127.0.0.1', + port, + path: `/standalone/sessions/${sessionId}/${action}`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + operation.on('error', () => undefined); + operation.end('{}'); + await vi.waitFor(() => expect(service[action]).toHaveBeenCalledOnce()); + + operation.destroy(); + await vi.waitFor(async () => { + const connections = await new Promise((resolve, reject) => + server.getConnections((error, count) => + error ? reject(error) : resolve(count), + ), + ); + expect(connections).toBe(0); + }); + finishRestore(); + + await vi.waitFor(() => + expect(service.cleanupDisconnectedRestore).toHaveBeenCalledWith( + restored, + ), + ); + expect(service.cleanupDisconnectedRestore).toHaveBeenCalledTimes(2); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }, + ); + + it('redacts full skill details from standalone restore replay arrays', async () => { + const { app, service } = createHarness(); + service.load.mockResolvedValueOnce({ + sessionId, + compactedReplay: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { + availableSkills: ['bugfix'], + availableSkillDetails: [ + { name: 'bugfix', body: 'full skill body' }, + ], + }, + }, + }, + }, + ], + } as never); + + const response = await request(app) + .post(`/standalone/sessions/${sessionId}/load`) + .send({}); + + expect(response.status).toBe(200); + expect(response.body.compactedReplay[0].data.update._meta).toEqual({ + availableSkills: ['bugfix'], + }); + expect(JSON.stringify(response.body)).not.toContain('full skill body'); + }); + it('repairs an exact directory only with an empty object body', async () => { const { app, service } = createHarness(); diff --git a/packages/cli/src/serve/routes/standalone-sessions.ts b/packages/cli/src/serve/routes/standalone-sessions.ts index a809db1ab11..283830a011c 100644 --- a/packages/cli/src/serve/routes/standalone-sessions.ts +++ b/packages/cli/src/serve/routes/standalone-sessions.ts @@ -13,10 +13,14 @@ import { import type { Application, Request, RequestHandler, Response } from 'express'; import type { CreateStandaloneSessionRequest, + CreatedStandaloneSession, RestoreStandaloneSessionOptions, + RestoredStandaloneSession, StandaloneSessionService, } from '../conversations/standalone-session-service.js'; +import { omitSkillDetailsFromReplayArrays } from '../skill-details-redaction.js'; import type { SendBridgeError } from '../server/error-response.js'; +import { InvalidCursorError } from '../server/session-list.js'; import { parseSessionExportFormat, sessionExportFormatValues, @@ -230,7 +234,7 @@ function parseListOptions( const abort = () => controller.abort(); req.once('aborted', abort); res.once('close', () => { - if (!res.writableEnded) abort(); + if (!res.writableFinished) abort(); }); return { ...(cursor !== undefined ? { cursor } : {}), @@ -300,35 +304,37 @@ export function registerStandaloneSessionRoutes( ...(approvalMode !== undefined ? { approvalMode } : {}), }; let disconnected = req.aborted; - let responseClient: { sessionId: string; clientId: string } | undefined; - let detach: Promise | undefined; - const detachResponseClient = (): Promise | undefined => { - if (!responseClient) return undefined; - detach ??= deps.service - .detachResponseClient( - responseClient.sessionId, - responseClient.clientId, - ) - .catch(() => undefined); - return detach; + let responseSession: CreatedStandaloneSession | undefined; + let cleanup: Promise | undefined; + const cleanupResponseSession = (): Promise | undefined => { + if (!responseSession) return undefined; + const created = responseSession; + cleanup ??= (async () => { + try { + await deps.service.cleanupDisconnectedCreate(created); + } catch { + await deps.service.cleanupDisconnectedCreate(created); + } + })().catch((error: unknown) => { + cleanup = undefined; + throw error; + }); + return cleanup; }; const markDisconnected = () => { disconnected = true; - void detachResponseClient(); + void cleanupResponseSession()?.catch(() => undefined); }; req.once('aborted', markDisconnected); res.once('close', () => { - if (!res.writableEnded) markDisconnected(); + if (!res.writableFinished) markDisconnected(); }); const created = await deps.service.create(request); if (created.session.clientId !== undefined) { - responseClient = { - sessionId: created.session.sessionId, - clientId: created.session.clientId, - }; + responseSession = created; } if (disconnected || !res.writable || res.destroyed) { - await detachResponseClient(); + await cleanupResponseSession()?.catch(() => undefined); return; } res.status(200).json({ @@ -345,7 +351,22 @@ export function registerStandaloneSessionRoutes( handle('GET /standalone/sessions', req, res, async () => { const options = parseListOptions(req, res); if (!options) return; - res.status(200).json(await deps.service.list(options)); + try { + const result = await deps.service.list(options); + options.signal.throwIfAborted(); + if (res.destroyed || !res.writable) return; + res.status(200).json(result); + } catch (error) { + if (options.signal.aborted || res.destroyed) return; + if (error instanceof InvalidCursorError) { + res.status(400).json({ + error: error.message, + code: 'invalid_cursor', + }); + return; + } + throw error; + } }), ); @@ -368,11 +389,44 @@ export function registerStandaloneSessionRoutes( async () => { const options = parseRestoreOptions(req, res); if (!options) return; - res - .status(200) - .json( - await deps.service[action](req.params['id'] ?? '', options), - ); + let disconnected = req.aborted; + const responseState: { + restored?: RestoredStandaloneSession; + } = {}; + let cleanup: Promise | undefined; + const cleanupRestore = (): Promise | undefined => { + const response = responseState.restored; + if (!response) return undefined; + cleanup ??= (async () => { + try { + await deps.service.cleanupDisconnectedRestore(response); + } catch { + await deps.service.cleanupDisconnectedRestore(response); + } + })().catch((error: unknown) => { + cleanup = undefined; + throw error; + }); + return cleanup; + }; + const markDisconnected = () => { + disconnected = true; + void cleanupRestore()?.catch(() => undefined); + }; + req.once('aborted', markDisconnected); + res.once('close', () => { + if (!res.writableFinished) markDisconnected(); + }); + const restored = await deps.service[action]( + req.params['id'] ?? '', + options, + ); + responseState.restored = restored; + if (disconnected || !res.writable || res.destroyed) { + await cleanupRestore()?.catch(() => undefined); + return; + } + res.status(200).json(omitSkillDetailsFromReplayArrays(restored)); }, ), ); diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index f3b61878be9..4271c898049 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -100,7 +100,7 @@ describe('sendBridgeError session writer errors', () => { ['working_directory_compromised', 409, false], ['deletion_recovery_compromised', 409, false], ['standalone_session_operation_failed', 500, false], - ['standalone_creation_rolled_back', 500, false], + ['standalone_creation_rolled_back', 500, true], ['standalone_creation_outcome_unknown', 500, false], ['transcript_deletion_failed', 500, true], ['transcript_deletion_outcome_unknown', 500, false], diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index f60d8ac8620..84062f73895 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3704,6 +3704,49 @@ describe('Server Config (config.ts)', () => { acquire.mockRestore(); }); + it('retries pending lease durability without reporting stale ownership', async () => { + const config = new Config({ + ...baseParams, + chatRecording: true, + experimentalZedIntegration: true, + sessionWriterLeaseEnabled: true, + }); + const cleanupFailure = new SessionWriterUnavailableError(); + let released = false; + let durabilityPending = false; + const release = vi + .fn() + .mockImplementationOnce(async () => { + released = true; + durabilityPending = true; + throw cleanupFailure; + }) + .mockImplementationOnce(async () => { + durabilityPending = false; + }); + const lease = { + release, + get isReleased() { + return released; + }, + get isReleaseDurabilityPending() { + return durabilityPending; + }, + } as unknown as SessionWriterLease; + ( + config as unknown as { + pendingSessionWriterLease: SessionWriterLease; + } + ).pendingSessionWriterLease = lease; + + await expect(config.closeSessionWriter()).rejects.toBe(cleanupFailure); + expect(config.hasSessionWriteOwnership()).toBe(false); + + await expect(config.closeSessionWriter()).resolves.toBeUndefined(); + expect(release).toHaveBeenCalledTimes(2); + expect(config.hasSessionWriteOwnership()).toBe(false); + }); + it('preserves activation and lease release failures', async () => { const config = new Config({ ...baseParams, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index afad48c9fcc..419c968e7aa 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3475,6 +3475,8 @@ export class Config { this.startPendingGoalRestore(); } catch (error) { let failure: unknown = error; + const ownedLease = lease ?? this.pendingSessionWriterLease; + let releaseFailureAlreadyReported = false; if ( !(failure instanceof SessionWriterError) && failure && @@ -3484,11 +3486,11 @@ export class Config { failure = new SessionWriterUnavailableError({ cause: failure }); } try { - const ownedLease = lease ?? this.pendingSessionWriterLease; await this.startPendingSessionWriterRelease(ownedLease); if ( this.pendingSessionWriterLease === ownedLease && - (ownedLease?.isReleased ?? true) + (ownedLease?.isReleased ?? true) && + !ownedLease?.isReleaseDurabilityPending ) { this.pendingSessionWriterLease = undefined; } @@ -3502,16 +3504,29 @@ export class Config { } catch (releaseError) { if ( releaseError instanceof SessionWriterLostError || - (lease ?? this.pendingSessionWriterLease)?.isReleased + (ownedLease?.isReleased && !ownedLease.isReleaseDurabilityPending) ) { this.pendingSessionWriterLease = undefined; - } else if (!containsErrorByIdentity(failure, releaseError)) { - failure = new SessionWriterUnavailableError({ - cause: new AggregateError( - [failure, releaseError], - 'Session writer lease release failed during activation cleanup', - ), - }); + } else { + releaseFailureAlreadyReported = containsErrorByIdentity( + failure, + releaseError, + ); + if (!releaseFailureAlreadyReported) { + failure = new SessionWriterUnavailableError({ + cause: new AggregateError( + [failure, releaseError], + 'Session writer lease release failed during activation cleanup', + ), + }); + } + } + } finally { + if ( + !releaseFailureAlreadyReported && + this.pendingSessionWriterRelease?.lease === ownedLease + ) { + this.pendingSessionWriterRelease = undefined; } } // The writer never became available, so the deferred restore can never @@ -8424,7 +8439,8 @@ export class Config { hasSessionWriteOwnership(): boolean { if (isDerivedConfig(this)) return false; return ( - this.pendingSessionWriterLease !== undefined || + (this.pendingSessionWriterLease !== undefined && + !this.pendingSessionWriterLease.isReleased) || this.chatRecordingService?.hasWriteOwnership() === true ); } @@ -8461,8 +8477,15 @@ export class Config { handoff: this.sessionWriterHandoffRequested, }); this.startPendingSessionWriterRelease(); - this.sessionWriterClosePromise ??= this.closeSessionWriterOnce(); - return this.sessionWriterClosePromise; + if (this.sessionWriterClosePromise) return this.sessionWriterClosePromise; + const pending = this.closeSessionWriterOnce(); + this.sessionWriterClosePromise = pending; + void pending.catch(() => { + if (this.sessionWriterClosePromise === pending) { + this.sessionWriterClosePromise = undefined; + } + }); + return pending; } private async closeSessionWriterOnce(): Promise { @@ -8490,18 +8513,23 @@ export class Config { await this.startPendingSessionWriterRelease(pendingLease); if ( this.pendingSessionWriterLease === pendingLease && - pendingLease.isReleased + pendingLease.isReleased && + !pendingLease.isReleaseDurabilityPending ) { this.pendingSessionWriterLease = undefined; } } catch (error) { if ( error instanceof SessionWriterLostError || - pendingLease.isReleased + (pendingLease.isReleased && !pendingLease.isReleaseDurabilityPending) ) { this.pendingSessionWriterLease = undefined; } failures.push(error); + } finally { + if (this.pendingSessionWriterRelease?.lease === pendingLease) { + this.pendingSessionWriterRelease = undefined; + } } } if (failures.length === 1) { diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 01ac71e9c92..9240f2843de 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -3363,6 +3363,38 @@ describe('ChatRecordingService', () => { await expect(chatRecordingService.close()).rejects.toBe(cleanupFailure); expect(chatRecordingService.hasWriteOwnership()).toBe(false); }); + + it('retries release durability without reporting stale write ownership', async () => { + const cleanupFailure = new SessionWriterUnavailableError(); + let released = false; + let durabilityPending = false; + Object.defineProperties(mockLease, { + isReleased: { + configurable: true, + get: () => released, + }, + isReleaseDurabilityPending: { + configurable: true, + get: () => durabilityPending, + }, + }); + vi.mocked(mockLease.release) + .mockImplementationOnce(async () => { + released = true; + durabilityPending = true; + throw cleanupFailure; + }) + .mockImplementationOnce(async () => { + durabilityPending = false; + }); + + await expect(chatRecordingService.close()).rejects.toBe(cleanupFailure); + expect(chatRecordingService.hasWriteOwnership()).toBe(false); + + await expect(chatRecordingService.close()).resolves.toBeUndefined(); + expect(mockLease.release).toHaveBeenCalledTimes(2); + expect(chatRecordingService.hasWriteOwnership()).toBe(false); + }); }); // Note: Session management tests (listSessions, loadSession, deleteSession, etc.) diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 52a7e84573a..ebab89473f7 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -1626,8 +1626,18 @@ export class ChatRecordingService { if (this.closePromise) return this.closePromise; if (this.state === 'closed') return Promise.resolve(); this.beginClose(options); - this.closePromise = this.closeOnce(); - return this.closePromise; + const pending = this.closeOnce(); + this.closePromise = pending; + void pending.catch(() => { + if ( + this.closePromise === pending && + this.binding !== undefined && + this.state === 'integrity_failed' + ) { + this.closePromise = undefined; + } + }); + return pending; } beginClose(options?: { handoff?: boolean }): void { @@ -1668,7 +1678,10 @@ export class ChatRecordingService { this.binding = undefined; this.state = 'closed'; } catch (error) { - if (lease?.isReleased || error instanceof SessionWriterLostError) { + if ( + error instanceof SessionWriterLostError || + (lease?.isReleased && !lease.isReleaseDurabilityPending) + ) { this.binding = undefined; this.state = 'closed'; } else { @@ -1680,7 +1693,7 @@ export class ChatRecordingService { } hasWriteOwnership(): boolean { - return this.binding !== undefined; + return this.binding !== undefined && !this.binding.lease.isReleased; } /** diff --git a/packages/core/src/services/session-writer-lease.test.ts b/packages/core/src/services/session-writer-lease.test.ts index 684dffaf681..6fc09c3821b 100644 --- a/packages/core/src/services/session-writer-lease.test.ts +++ b/packages/core/src/services/session-writer-lease.test.ts @@ -54,10 +54,19 @@ const lstatFault = vi.hoisted(() => ({ calls: 0, })); +const directorySyncFault = vi.hoisted(() => ({ + path: undefined as string | undefined, + remainingFailures: 0, +})); + const zeroInodeFault = vi.hoisted(() => ({ underRoot: undefined as string | undefined, })); +const pathZeroInodeFault = vi.hoisted(() => ({ + underRoot: undefined as string | undefined, +})); + const fsOpenTestHook = vi.hoisted(() => ({ beforeOpen: undefined as | ((filePath: PathLike, flags: string | number) => void | Promise) @@ -127,9 +136,11 @@ vi.mock('node:fs/promises', async (importOriginal) => { actual.stat as (...args: unknown[]) => ReturnType )(filePath, ...rest); if ( - zeroInodeFault.underRoot !== undefined && typeof filePath === 'string' && - filePath.startsWith(zeroInodeFault.underRoot) + ((zeroInodeFault.underRoot !== undefined && + filePath.startsWith(zeroInodeFault.underRoot)) || + (pathZeroInodeFault.underRoot !== undefined && + filePath.startsWith(pathZeroInodeFault.underRoot))) ) { Object.defineProperty(result, 'ino', { value: 0 }); } @@ -138,6 +149,31 @@ vi.mock('node:fs/promises', async (importOriginal) => { open: async (filePath: PathLike, flags: string | number, mode?: Mode) => { await fsOpenTestHook.beforeOpen?.(filePath, flags); const handle = await actual.open(filePath, flags, mode); + if ( + zeroInodeFault.underRoot !== undefined && + typeof filePath === 'string' && + filePath.startsWith(zeroInodeFault.underRoot) + ) { + const handleStat = handle.stat.bind(handle); + handle.stat = (async (...args) => { + const result = await handleStat(...args); + Object.defineProperty(result, 'ino', { value: 0 }); + return result; + }) as typeof handle.stat; + } + const sync = handle.sync.bind(handle); + handle.sync = async () => { + if ( + filePath === directorySyncFault.path && + directorySyncFault.remainingFailures > 0 + ) { + directorySyncFault.remainingFailures--; + throw Object.assign(new Error('directory sync failure'), { + code: 'EIO', + }); + } + await sync(); + }; const writeFile = handle.writeFile.bind(handle); handle.writeFile = async (data, options) => { if ( @@ -397,7 +433,10 @@ afterEach(async () => { lstatFault.path = undefined; lstatFault.remainingFailures = 0; lstatFault.calls = 0; + directorySyncFault.path = undefined; + directorySyncFault.remainingFailures = 0; zeroInodeFault.underRoot = undefined; + pathZeroInodeFault.underRoot = undefined; fsOpenTestHook.beforeOpen = undefined; transitionFault.renameFrom = undefined; transitionFault.renameTo = undefined; @@ -1427,6 +1466,7 @@ describe('SessionWriterLease', () => { await fs.mkdir(path.dirname(fixture.transcriptPath), { recursive: true }); const seed = '{"seed":true}\n'; await fs.writeFile(fixture.transcriptPath, seed); + zeroInodeFault.underRoot = fixture.runtimeBaseDir; const originalStat = nativeFileHandleStat; const stat = vi .spyOn(fileHandlePrototype, 'stat') @@ -2164,6 +2204,30 @@ describe('SessionWriterLease', () => { }, ); + it('retries release when a completed lease still exactly owns the primary lock', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire(fixture.options); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + const retiredPath = `${lockPath}.released.${encodeURIComponent(lease.ownerId)}`; + await fs.mkdir(retiredPath); + + await expect(lease.release()).rejects.toBeInstanceOf( + SessionWriterUnavailableError, + ); + expect(lease.isReleased).toBe(false); + await expect(fs.readFile(lockPath, 'utf8')).resolves.toContain( + fixture.options.sessionId, + ); + + await fs.rmdir(retiredPath); + await expect(lease.release()).resolves.toBeUndefined(); + expect(lease.isReleased).toBe(true); + await expect(fs.lstat(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('retries a transient ownership precheck failure before release', async () => { const fixture = await createFixture(); const lease = await SessionWriterLease.acquire(fixture.options); @@ -2181,6 +2245,148 @@ describe('SessionWriterLease', () => { await expect(fs.lstat(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('retries release after ownership checks are temporarily unavailable', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire(fixture.options); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + lstatFault.path = lockPath; + lstatFault.remainingFailures = 4; + + await expect(lease.release()).rejects.toBeInstanceOf( + SessionWriterUnavailableError, + ); + await expect(lease.release()).resolves.toBeUndefined(); + + expect(lease.isReleased).toBe(true); + expect(lstatFault.calls).toBeGreaterThanOrEqual(5); + await expect(fs.lstat(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('retries release durability after the primary lock is removed', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire(fixture.options); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + directorySyncFault.path = path.dirname(lockPath); + directorySyncFault.remainingFailures = 1; + + await expect(lease.release()).rejects.toBeInstanceOf( + SessionWriterUnavailableError, + ); + expect(lease.isReleased).toBe(true); + expect(lease.isReleaseDurabilityPending).toBe(true); + await expect(fs.lstat(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }); + + await expect(lease.release()).resolves.toBeUndefined(); + expect(lease.isReleased).toBe(true); + expect(lease.isReleaseDurabilityPending).toBe(false); + }); + + it('reconciles a release rename error after the rename took effect', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire(fixture.options); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + transitionFault.renameFrom = lockPath; + transitionFault.renameTo = `${lockPath}.released.${encodeURIComponent(lease.ownerId)}`; + transitionFault.afterRename = () => { + throw Object.assign(new Error('rename result unavailable'), { + code: 'EIO', + }); + }; + + await expect(lease.release()).resolves.toBeUndefined(); + + expect(lease.isReleased).toBe(true); + await expect(fs.lstat(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not confirm release through a replacement lock directory', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire(fixture.options); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + const lockDirectory = path.dirname(lockPath); + const originalDirectory = `${lockDirectory}.original`; + const retiredPath = `${lockPath}.released.${encodeURIComponent(lease.ownerId)}`; + transitionFault.renameFrom = lockPath; + transitionFault.renameTo = retiredPath; + transitionFault.afterRename = async () => { + await fs.rename(lockDirectory, originalDirectory); + await fs.mkdir(lockDirectory); + }; + + try { + await expect(lease.release()).rejects.toBeInstanceOf( + SessionWriterUnavailableError, + ); + + expect(lease.isReleased).toBe(true); + expect(lease.isReleaseDurabilityPending).toBe(true); + await expect( + fs.stat(path.join(originalDirectory, path.basename(retiredPath))), + ).resolves.toBeDefined(); + await expect(fs.lstat(lockPath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + await fs.rmdir(lockDirectory); + await fs.rename(originalDirectory, lockDirectory); + await lease.release(); + await fs.unlink(retiredPath).catch(() => undefined); + } + }); + + it('rejects release when lock directory inode verifiability changes', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire(fixture.options); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + pathZeroInodeFault.underRoot = path.dirname(lockPath); + + await expect(lease.release()).rejects.toBeInstanceOf( + SessionWriterUnavailableError, + ); + await expect(fs.stat(lockPath)).resolves.toBeDefined(); + + pathZeroInodeFault.underRoot = undefined; + await expect(lease.release()).resolves.toBeUndefined(); + }); + + it('retries release durability before discarding a failed acquisition', async () => { + const fixture = await createFixture(); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + const activationFailure = new Error('activation failed'); + directorySyncFault.path = path.dirname(lockPath); + directorySyncFault.remainingFailures = 1; + + await expect( + SessionWriterLease.acquire({ + ...fixture.options, + onOwnershipAcquired: () => { + throw activationFailure; + }, + }), + ).rejects.toBe(activationFailure); + + expect(directorySyncFault.remainingFailures).toBe(0); + await expect(fs.lstat(lockPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('never reclaims a dead local owner when managed policy is enabled', async () => { const fixture = await createFixture(); const owner = startLeaseProcess(); diff --git a/packages/core/src/services/session-writer-lease.ts b/packages/core/src/services/session-writer-lease.ts index 0ab985ee227..dae0a72d920 100644 --- a/packages/core/src/services/session-writer-lease.ts +++ b/packages/core/src/services/session-writer-lease.ts @@ -33,6 +33,97 @@ const TRANSCRIPT_APPEND_FLAGS = nodeConstants.O_RDWR | TRANSCRIPT_NO_FOLLOW_FLAG | TRANSCRIPT_NONBLOCK_FLAG; + +interface DurableLockDirectory { + path: string; + handle: fs.FileHandle; + dev: number; + ino: number; + inodeVerifiable: boolean; +} + +async function openDurableLockDirectory( + directory: string, +): Promise { + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open( + directory, + nodeConstants.O_RDONLY | + (process.platform === 'win32' ? 0 : (nodeConstants.O_NOFOLLOW ?? 0)), + ); + const opened = await handle.stat(); + const current = await fs.stat(directory); + const openedInodeVerifiable = hasVerifiableInode(opened.ino); + const currentInodeVerifiable = hasVerifiableInode(current.ino); + if ( + !opened.isDirectory() || + !current.isDirectory() || + opened.dev !== current.dev || + openedInodeVerifiable !== currentInodeVerifiable || + (openedInodeVerifiable && opened.ino !== current.ino) + ) { + throw new Error('Session writer lock directory changed.'); + } + return { + path: directory, + handle, + dev: opened.dev, + ino: opened.ino, + inodeVerifiable: openedInodeVerifiable, + }; + } catch (error) { + await handle?.close().catch(() => undefined); + if (error instanceof SessionWriterError) throw error; + throw new SessionWriterUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } +} + +async function syncDurableLockDirectory( + directory: DurableLockDirectory, +): Promise { + try { + const opened = await directory.handle.stat(); + const openedInodeVerifiable = hasVerifiableInode(opened.ino); + if ( + !opened.isDirectory() || + opened.dev !== directory.dev || + openedInodeVerifiable !== directory.inodeVerifiable || + (directory.inodeVerifiable && opened.ino !== directory.ino) + ) { + throw new Error('Session writer lock directory changed.'); + } + try { + await directory.handle.sync(); + } catch (error) { + if ( + process.platform !== 'win32' || + !['EACCES', 'EINVAL', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) { + throw error; + } + } + const current = await fs.stat(directory.path); + const currentInodeVerifiable = hasVerifiableInode(current.ino); + if ( + !current.isDirectory() || + current.dev !== directory.dev || + currentInodeVerifiable !== directory.inodeVerifiable || + (directory.inodeVerifiable && current.ino !== directory.ino) + ) { + throw new Error('Session writer lock directory changed.'); + } + } catch (error) { + if (error instanceof SessionWriterError) throw error; + throw new SessionWriterUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } +} const debugLogger = createDebugLogger('SESSION_WRITER_LEASE'); function describeError(error: unknown): string { @@ -1484,6 +1575,10 @@ export class SessionWriterLease { private expectedTranscriptState: TranscriptState | undefined; private expectedTranscriptHasher: Hash | undefined; private released = false; + private releaseDurabilityPending = false; + private releaseDirectory: DurableLockDirectory | undefined; + private releaseRetryable = true; + private terminalOperation: 'release' | 'seal' | undefined; private terminalPromise: Promise | undefined; private operationTail: Promise = Promise.resolve(); private readonly lockRecordRaw: string; @@ -1865,9 +1960,20 @@ export class SessionWriterLease { } return lease; } catch (error) { - try { - await lease.release(); - } catch (releaseError) { + let releaseError: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt === 1) lease.releaseRetryable = false; + try { + await lease.release(); + releaseError = undefined; + break; + } catch (candidate) { + releaseError = candidate; + if (candidate instanceof SessionWriterLostError) break; + } + } + if (releaseError !== undefined) { + await lease.closeReleaseDirectory(); throw new SessionWriterUnavailableError({ cause: new AggregateError( [error, releaseError], @@ -2201,11 +2307,29 @@ export class SessionWriterLease { } release(): Promise { - this.terminalPromise ??= this.runExclusive(() => this.releaseOnce()); + if (this.terminalOperation === 'seal') return this.terminalPromise!; + this.terminalOperation = 'release'; + if (this.terminalPromise) return this.terminalPromise; + const terminal = this.runExclusive(() => this.releaseOnce()).catch( + (error: unknown) => { + if ( + this.releaseRetryable && + (!this.released || this.releaseDurabilityPending) && + !(error instanceof SessionWriterLostError) && + this.terminalPromise === terminal + ) { + this.terminalPromise = undefined; + } + throw error; + }, + ); + this.terminalPromise = terminal; return this.terminalPromise; } sealForHandoff(): Promise { + if (this.terminalOperation === 'release') return this.release(); + this.terminalOperation = 'seal'; this.terminalPromise ??= this.runExclusive(() => this.sealForHandoffOnce()); return this.terminalPromise; } @@ -2214,6 +2338,10 @@ export class SessionWriterLease { return this.released; } + get isReleaseDurabilityPending(): boolean { + return this.releaseDurabilityPending; + } + private runExclusive(operation: () => Promise): Promise { const pending = this.operationTail.then(operation, operation); this.operationTail = pending.then( @@ -2224,39 +2352,67 @@ export class SessionWriterLease { } private async releaseOnce(): Promise { - if (this.released) return; - await this.readOwnedLockForRelease(); + if (this.released) { + if (this.releaseDurabilityPending) { + if (!this.releaseDirectory) throw new SessionWriterUnavailableError(); + await syncDurableLockDirectory(this.releaseDirectory); + this.releaseDurabilityPending = false; + await this.closeReleaseDirectory(); + } + return; + } try { - await fs.rename(this.lockPath, this.retiredPath); + this.releaseDirectory = await openDurableLockDirectory( + path.dirname(this.lockPath), + ); + await this.readOwnedLockForRelease(); + try { + await fs.rename(this.lockPath, this.retiredPath); + } catch (error) { + const [primaryState, retiredState] = await Promise.all([ + this.inspectReleasePath(this.lockPath), + this.inspectReleasePath(this.retiredPath), + ]); + if (primaryState === 'missing' || primaryState === 'other') { + this.released = true; + if (retiredState === 'owned') { + this.releaseDurabilityPending = true; + await fs.unlink(this.retiredPath).catch(() => {}); + await syncDurableLockDirectory(this.releaseDirectory); + this.releaseDurabilityPending = false; + await this.closeReleaseDirectory(); + return; + } + throw new SessionWriterLostError(); + } + if (error instanceof SessionWriterError) throw error; + throw new SessionWriterUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } this.released = true; + this.releaseDurabilityPending = true; await fs.unlink(this.retiredPath).catch((error) => { debugLogger.debug( `Session writer retired lock cleanup failed path=${JSON.stringify(this.retiredPath)} ` + `error=${describeDiagnosticError(error)}`, ); }); + await syncDurableLockDirectory(this.releaseDirectory); + this.releaseDurabilityPending = false; + await this.closeReleaseDirectory(); } catch (error) { - const [primaryState, retiredState] = await Promise.all([ - this.inspectReleasePath(this.lockPath), - this.inspectReleasePath(this.retiredPath), - ]); - if (primaryState === 'missing' || primaryState === 'other') { - this.released = true; - if (retiredState === 'owned') { - await fs.unlink(this.retiredPath).catch(() => {}); - throw new SessionWriterUnavailableError({ - cause: error instanceof Error ? error : undefined, - }); - } - throw new SessionWriterLostError(); - } - if (error instanceof SessionWriterError) throw error; - throw new SessionWriterUnavailableError({ - cause: error instanceof Error ? error : undefined, - }); + if (!this.releaseDurabilityPending) await this.closeReleaseDirectory(); + throw error; } } + private async closeReleaseDirectory(): Promise { + const directory = this.releaseDirectory; + this.releaseDirectory = undefined; + await directory?.handle.close().catch(() => undefined); + } + private async sealForHandoffOnce(): Promise { if (this.released) return; const ownedRecord = await this.readOwnedLockForRelease(); diff --git a/packages/core/src/services/sessionService.rename.test.ts b/packages/core/src/services/sessionService.rename.test.ts index a5043b75ed2..c4bc875016a 100644 --- a/packages/core/src/services/sessionService.rename.test.ts +++ b/packages/core/src/services/sessionService.rename.test.ts @@ -132,6 +132,69 @@ describe('SessionService - rename and custom title', () => { ); }); + it('runs lifecycle mutation fences before appending the title', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + const internals = sessionService as unknown as { + resolveMaintainableSessionSnapshot: () => Promise<{ + location: 'active'; + identities: Array<{ + state: 'active'; + filePath: string; + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; + }>; + }>; + assertMaintainableSessionUnchanged: () => void; + }; + vi.spyOn( + internals, + 'resolveMaintainableSessionSnapshot', + ).mockResolvedValue({ + location: 'active', + identities: [ + { + state: 'active', + filePath: `/chats/${sessionIdA}.jsonl`, + dev: 1, + ino: 1, + size: 1, + mtimeMs: 1, + ctimeMs: 1, + }, + ], + }); + vi.spyOn( + internals, + 'assertMaintainableSessionUnchanged', + ).mockImplementation(() => undefined); + const order: string[] = []; + vi.mocked(jsonl.writeLineSync).mockImplementation(() => { + order.push('write'); + }); + + await expect( + sessionService.renameSessionForLifecycle( + sessionIdA, + 'lifecycle title', + 'manual', + 'active', + { + assertStorageUnchanged: () => { + order.push('storage'); + }, + assertCanMutate: () => { + order.push('runtime'); + }, + }, + ), + ).resolves.toBe(true); + + expect(order).toEqual(['storage', 'runtime', 'write']); + }); + it('should return false when session does not exist', async () => { vi.mocked(jsonl.readLines).mockResolvedValue([]); diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 4b99a264585..5c902a1d95d 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -25,6 +25,7 @@ import { getProjectHash } from '../utils/paths.js'; import { readRuntimeStatus } from '../utils/runtimeStatus.js'; import { SessionService, + SessionTranscriptDurabilityError, buildApiHistoryFromConversation, computeUniqueBranchTitle, normalizeDerivedBranchTitle, @@ -1762,6 +1763,12 @@ describe('SessionService', () => { }); describe('removeSession', () => { + const lifecycleParent = { + device: 7, + inode: 9, + inodeVerifiable: true, + }; + it('should remove session file', async () => { vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); @@ -2072,10 +2079,24 @@ describe('SessionService', () => { SessionOrganizationService.prototype, 'removeSession', ); + const sync = vi.fn(async () => undefined); + const close = vi.fn(async () => undefined); + const directoryStats = { + dev: 7, + ino: 9, + isDirectory: () => true, + } as fs.Stats; + statPromiseSpy.mockResolvedValue(directoryStats); + const open = vi.spyOn(fs.promises, 'open').mockResolvedValue({ + stat: vi.fn(async () => directoryStats), + sync, + close, + } as unknown as fs.promises.FileHandle); const removed = await sessionService.removeSessionTranscriptForLifecycle( sessionIdA, 'active', + lifecycleParent, ); expect(removed).toBe(true); @@ -2086,6 +2107,159 @@ describe('SessionService', () => { expect(commitUsageBeforeTranscriptDeletion).toHaveBeenCalledOnce(); expect(rmSyncSpy).not.toHaveBeenCalled(); expect(removeOrganizationSpy).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith( + expect.stringContaining('/chats'), + expect.any(Number), + ); + expect(sync).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + + it('surfaces transcript parent sync failure after lifecycle unlink', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return [recordA1]; + }, + ); + existsSyncSpy.mockReturnValue(true); + const syncError = Object.assign(new Error('sync failed'), { + code: 'EIO', + }); + const directoryStats = { + dev: 7, + ino: 9, + isDirectory: () => true, + } as fs.Stats; + statPromiseSpy.mockResolvedValue(directoryStats); + vi.spyOn(fs.promises, 'open').mockResolvedValue({ + stat: vi.fn(async () => directoryStats), + sync: vi.fn(async () => Promise.reject(syncError)), + close: vi.fn(async () => undefined), + } as unknown as fs.promises.FileHandle); + + await expect( + sessionService.removeSessionTranscriptForLifecycle( + sessionIdA, + 'active', + lifecycleParent, + ), + ).rejects.toMatchObject({ + name: 'SessionTranscriptDurabilityError', + cause: syncError, + } satisfies Partial); + + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + ); + }); + + it('rejects lifecycle durability when the transcript parent is replaced after unlink', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return [recordA1]; + }, + ); + existsSyncSpy.mockReturnValue(true); + const original = { + dev: 7, + ino: 9, + isDirectory: () => true, + } as fs.Stats; + const replacement = { + dev: 7, + ino: 10, + isDirectory: () => true, + } as fs.Stats; + statPromiseSpy + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(replacement); + vi.spyOn(fs.promises, 'open').mockResolvedValue({ + stat: vi.fn(async () => original), + sync: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + } as unknown as fs.promises.FileHandle); + + await expect( + sessionService.removeSessionTranscriptForLifecycle( + sessionIdA, + 'active', + lifecycleParent, + ), + ).rejects.toBeInstanceOf(SessionTranscriptDurabilityError); + + expect(unlinkSyncSpy).toHaveBeenCalledOnce(); + }); + + it('rejects recovery confirmation for a replacement transcript parent', async () => { + const replacement = { + dev: 7, + ino: 10, + isDirectory: () => true, + } as fs.Stats; + statPromiseSpy.mockResolvedValue(replacement); + const sync = vi.fn(async () => undefined); + vi.spyOn(fs.promises, 'open').mockResolvedValue({ + stat: vi.fn(async () => replacement), + sync, + close: vi.fn(async () => undefined), + } as unknown as fs.promises.FileHandle); + + await expect( + sessionService.confirmSessionTranscriptDeletionForLifecycle( + 'active', + lifecycleParent, + ), + ).rejects.toBeInstanceOf(SessionTranscriptDurabilityError); + + expect(sync).not.toHaveBeenCalled(); + }); + + it('does not hide lifecycle directory I/O failures on Windows', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return [recordA1]; + }, + ); + existsSyncSpy.mockReturnValue(true); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const directoryStats = { + dev: 7, + ino: 9, + isDirectory: () => true, + } as fs.Stats; + statPromiseSpy.mockResolvedValue(directoryStats); + const syncError = Object.assign(new Error('sync failed'), { + code: 'EIO', + }); + vi.spyOn(fs.promises, 'open').mockResolvedValue({ + stat: vi.fn(async () => directoryStats), + sync: vi.fn(async () => Promise.reject(syncError)), + close: vi.fn(async () => undefined), + } as unknown as fs.promises.FileHandle); + + await expect( + sessionService.removeSessionTranscriptForLifecycle( + sessionIdA, + 'active', + lifecycleParent, + ), + ).rejects.toMatchObject({ cause: syncError }); }); it('rejects lifecycle deletion when active and archived transcripts conflict', async () => { @@ -2095,6 +2269,7 @@ describe('SessionService', () => { sessionService.removeSessionTranscriptForLifecycle( sessionIdA, 'active', + lifecycleParent, ), ).rejects.toBeInstanceOf(SessionTranscriptChangedError); @@ -2118,6 +2293,7 @@ describe('SessionService', () => { sessionService.removeSessionTranscriptForLifecycle( sessionIdA, 'active', + lifecycleParent, ), ).rejects.toBeInstanceOf(SessionTranscriptChangedError); @@ -2155,6 +2331,103 @@ describe('SessionService', () => { expect(removeOrganizationSpy).toHaveBeenCalledWith(sessionIdA); }); + it('durably confirms lifecycle sidecar cleanup before returning', async () => { + const directoryStats = { + dev: 7, + ino: 9, + isDirectory: () => true, + } as fs.Stats; + statPromiseSpy.mockResolvedValue(directoryStats); + const sync = vi.fn(async () => undefined); + const close = vi.fn(async () => undefined); + const open = vi.spyOn(fs.promises, 'open').mockImplementation( + async () => + ({ + stat: vi.fn(async () => directoryStats), + sync, + close, + }) as unknown as fs.promises.FileHandle, + ); + vi.spyOn( + SessionOrganizationService.prototype, + 'removeSession', + ).mockResolvedValue(); + + await sessionService.cleanupRemovedSessionStateForLifecycle(sessionIdA); + + expect(open).toHaveBeenCalledTimes(4); + expect(sync).toHaveBeenCalledTimes(4); + expect(close).toHaveBeenCalledTimes(4); + }); + + it('rejects a replacement sidecar parent before lifecycle cleanup', async () => { + const original = { + dev: 7, + ino: 9, + isDirectory: () => true, + } as fs.Stats; + const replacement = { + dev: 7, + ino: 10, + isDirectory: () => true, + } as fs.Stats; + statPromiseSpy + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(replacement); + vi.spyOn(fs.promises, 'open').mockImplementation( + async () => + ({ + stat: vi.fn(async () => original), + sync: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }) as unknown as fs.promises.FileHandle, + ); + const removeOrganization = vi.spyOn( + SessionOrganizationService.prototype, + 'removeSession', + ); + + await expect( + sessionService.cleanupRemovedSessionStateForLifecycle(sessionIdA), + ).rejects.toThrow('Session transcript parent directory changed.'); + + expect(unlinkSyncSpy).not.toHaveBeenCalled(); + expect(rmSyncSpy).not.toHaveBeenCalled(); + expect(removeOrganization).not.toHaveBeenCalled(); + }); + + it('rejects a vanished sidecar parent after opening it', async () => { + const original = { + dev: 7, + ino: 9, + isDirectory: () => true, + } as fs.Stats; + const vanished = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + statPromiseSpy.mockRejectedValueOnce(vanished); + const close = vi.fn(async () => undefined); + vi.spyOn(fs.promises, 'open').mockResolvedValue({ + stat: vi.fn(async () => original), + sync: vi.fn(async () => undefined), + close, + } as unknown as fs.promises.FileHandle); + const removeOrganization = vi.spyOn( + SessionOrganizationService.prototype, + 'removeSession', + ); + + await expect( + sessionService.cleanupRemovedSessionStateForLifecycle(sessionIdA), + ).rejects.toBe(vanished); + + expect(close).toHaveBeenCalledOnce(); + expect(unlinkSyncSpy).not.toHaveBeenCalled(); + expect(rmSyncSpy).not.toHaveBeenCalled(); + expect(removeOrganization).not.toHaveBeenCalled(); + }); + it('surfaces lifecycle sidecar cleanup failures for retry', async () => { const cleanupError = new Error('organization cleanup failed'); vi.spyOn( diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index cbaa1f02c9e..49e90d99520 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -193,6 +193,22 @@ export class SessionStorageEntryError extends Error { } } +export class SessionTranscriptDurabilityError extends Error { + override readonly name = 'SessionTranscriptDurabilityError'; + + constructor(options: { cause: unknown }) { + super('Session transcript deletion durability could not be confirmed.', { + cause: options.cause, + }); + } +} + +export interface SessionTranscriptParentIdentity { + device: number; + inode: number; + inodeVerifiable: boolean; +} + export class SessionIdCaseConflictError extends Error { override readonly name = 'SessionIdCaseConflictError'; @@ -416,6 +432,122 @@ async function fsyncDirectoryBestEffort(directory: string): Promise { } } +interface DurableDirectoryHandle { + directory: string; + handle: fs.promises.FileHandle; + dev: number; + ino: number; + inodeVerifiable: boolean; +} + +function sameTranscriptParentIdentity( + left: SessionTranscriptParentIdentity, + right: SessionTranscriptParentIdentity, +): boolean { + return ( + left.device === right.device && + left.inodeVerifiable === right.inodeVerifiable && + (!left.inodeVerifiable || left.inode === right.inode) + ); +} + +function transcriptParentIdentity( + directory: DurableDirectoryHandle, +): SessionTranscriptParentIdentity { + return { + device: directory.dev, + inode: directory.inodeVerifiable ? directory.ino : 0, + inodeVerifiable: directory.inodeVerifiable, + }; +} + +async function assertDurableDirectoryHandle( + expected: DurableDirectoryHandle, +): Promise { + const [opened, current] = await Promise.all([ + expected.handle.stat(), + fs.promises.stat(expected.directory), + ]); + const openedInodeVerifiable = hasVerifiableInode(opened.ino); + const currentInodeVerifiable = hasVerifiableInode(current.ino); + if ( + !opened.isDirectory() || + !current.isDirectory() || + opened.dev !== expected.dev || + current.dev !== expected.dev || + openedInodeVerifiable !== expected.inodeVerifiable || + currentInodeVerifiable !== expected.inodeVerifiable || + (expected.inodeVerifiable && + (opened.ino !== expected.ino || current.ino !== expected.ino)) + ) { + throw new Error('Session transcript parent directory changed.'); + } +} + +async function openDurableDirectory( + directory: string, + openedHandle?: fs.promises.FileHandle, +): Promise { + const handle = + openedHandle ?? + (await fs.promises.open( + directory, + fs.constants.O_RDONLY | + (process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW ?? 0)), + )); + try { + const opened = await handle.stat(); + const expected = { + directory, + handle, + dev: opened.dev, + ino: opened.ino, + inodeVerifiable: hasVerifiableInode(opened.ino), + }; + await assertDurableDirectoryHandle(expected); + return expected; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +} + +async function openDurableDirectoryIfPresent( + directory: string, +): Promise { + let handle: fs.promises.FileHandle; + try { + handle = await fs.promises.open( + directory, + fs.constants.O_RDONLY | + (process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW ?? 0)), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + return openDurableDirectory(directory, handle); +} + +async function syncDurableDirectory( + expected: DurableDirectoryHandle, +): Promise { + await assertDurableDirectoryHandle(expected); + try { + await expected.handle.sync(); + } catch (error) { + if ( + process.platform !== 'win32' || + !['EACCES', 'EINVAL', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) { + throw error; + } + } + await assertDurableDirectoryHandle(expected); +} + function validatedBackupPath(directory: string, name: string): string { if (name.length === 0 || path.basename(name) !== name) { throw new Error(`Invalid file-history backup name: ${name}`); @@ -965,6 +1097,13 @@ export class SessionService { } } + async getSessionTranscriptLocationForLifecycle( + sessionId: string, + ): Promise { + return (await this.resolveMaintainableSessionSnapshot(sessionId, false)) + .location; + } + private async resolveMaintainableSessionSnapshot( sessionId: string, captureIdentity = true, @@ -2386,9 +2525,53 @@ export class SessionService { async removeSessionTranscriptForLifecycle( sessionId: string, expectedLocation: SessionArchiveState, + expectedParentIdentity: SessionTranscriptParentIdentity, options: RemoveSessionOptions = {}, ): Promise { - return this.removeSessionTranscripts(sessionId, options, expectedLocation); + return this.removeSessionTranscripts( + sessionId, + options, + expectedLocation, + expectedParentIdentity, + ); + } + + async getSessionTranscriptParentIdentityForLifecycle( + expectedLocation: SessionArchiveState, + ): Promise { + const directory = await openDurableDirectory( + this.getChatsDirForState(expectedLocation), + ); + try { + return transcriptParentIdentity(directory); + } finally { + await directory.handle.close().catch(() => undefined); + } + } + + async confirmSessionTranscriptDeletionForLifecycle( + expectedLocation: SessionArchiveState, + expectedParentIdentity: SessionTranscriptParentIdentity, + ): Promise { + let directory: DurableDirectoryHandle | undefined; + try { + directory = await openDurableDirectory( + this.getChatsDirForState(expectedLocation), + ); + if ( + !sameTranscriptParentIdentity( + transcriptParentIdentity(directory), + expectedParentIdentity, + ) + ) { + throw new Error('Session transcript parent directory changed.'); + } + await syncDurableDirectory(directory); + } catch (error) { + throw new SessionTranscriptDurabilityError({ cause: error }); + } finally { + await directory?.handle.close().catch(() => undefined); + } } async cleanupRemovedSessionState( @@ -2398,6 +2581,36 @@ export class SessionService { await this.cleanupRemovedSessionStateInternal(sessionId, options, true); } + async cleanupRemovedSessionStateForLifecycle( + sessionId: string, + options: RemoveSessionOptions = {}, + ): Promise { + const parents: DurableDirectoryHandle[] = []; + try { + for (const directory of [ + this.getChatsDirForState('active'), + this.getChatsDirForState('archived'), + path.join(Storage.getGlobalQwenDir(), FILE_HISTORY_DIR), + this.storage.getProjectDir(), + ]) { + const parent = await openDurableDirectoryIfPresent(directory); + if (parent) parents.push(parent); + } + for (const parent of parents) { + await assertDurableDirectoryHandle(parent); + } + await this.cleanupRemovedSessionStateInternal(sessionId, options, true); + options.assertCanMutate?.(); + for (const parent of parents) { + await syncDurableDirectory(parent); + } + } finally { + await Promise.all( + parents.map((parent) => parent.handle.close().catch(() => undefined)), + ); + } + } + /** * Usage salvage wrapper enforcing the "never blocks deletion" contract at * the call site. Preparation is read-only so rejected lifecycle mutations @@ -2432,11 +2645,14 @@ export class SessionService { sessionId: string, options: RemoveSessionOptions = {}, expectedLocation?: SessionArchiveState, + expectedParentIdentity?: SessionTranscriptParentIdentity, ): Promise { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { return false; } + let durableParent: DurableDirectoryHandle | undefined; + let transcriptRemoved = false; try { const physicalSnapshot = await this.resolveMaintainableSessionSnapshot(sessionId); @@ -2462,18 +2678,49 @@ export class SessionService { preparedSessionIds.add(prepared.record.sessionId); } } + if (expectedLocation !== undefined) { + durableParent = await openDurableDirectory( + this.getChatsDirForState(expectedLocation), + ); + if ( + !expectedParentIdentity || + !sameTranscriptParentIdentity( + transcriptParentIdentity(durableParent), + expectedParentIdentity, + ) + ) { + throw new SessionTranscriptDurabilityError({ + cause: new Error('Session transcript parent directory changed.'), + }); + } + } await options.assertStorageUnchanged?.(); options.assertCanMutate?.(); this.assertMaintainableSessionUnchanged(sessionId, physicalSnapshot); + if (durableParent) { + await assertDurableDirectoryHandle(durableParent); + } for (const identity of physicalSnapshot.identities) { this.removeFileIfExists(identity.filePath); + transcriptRemoved = true; this.commitUsageSalvageBestEffort( preparedUsage.get(identity.filePath) ?? null, ); } + if (durableParent) { + await syncDurableDirectory(durableParent); + } return true; } catch (error) { - if (expectedLocation !== undefined) throw error; + if (expectedLocation !== undefined) { + if ( + transcriptRemoved && + !(error instanceof SessionTranscriptDurabilityError) + ) { + throw new SessionTranscriptDurabilityError({ cause: error }); + } + throw error; + } if ( error instanceof SessionStorageEntryError && error.reason === 'foreign_project' @@ -2484,6 +2731,8 @@ export class SessionService { return false; } throw error; + } finally { + await durableParent?.handle.close().catch(() => undefined); } } @@ -2867,11 +3116,63 @@ export class SessionService { title: string, titleSource: TitleSource = 'manual', archiveState: SessionArchiveState = 'active', + ): Promise { + return this.renameSessionInternal( + sessionId, + title, + titleSource, + archiveState, + ); + } + + async renameSessionForLifecycle( + sessionId: string, + title: string, + titleSource: TitleSource, + expectedLocation: SessionArchiveState, + options: { + assertStorageUnchanged?: () => void | Promise; + assertCanMutate?: () => void; + } = {}, + ): Promise { + return this.renameSessionInternal( + sessionId, + title, + titleSource, + expectedLocation, + options, + ); + } + + private async renameSessionInternal( + sessionId: string, + title: string, + titleSource: TitleSource, + archiveState: SessionArchiveState, + lifecycleOptions?: { + assertStorageUnchanged?: () => void | Promise; + assertCanMutate?: () => void; + }, ): Promise { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { return false; } - const filePath = this.getSessionFilePath(sessionId, archiveState); + const physicalSnapshot = lifecycleOptions + ? await this.resolveMaintainableSessionSnapshot(sessionId) + : undefined; + if (physicalSnapshot && physicalSnapshot.location === undefined) { + return false; + } + if ( + physicalSnapshot && + (physicalSnapshot.location !== archiveState || + physicalSnapshot.identities.length !== 1) + ) { + throw new SessionTranscriptChangedError(); + } + const filePath = + physicalSnapshot?.identities[0]?.filePath ?? + this.getSessionFilePath(sessionId, archiveState); try { // Verify the file exists and belongs to this project @@ -2908,6 +3209,11 @@ export class SessionService { version: records[0].version, systemPayload: { customTitle: title, titleSource }, }; + await lifecycleOptions?.assertStorageUnchanged?.(); + lifecycleOptions?.assertCanMutate?.(); + if (physicalSnapshot) { + this.assertMaintainableSessionUnchanged(sessionId, physicalSnapshot); + } jsonl.writeLineSync(filePath, titleRecord); return true; } catch (error) { From b5a26365a16171a3a4c96f78107cf36356693625 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 27 Aug 2026 07:58:42 +0800 Subject: [PATCH 6/8] codex: address PR review feedback (#10179) Co-authored-by: Qwen-Coder --- .../standalone-session-service.test.ts | 97 +++++++++++++++++++ .../standalone-session-service.ts | 57 ++++++----- 2 files changed, 131 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/serve/conversations/standalone-session-service.test.ts b/packages/cli/src/serve/conversations/standalone-session-service.test.ts index 9573bbbb48b..c685043ac53 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.test.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.test.ts @@ -493,6 +493,33 @@ describe('StandaloneSessionService', () => { expect(harness.bridge.detachClient).toHaveBeenCalledTimes(2); }); + it('retries deletion reconciliation after a transient sweep failure', async () => { + mockActiveStandalone(); + const harness = createHarness(); + harness.deletionJournal.listSessionIds + .mockRejectedValueOnce( + Object.assign(new Error('transient'), { code: 'EIO' }), + ) + .mockResolvedValueOnce([]); + harness.bridge.getSessionSummary.mockReturnValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + sourceType: 'standalone', + clientCount: 1, + hasActivePrompt: false, + }); + + await expect( + harness.service.rename(sessionId, 'First attempt'), + ).rejects.toMatchObject({ code: 'EIO' }); + await expect( + harness.service.rename(sessionId, 'Second attempt'), + ).resolves.toEqual({ sessionId, displayName: 'Second attempt' }); + + expect(harness.deletionJournal.listSessionIds).toHaveBeenCalledTimes(2); + expect(harness.bridge.updateSessionMetadata).toHaveBeenCalledOnce(); + }); + it('fails read-only exact lookup closed while deletion is journaled', async () => { mockActiveStandalone(); vi.spyOn(SessionService.prototype, 'getSessionListItem').mockResolvedValue({ @@ -1765,6 +1792,76 @@ describe('StandaloneSessionService', () => { await expect(harness.service.list()).resolves.toEqual({ sessions: [] }); }); + it('omits a transitioning catalog row without hiding stable rows', async () => { + const stableSessionId = '22222222-2222-4222-8222-222222222222'; + listWorkspaceSessionsForResponse.mockResolvedValueOnce({ + sessions: [ + { + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + clientCount: 0, + hasActivePrompt: false, + }, + { + sessionId: stableSessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T01:00:00.000Z', + sourceType: 'standalone', + clientCount: 0, + hasActivePrompt: false, + }, + ], + }); + vi.spyOn( + SessionService.prototype, + 'findSessionIdIgnoringCase', + ).mockImplementation(async (candidate) => candidate); + vi.spyOn(SessionService.prototype, 'getSessionLocation').mockResolvedValue( + 'active', + ); + vi.spyOn( + SessionService.prototype, + 'readCreationMetadataIfReadable', + ).mockResolvedValue({ sourceType: 'standalone' }); + const harness = createHarness(); + let releaseExclusive!: () => void; + let markExclusiveEntered!: () => void; + const exclusiveEntered = new Promise((resolve) => { + markExclusiveEntered = resolve; + }); + const heldExclusive = harness.lifecycle.runExclusiveAfterShared( + sessionId, + async () => { + markExclusiveEntered(); + await new Promise((resolve) => { + releaseExclusive = resolve; + }); + }, + ); + await exclusiveEntered; + + try { + await expect(harness.service.list()).resolves.toEqual({ + sessions: [ + { + sessionId: stableSessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T01:00:00.000Z', + sourceType: 'standalone', + context: { kind: 'standalone' }, + clientCount: 0, + hasActivePrompt: false, + }, + ], + }); + } finally { + releaseExclusive(); + await heldExclusive; + } + }); + it('aborts while a catalog row is waiting for durable verification', async () => { listWorkspaceSessionsForResponse.mockResolvedValueOnce({ sessions: [ diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index 6111fd5e83c..43c12061498 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import { CdWhilePromptActiveError, + SessionArchivingError, SessionNotFoundError, StandaloneSessionSpawnError, } from '@qwen-code/acp-bridge/bridgeErrors'; @@ -601,29 +602,34 @@ export class StandaloneSessionService { const sessions: StandaloneSessionSummary[] = []; for (const summary of byCanonicalId.values()) { options.signal?.throwIfAborted(); - const stable = await this.options.lifecycle.runSharedMany( - [summary.sessionId], - async () => { - options.signal?.throwIfAborted(); - if ( - await this.options.deletionJournal.hasRecord(summary.sessionId) - ) { - return false; - } - options.signal?.throwIfAborted(); - const durable = await this.inspectStoredStandalone( - runtime, - summary.sessionId, - ); - options.signal?.throwIfAborted(); - return ( - durable.kind === 'standalone' && - durable.source.metadata.parentSessionId === undefined && - (durable.location === 'archived') === - (summary.isArchived === true) - ); - }, - ); + let stable = false; + try { + stable = await this.options.lifecycle.runSharedMany( + [summary.sessionId], + async () => { + options.signal?.throwIfAborted(); + if ( + await this.options.deletionJournal.hasRecord(summary.sessionId) + ) { + return false; + } + options.signal?.throwIfAborted(); + const durable = await this.inspectStoredStandalone( + runtime, + summary.sessionId, + ); + options.signal?.throwIfAborted(); + return ( + durable.kind === 'standalone' && + durable.source.metadata.parentSessionId === undefined && + (durable.location === 'archived') === + (summary.isArchived === true) + ); + }, + ); + } catch (error) { + if (!(error instanceof SessionArchivingError)) throw error; + } options.signal?.throwIfAborted(); if (stable) sessions.push(summary); } @@ -969,6 +975,11 @@ export class StandaloneSessionService { } })(); this.reconciliations.set(runtime, pending); + void pending.catch(() => { + if (this.reconciliations.get(runtime) === pending) { + this.reconciliations.delete(runtime); + } + }); return pending; } From 7dd7f52b6d3b922b93231072f126271eb518faea Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 27 Aug 2026 14:10:29 +0800 Subject: [PATCH 7/8] fix(cli): Preserve internal child task lookup Co-authored-by: Qwen-Coder --- .../standalone-session-service.test.ts | 30 ++++++++++++++-- .../standalone-session-service.ts | 27 ++++++++++++-- .../src/serve/live/live-task-service.test.ts | 35 +++++++++++++++++-- .../cli/src/serve/live/live-task-service.ts | 10 ++++-- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/serve/conversations/standalone-session-service.test.ts b/packages/cli/src/serve/conversations/standalone-session-service.test.ts index c685043ac53..685d5355cf0 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.test.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.test.ts @@ -1443,6 +1443,9 @@ describe('StandaloneSessionService', () => { code: 'standalone_session_not_found', sessionId: childSessionId, }); + await expect( + harness.service.getForInternalTask(childSessionId), + ).resolves.toEqual({ sessionId: childSessionId, state: 'creating' }); releaseSpawn(); await creating; @@ -1518,9 +1521,23 @@ describe('StandaloneSessionService', () => { vi.spyOn( SessionService.prototype, 'readCreationMetadataIfReadable', - ).mockResolvedValue({ + ).mockImplementation(async (targetSessionId) => + targetSessionId === sessionId + ? { + sourceType: 'standalone', + parentSessionId: '22222222-2222-4222-8222-222222222222', + } + : { sourceType: 'standalone' }, + ); + vi.spyOn(SessionService.prototype, 'getSessionListItem').mockResolvedValue({ + sessionId, + cwd: root.canonicalRoot, + startTime: '2026-08-24T00:00:00.000Z', + mtime: Date.parse('2026-08-24T01:00:00.000Z'), + prompt: 'child task', + filePath: '/transcripts/session.jsonl', sourceType: 'standalone', - parentSessionId: '22222222-2222-4222-8222-222222222222', + isArchived: false, }); const harness = createHarness(); @@ -1529,6 +1546,15 @@ describe('StandaloneSessionService', () => { sessionId, }); expect(harness.bridge.getSessionSummary).not.toHaveBeenCalled(); + + await expect( + harness.service.getForInternalTask(sessionId), + ).resolves.toMatchObject({ + sessionId, + sourceType: 'standalone', + parentSessionId: '22222222-2222-4222-8222-222222222222', + context: { kind: 'standalone' }, + }); }); it('merges only volatile live state onto authoritative persisted identity', async () => { diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index 43c12061498..f83a7575ce6 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -487,10 +487,24 @@ export class StandaloneSessionService { } } - async get(rawSessionId: string): Promise { + get(rawSessionId: string): Promise { + return this.getWithRequiredScope(rawSessionId, 'top-level'); + } + + getForInternalTask(rawSessionId: string): Promise { + return this.getWithRequiredScope(rawSessionId, 'any'); + } + + private async getWithRequiredScope( + rawSessionId: string, + requiredScope: 'top-level' | 'any', + ): Promise { const { sessionId } = parseRequiredSessionId(rawSessionId); const creating = this.creating.get(sessionId); - if (creating?.scope === 'top-level') { + if ( + creating !== undefined && + (requiredScope === 'any' || creating.scope === 'top-level') + ) { return { sessionId, state: 'creating' }; } if (creating) { @@ -507,6 +521,7 @@ export class StandaloneSessionService { const persisted = await this.readStandaloneSummary( runtime, sessionId, + requiredScope, ); if (await this.options.deletionJournal.hasRecord(sessionId)) { throw serviceError('standalone_session_conflict', sessionId, true); @@ -2811,6 +2826,7 @@ export class StandaloneSessionService { private async readStandaloneSummary( runtime: WorkspaceRuntime, sessionId: string, + requiredScope: 'top-level' | 'any', ): Promise { const durable = await runWithWorkspaceRuntimeStorage(runtime, async () => { const service = createWorkspaceRuntimeSessionService(runtime); @@ -2829,7 +2845,12 @@ export class StandaloneSessionService { if (source?.kind !== 'standalone') { return undefined; } - if (source.metadata.parentSessionId !== undefined) return undefined; + if ( + requiredScope === 'top-level' && + source.metadata.parentSessionId !== undefined + ) { + return undefined; + } const item = await service.getSessionListItem(storageSessionId, location); if (!item) return undefined; let prs: Awaited>; 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 206edbf3428..a5b236c9b46 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -307,7 +307,7 @@ function makeHarness() { async (sessionId: string) => `/conversations/${sessionId}`, ); const standaloneSessionService = { - get: vi.fn(async (targetSessionId: string) => { + getForInternalTask: vi.fn(async (targetSessionId: string) => { const canonicalSessionId = normalizeSessionIdForLookup(targetSessionId); const storageSessionId = [...persistedSessions.keys()].find( (candidate) => @@ -500,7 +500,9 @@ describe('LiveTaskService', () => { }; persistedSessions.set(sessionId, persisted(sessionId)); persistedSessionOwners.set(sessionId, '/project'); - harness.standaloneSessionService.get.mockRejectedValueOnce(makeError()); + harness.standaloneSessionService.getForInternalTask.mockRejectedValueOnce( + makeError(), + ); listWorkspaceSessionsForResponse.mockResolvedValue({ sessions: [summary], }); @@ -1155,6 +1157,35 @@ describe('LiveTaskService', () => { expect(harness.sendPrompt).toHaveBeenCalledOnce(); }); + it('locates and resumes a cold child standalone task', async () => { + const harness = makeHarness(); + const childSessionId = '22222222-2222-4222-8222-222222222222'; + persistedSessions.set(childSessionId, persisted(childSessionId)); + persistedSessionOwners.set(childSessionId, '/conversations'); + sessionSources.set(childSessionId, { + sourceType: 'standalone', + parentSessionId: '11111111-1111-4111-8111-111111111111', + }); + + const result = await harness.service.handle({ + callerSessionId: 'live-root', + name: 'send_message_to_thread', + arguments: { + threadId: childSessionId, + prompt: 'continue this child task', + }, + }); + + expect(result).toEqual({ threadId: childSessionId }); + expect( + harness.standaloneSessionService.getForInternalTask, + ).toHaveBeenCalledWith(childSessionId); + expect( + harness.standaloneSessionService.resumeForInternalTask, + ).toHaveBeenCalledWith(childSessionId); + expect(harness.sendPrompt).toHaveBeenCalledOnce(); + }); + it('reuses the canonical bridge entry for a mixed-case persisted task', async () => { const harness = makeHarness(); const sessionId = '550e8400-e29b-41d4-a716-446655440000'; diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 467b160a4f5..3eff2deef58 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -108,7 +108,10 @@ export interface LiveTaskServiceOptions { ensureConversationRuntime: () => Promise; standaloneSessionService?: Pick< StandaloneSessionService, - 'createWithInitialPrompt' | 'get' | 'list' | 'resumeForInternalTask' + | 'createWithInitialPrompt' + | 'getForInternalTask' + | 'list' + | 'resumeForInternalTask' > & { dispatchPrompt( sessionId: string, @@ -1208,7 +1211,8 @@ export class LiveTaskService { const service = createWorkspaceRuntimeSessionService(runtime); let summary: BridgeSessionSummary; if (runtime.provenance === 'live-conversation') { - const standalone = await this.getStandaloneSessionService().get(threadId); + const standalone = + await this.getStandaloneSessionService().getForInternalTask(threadId); if ('state' in standalone) { throw new Error(`Task is still being created: ${threadId}`); } @@ -1269,7 +1273,7 @@ export class LiveTaskService { exists: runtime.provenance === 'live-conversation' ? await this.getStandaloneSessionService() - .get(threadId) + .getForInternalTask(threadId) .then((summary) => !('state' in summary)) .catch((error) => { if ( From fbd3bf32bd207424e39bb7063728807f873d1668 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 27 Aug 2026 17:28:43 +0800 Subject: [PATCH 8/8] fix(cli): Re-run standalone deletion reconciliation Co-authored-by: Qwen-Coder --- .../standalone-session-service.test.ts | 17 +++++++++++++++++ .../conversations/standalone-session-service.ts | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/conversations/standalone-session-service.test.ts b/packages/cli/src/serve/conversations/standalone-session-service.test.ts index 685d5355cf0..b4f76c36c60 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.test.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.test.ts @@ -520,6 +520,23 @@ describe('StandaloneSessionService', () => { expect(harness.bridge.updateSessionMetadata).toHaveBeenCalledOnce(); }); + it('repeats deletion reconciliation after a successful sweep', async () => { + mockActiveStandalone(); + const harness = createHarness(); + harness.bridge.getSessionSummary.mockReturnValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + sourceType: 'standalone', + clientCount: 1, + hasActivePrompt: false, + }); + + await harness.service.rename(sessionId, 'First name'); + await harness.service.rename(sessionId, 'Second name'); + + expect(harness.deletionJournal.listSessionIds).toHaveBeenCalledTimes(2); + }); + it('fails read-only exact lookup closed while deletion is journaled', async () => { mockActiveStandalone(); vi.spyOn(SessionService.prototype, 'getSessionListItem').mockResolvedValue({ diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index f83a7575ce6..05f71e807c5 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -990,11 +990,12 @@ export class StandaloneSessionService { } })(); this.reconciliations.set(runtime, pending); - void pending.catch(() => { + const clearPending = () => { if (this.reconciliations.get(runtime) === pending) { this.reconciliations.delete(runtime); } - }); + }; + void pending.then(clearPending, clearPending); return pending; }