Back to Qwen Code

Session writer lease P0a

docs/design/session-writer-lease-p0a.md

0.21.312.0 KB
Original Source

Session writer lease P0a

Problem

A persisted session can currently be loaded by a second Qwen process while the original process is still producing and recording a turn. Both recorders cache the same parent UUID. When they append independently, the JSONL transcript gains two unmarked children from that parent. Resume follows the physical tail and can therefore hide the first process's complete answer.

The production incident had exactly this ordering: the original process recorded a tool result, the daemon fresh-loaded that session, the original process recorded the remaining tool work and final answer, and the daemon later recorded a user message using the earlier tool result as its parent.

Scope

P0a establishes one cross-process writer for each ACP/daemon (runtime base, session ID) and protects the ordinary linear append path involved in this incident. It includes:

  • an atomic owner-token lease with dead-process recovery;
  • an authoritative transcript reload after lease acquisition;
  • owner, file-identity, and byte-length fencing on every JSONL append;
  • turn admission before user, cron, notification, and teammate work starts;
  • reuse of an already-live session inside one daemon;
  • owner-barrier reads for live transcript replay and Desktop history refresh;
  • deterministic ACP/HTTP conflict errors; and
  • lease draining and release on session close and failed initialization.

P0a does not make session switching, rewind, branch/fork, working-directory migration, archive/delete/rename maintenance, or transcript repair transactional. It also does not introduce an initializing registry entry that serializes every same-daemon load/resume against close; a repeated load reuses the owner after that owner is registered, while the cross-process lease still rejects a second writer during initialization. Full load/close outcome coalescing belongs to P0b. Session switching and persistence-root migration fail closed while an ACP Config owns a lease. ACP's logical working-directory change remains supported because it keeps the recorder and SessionService bound to the original persistence root. Same-owner rewind loads through that Config-pinned SessionService under the recorder write barrier; rename and branch retain their existing recorder or flush-before-copy paths. Daemon archive/delete and maintenance of non-live sessions retain their existing semantics. Concurrent maintenance from outside the live owner remains unsupported and is part of the P0b boundary. Interactive and headless CLI recorders retain their existing unleased behavior so /clear, /resume, /branch, and /cd do not regress; they must not write the same session concurrently with an ACP owner until P0b broadens the protocol.

The protocol is gated by experimental.sessionWriterLease and is disabled by default. The effective value is snapshotted from the bootstrap Config when the ACP child starts and remains fixed for every session served by that process; per-session settings reloads cannot change it. Enabling it requires a process restart. The setting affects only ACP/daemon recorders; interactive and headless recorders continue to use the legacy path even when the setting is enabled.

Invariants

  1. At most one cooperating ACP process owns a session writer lease under a runtime base.
  2. A leased ACP recorder is inactive until it owns the lease and has reloaded the transcript while holding it.
  3. Preview data loaded before the lease is never the recorder's authoritative tail.
  4. Every leased ACP append verifies the owner token, hard transcript state, and byte length. Timestamp-only drift is accepted only after stable content verification.
  5. An ownership or transcript-integrity failure permanently rejects later top-level turns in that leased ACP Config.
  6. A daemon never constructs a second writable Config for a session already live in that daemon.
  7. A live entry is removed only after its recorder has drained and released the lease.
  8. Runtime output roots are pinned per Config so the lock and transcript cannot resolve through different async workspace contexts.

Lease protocol

The lock is stored at:

text
<runtime base>/tmp/session-writer-locks/<encoded session id>.lock

Its immutable record contains a random owner token, PID, host, process kind, acquisition time, Qwen version, and (when available) a stable OS process-start identity. Linux uses the kernel boot ID plus the process start ticks, so wall-clock corrections cannot make a live owner appear stale. Darwin normalizes the process-start probe to the C locale and UTC so two processes with different environments compare the same identity. The identity distinguishes PID reuse when the platform exposes it reliably. A foreign-host owner and any state whose safety cannot be proven fail closed.

Acquisition creates a fully written temporary record and links it into the lock name atomically. A valid live owner returns session_writer_conflict. A valid dead local owner can be renamed, rechecked, and reclaimed. Reclaim guards form bounded owner generations so another process can recover if a reclaimer itself crashes. A malformed, symlink, or non-regular lock returns session_writer_unavailable rather than being guessed stale.

The lease snapshots whether the transcript exists, its file identity, security metadata, byte length, and an in-memory incremental SHA-256 state. Existence, length, device/inode, mode, owner/group, and link-count changes fail closed. Birth, change, and modification timestamps are advisory: timestamp-only drift triggers a stable full-content check through one file handle and is accepted only when the digest is unchanged. Extended attributes and ACL entries that do not change the mode are not fingerprinted separately. When such an operation surfaces as timestamp drift, it is accepted after the same content check if all hard state remains unchanged; if the filesystem exposes no observed timestamp difference, the operation is not detected. appendJsonLine applies the same check after opening its append handle, advances a candidate digest with the known bytes, and commits the digest and expected state only after a successful durable append, post-write path verification, and final owner check. New transcript creation uses exclusive creation.

Acquiring an existing transcript performs one O(n) streaming read to establish the digest baseline, using a buffer bounded to 1 MiB; ordinary appends remain incremental. A reconciliation scan requires timestamps to remain stable from its pre-read state through its post-read state and retries timestamp-only instability at most three times. This stability interval is necessary because a sequential digest can match the expected content even when a non-cooperating writer changes an already-read offset behind the read cursor. If timestamps continue changing, the lease returns session_writer_unavailable instead of accepting a potentially torn snapshot.

The incremental digest is a live-process compatibility check, not a persisted proof for certified handoff. A non-cooperating writer can still overwrite an equal-length prefix during an append without leaving a timestamp difference visible at one of this process's state observations. Closing that existing boundary would require an unconditional O(n) post-write scan, make repeated appends quadratic, and is outside P0a.

Activation and close

When the feature gate is enabled, an ACP Config.initialize() acquires the lease before extension, hook, tool, model, or scheduler initialization. While holding the lease it resolves active/archive state, reloads the active transcript when one exists, verifies that the transcript did not change during the reload, replaces any pre-lock preview, and activates the recorder. ACP Configs without the opt-in and all non-ACP Configs continue through the legacy recorder path without acquiring this P0a lease.

Any later initialization failure closes the recorder and releases the lease. Normal shutdown and ACP session close finalize pending metadata, drain the recorder queue, release the owner token, and only then remove the live session entry. Cleanup is identity-checked so a failed older initialization cannot close a newer same-ID entry. Acquisition cleanup uses the lease's single-flight exact-record release; a terminal failure retains the primary lock, later release calls observe the same failure instead of attempting a second rename, and another writer remains fenced until process-exit recovery. A definitive child refusal leaves the session live so close can be retried. Close draining is bounded; a timeout or transport failure has an unknown result, so the bridge terminates the shared ACP channel and its process-owned leases become recoverable as stale. Other sessions on that channel are also reaped by that recovery action.

Error contract

KindJSON-RPCHTTPMeaning
session_writer_conflict-32020409Another live process owns the session.
session_writer_lost-32021409This Config no longer owns its lock.
session_transcript_changed-32022409The JSONL changed outside the expected append sequence.
session_writer_unavailable-32023503Ownership could not be verified safely.

External responses use fixed messages and errorKind; they do not expose PID, host, owner token, lock path, or transcript path.

A symlink or non-regular transcript path with no prior regular-file baseline is session_writer_unavailable. Once a lease has established a regular-file baseline, replacing that path with a symlink or another non-regular file is an external transcript replacement and is classified as session_transcript_changed.

Compatibility and rollout

The protocol only coordinates ACP writers that have the feature enabled. Deployment and rollback must drain old ACP/daemon writer processes before enabling or disabling the setting. Mixed-version or mixed-configuration ACP operation is not safe because a legacy writer ignores the lock. Concurrent interactive or headless access to the same persisted session remains outside P0a and is unsupported until P0b.

The runtime filesystem must support same-directory hard links with atomic no-replace behavior. If that prerequisite is unavailable, acquisition fails closed with session_writer_unavailable.

Existing branched transcripts are not automatically repaired. P0a prevents a new stale-load branch after rollout; repair and explicit branch semantics remain separate work.

Verification

Unit coverage exercises the default-off and explicit-opt-in gates, lock contention, dead-owner and crashed-reclaimer recovery, malformed and non-regular locks, concurrent owner-token release, bounded release prechecks, terminal cleanup failures, truncated and externally changed transcripts, timestamp-only reconciliation, equal-length in-place and atomic replacement, security-metadata changes, UTF-8 byte accounting, recorder activation/fencing/close, authoritative reload, initialization cleanup, runtime-root pinning, turn admission, same-daemon replay reuse, disabled-recording compatibility, legacy interactive recorder behavior, and error sanitization. Darwin coverage also verifies that processes with different time zones derive the same owner identity. PID-reuse handling is implemented but is not claimed as test evidence because process-start probing is platform dependent.

With the feature gate enabled, a real two-process regression recreates the incident timing: process A holds the writer after a tool-result tail, process B is rejected before loading as a writer, A appends its final answer and closes, and B then acquires, reloads that final answer, and appends the next user record with the final answer as its parent.

Desktop coverage verifies that a writer conflict is surfaced to the user instead of silently replacing the requested persisted session with a fresh session. Live history refresh is served through the owner's write barrier and Config-pinned SessionService, including after a logical /cd.