Tree-Scoped Session Ownership Lock¶
Status: implemented on 2026-09-21. This document records the decision to move
cross-process session ownership from a per-session lock to a single lock per
root session tree. It amends the ownership rules described in
Architecture and the session guarantees in
Sessions; it does not touch the ~/.cookie-agent store
locks covered by Store lock retention.
Problem¶
Ownership is keyed per session today. SessionStore carries
ownership: HashMap<SessionId, StoreOwnership>
(crates/engine/src/session/mod.rs, around lines 216 and 255), and every
session — each root and each delegated child — acquires a lock of its own. A
lock is taken at publication (the fork path around lines 1367-1395, the
buffered-publish path around lines 1446-1478) or at adoption
(begin_write_locked, around line 833).
On unix that lock is <session-dir>/owner.lock. On Windows it is a
<session-id>.owner.lock sidecar placed next to the session directory
(crates/engine/src/ownership.rs, owner_lock_path), because a handle held
inside the directory would block the publication rename. A tree with N children
therefore litters <root>/subagents/ with N sidecars, plus one beside the root.
The per-child locks buy nothing:
- A child never has a different writer than its root. Delegation only creates and resumes children from a process that already owns the parent.
- A tree is loaded, folded, reconciled, and published as a unit.
- Residency and eviction already retain locks for the lifetime of the process, so the per-child locks are never released early anyway.
Decision¶
- One lock per tree. A root tree is guarded by exactly one ownership lock:
<root-dir>/owner.lockon unix, and a<root-id>.owner.locksidecar beside the root directory on Windows. The Windows sidecar stays a sidecar so the temporary-directory rename at publication is not blocked by the open handle. Children have no lock file of any kind. Holding a root's lock guarantees that only its holder writes anywhere under that root's directory, children included. - The ownership map is keyed by root.
SessionStore.ownershipholdsTreeOwnership::{PendingPublish { authority }, Adopting { lock, authority }, Owned { lock, authority }, Foreign}(theStoreOwnershipenum is renamed). There is oneWriteAuthorityper tree, and everyEventLog::open_ownedin the tree is handed that tree's capability, so dropping the tree lock invalidates every log in the tree at once. - Per-session adoption bookkeeping stays. A session is writable only when
its tree is owned by this process and the session was either created in
this process or adopted in it — reconciled by
Engine::reconcile_sessionand committed.WriteOpen::{AlreadyOwned, Adopting}keeps its per-session meaning; the created/adopted sessions are tracked in a per-store set beside the tree map. begin_write(id)resolvesroot = root_of(id)first. If the tree is unowned ittry_acquires the root lock; a foreign or unreadable lock is cached per root and reported asSessionLocked(id)for every session in that tree, exactly as the per-session cache does today. If the tree is already owned there is no lock activity at all. The per-session step follows: a created or adopted session reportsAlreadyOwned(reopening its log as today), and any other session has its log opened for adoption and reportsAdopting. Acquiring the tree lock through a child does not reconcile the root; the root reconciles when it is first written, through its own adoption.- Publication. A new root or a fork of a root acquires the root lock as it
does today — inside the temporary directory before the rename on unix, from
the sidecar path derived from the final directory on Windows. Publishing a
child creates no lock of its own; it requires the tree to be
PendingPublishorOwnedby this process and fails withSessionLockedotherwise. A fork of a delegated session is the one publication that can be the first write in a tree — nothing else in the tree has to be open for it — so it acquires the root lock when the tree is free, the same way an adoption reached through a child does, and fails as foreign-owned when it is not. Its temporary directory is prepared inside the root's directory, so the lock is taken before it is created. is_owned(id)is answered from the tree ofid, and from the per-session record: it is true when the tree is held and settled here andidwas created or adopted here. That keeps its meaning at the call sites that use it as a write gate, while a foreign root answersfalsefor every session in its tree.release_ownershipdrops every tree lock the store holds. Eviction is unchanged: a tree lock is retained for the lifetime of the process even after every session in the tree has been evicted.- Legacy child lock files (
<child>/owner.lockand<child-id>.owner.lock) are ignored. The tree's single load pass removes them best-effort. There is no migration and no compatibility decoding. - Errors are unchanged.
SessionError::SessionLocked,EngineError::SessionOwnedByAnotherProcess, and the server's error mapping keep their current shape; a foreign root simply makes every session in its tree report that error.
Non-goals¶
Cross-tree locks, work-dir-level locks, any change to the artifact or provider-store locks (see Store lock retention), and any change to adoption reconciliation semantics.