docs/plans/2026-08-25-standalone-pr3-daemon-api.md
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,
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.
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:
origin/main baseline.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.main; no signature
drift changes the implementation order or ownership boundaries below./standalone/sessions REST route family.fileCleanupPending.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.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.RestoreStandaloneSessionOptions; client identity comes from the existing
header parser, not the body.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.{ sessionId, code, message }. A failure for one ID
does not roll back successful operations on other IDs.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:
ConversationRuntimeOwnershipConversationRuntimeManagerConversationWorkspaceStandaloneDeletionJournalStandaloneSessionServiceSessionArchiveCoordinatorcurrentServeFeaturesForRunQwenServe() 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.
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:
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.
Create StandaloneDeletionJournal under the stable ownership namespace:
<stableBaseDir>/conversations/deletions/
delete-<canonical-id>.prepared.json
delete-<canonical-id>.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:
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.
Extend ConversationWorkspace; do not perform path manipulation in routes or
the journal class.
.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.
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:
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.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:
transcript_deletion_failed.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.
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 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:
session_archived.session_busy if an active prompt or cwd-bound background
work prevents relocation; it never cancels work automatically.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.
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.
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.
For each canonical ID under exclusive admission:
removed; a compromised record returns its structured error.prepared, recording the authoritative storage spelling,
active/archive location, and either a matching child or absence.staged evidence.fileCleanupPending and retain the journal on any
failure.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.
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 |
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 |
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.
packages/core/src/services/sessionService.ts and its tests.packages/cli/src/serve/conversations/standalone-deletion-journal.ts and
colocated tests.conversation-workspace.ts and identity tests with staged-path
operations and platform-specific failures.standalone-session-service.ts and its tests with reconciliation,
prompt-less create, detach, repair, metadata/export, lifecycle batches, and
delete.packages/cli/src/serve/routes/standalone-sessions.ts and tests.server.ts; extend error-response.ts without duplicating
status logic.capabilities.ts, server/serve-features.ts, server tests, and
bootstrap tests..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.
Required local checks after implementation:
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.
removeSession() cannot retry sidecar cleanup once the
transcript is absent. Added narrow core commit-point and idempotent cleanup
primitives.createServeFeatures() construction order and ensuring the
tag cannot appear before route registration completes.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.normalizeSessionIdForLookup() for every PR3-valid ID.After these changes, no additional clear scope, compatibility, failure-path, 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.