# Nucleic — File Locks & Parent-Scoped Worktrees (v0) A dedicated, explicit file-lock system for parallel sessions, scoped to each session's **parent worktree/branch**, with all-or-nothing acquisition, a fair waiter queue, and a release that fires on *merge-into-parent* no matter who performed the merge. This replaces the current **implicit** lock — where a session's "lock" is an emergent property of a live `git diff` (`AppStore.gatherActiveWork` over `SessionController.unmergedFiles`) — with authoritative records owned by a `LockManager` actor. It also introduces **nested worktrees** (a session whose parent is another session) and the cascade behavior that keeps the tree consistent as work lands upward. **Status:** design draft. Amends WORKTREE_MANAGER §1 (adds a parent pointer; "one session ↔ one worktree ↔ one branch" still holds) and §4, and RUNTIME §4 (two new tables). Git commands are spelled out so the plumbing is reviewable; the Swift surface (§7) is the stable contract. > **Amended 2026-07-21 — the locking system moves into Carbon and becomes mesh-wide.** See > [CARBON_SHARDING.md](CARBON_SHARDING.md) §17. Everything in this doc — acquisition, the > queue, grant/re-ground, cascade, landing detection — **remains normative**; what changes is > where the state lives (a replicated per-domain **lock ledger**), the actor's home > (`CarbonLockManager` in `Sources/NucleicCore/Carbon/`, run by the domain's **lock > authority**), and release visibility: release is **fenced on Carbon propagation** — no > device observes a lock as released until it holds the landed project files via Carbon > (CARBON_SHARDING §17.4). Carbon (the data storage/integrity layer) is the natural home for > locks, and Carbon + Covalence are now **mandatory features**, so the move carries no > on/off-state concerns. --- ## 0. Problem & motivation ### 0.1 The leak Sessions whose **agent** performs the merge itself (`git merge` from its own shell, outside the app's autoship `MergeQueue`) frequently never release their file locks. Autoship sessions *usually* release, but inconsistently. The result: a stale lock blocks other agents indefinitely until someone hits the manual "Release File Locks" button. ### 0.2 Root cause — there is no real lock Today a "lock" is inferred, not held. `SessionController.unmergedFiles(into:)` computes "files this session changed that aren't yet in `target`," and `gatherActiveWork` treats that set as the session's held locks. A file is "locked" only while that diff keeps reporting it. Release happens two ways, **both brittle**: 1. **Explicit suppression** — `AppStore.forceReleaseLocks` inserts the session into a `forciblyReleased` set. Wired into exactly one place: the `.merged` case of `handleShipUpdate` (autoship only). 2. **Natural emptying** — the `target...HEAD` three-dot diff goes empty when the work lands. The leak follows from three compounding facts: | # | Fact | Consequence | | --- | --- | --- | | 1 | The agent process has a shell and can `git merge` itself | Agent-driven merges never call `forceReleaseLocks` — release depends *entirely* on the diff emptying | | 2 | Merge target + lock computation are **hardcoded to `project.defaultBranch`** (`SessionController.integrate`, `unmergedFiles`, `shipIfCompleted`) | Work that lands in a *different* parent never empties the diff against `defaultBranch`; and **every session shares one lock namespace**, so "separate parents" don't actually isolate | | 3 | `unmergedFiles` unconditionally unions in untracked files + `git diff HEAD` | A stray untracked/dirty file keeps the footprint non-empty → lock pinned forever | ### 0.3 Design decisions (from review, 2026-06-15) | Question | Decision | | --- | --- | | Lock domain (file mutual-exclusion scope)? | **Per root tree** (resolved 2026-06-15, reversing per-immediate-parent). Mutual exclusion spans every session whose lineage flows to the same top-level project branch (`rootRef`) — parent↔child, siblings, cousins all contend; different top-level branches don't. | | Release target / parent pointer? | **Immediate parent.** A session releases when its work lands in its own `parentRef` (a project branch, or a parent session's branch for a nested child). Cascade re-targets `parentRef`; `rootRef` is invariant. | | Do worktree-less sessions lock? | **Yes** (resolved 2026-06-15). A session editing a branch checkout directly still acquires/holds locks; for it, "landed in parent" = committed to its branch. | | How is release triggered when the agent merges itself? | **Both** — release immediately when Nucleic mediates the merge, *and* a git-ancestry poll backstop that detects agent/user merges, keyed on landed content so dirty residue can't pin a lock. | | Mid-work the agent needs a new, contended file. Hold or release? | **Hold edited, queue for delta** — keep locks on files already held, queue only for the new file. All-or-nothing is strict at *initial* acquisition. | | On grant, how is the worktree updated, and what on conflict? | **Merge parent in; pause on conflict** — `git merge ` into the session branch; on conflict keep the lock held and surface a paused/conflict state. | | When a parent merges up / is removed, what happens to its children? | **Re-target to grandparent** — rewrite the child's parent to the grandparent; the child updates against the new parent on its next grant. | | Build scope | **Full nested end-to-end** — plumbing *and* the create-child flow, tree view, cascade UX, orphan handling, iOS parity. | --- ## 1. Invariants 1. **Locks are authoritative and explicit.** A lock is a record in the `LockManager`, not a value derived from a live `git diff`. The diff is used only to *detect* release (§4.4), and only over the specific locked files. 2. **A lock is scoped to the root tree.** `domain = rootRef` — the top-level project branch the session's lineage flows to. Every session in that tree (parent, child, sibling, cousin) contends for a file: at most one may hold/edit it at once — if a file is being edited it is locked. Two sessions under *different* top-level branches never contend. A session still *releases* against its own immediate `parentRef` (§4.4). 3. **All-or-nothing at acquisition.** A session with no locks yet acquires its full requested set atomically or nothing — it never holds a subset while blocked. (Mid-work expansion is the one bounded exception, §4.2.) 4. **Release only on merge-into-parent.** A lock releases when its file's content has landed in the parent (mediated or detected), or on explicit/lifecycle release — **never on a bare commit** inside the worktree. 5. **One session ↔ one worktree ↔ one branch ↔ one parent.** Extends WORKTREE_MANAGER §1 with an explicit parent pointer. The parent may be a project branch or another session's branch. 6. **Survivable.** Lock records and waiter queues persist (§6) and are reconciled against `git` + session state on launch; a crash never strands a lock or silently reorders a queue. 7. **Fail toward progress, not deadlock.** A blocked next-up waiter yields its place (§4.3); a gone/terminal holder or waiter is reaped; granting is gated by *readiness*, never by a stuck predecessor. 8. **Mesh-consistent release (2026-07-21).** A release is *published* once — by the domain's lock authority, when the work has landed — but *observed* per device: a device treats a lock as released only when it holds the landed project files via Carbon (manifests + shards, packs applied — CARBON_SHARDING §17.4). The landing host observes its own release immediately; a device that lacks the content keeps seeing the lock held. Never fail open. --- ## 2. Domain model ### 2.1 Lock domains and the worktree tree A **lock domain** is identified by a session's **root ref** — the top-level project branch its whole lineage flows to. Every session in one tree shares that single domain, so file mutual-exclusion spans the entire tree (parent, child, sibling, cousin). A session's *release* target is its own immediate `parentRef` (which varies per node); the *lock domain* is the shared `rootRef` (invariant under cascade). ``` project branch: dev ═══════════════════════ LOCK DOMAIN "dev" (rootRef for the whole tree) ├── session Q parentRef=dev rootRef=dev └── session P parentRef=dev rootRef=dev └── branch nucleic/P ├── child C1 parentRef=nucleic/P rootRef=dev └── child C2 parentRef=nucleic/P rootRef=dev • One domain "dev": Q, P, C1, C2 all contend on a file — only one may edit it at a time • Parent/child: P and C1 DO contend (same tree); holder edits, the other waits (§2.2, §4.5) • Release target: each clears its lock when its file lands in its own parentRef (P→dev, C1/C2→nucleic/P) — though the lock domain is the shared root "dev" • Cascade: P merges into dev & is removed ⇒ C1,C2 re-target parentRef "nucleic/P"→"dev"; rootRef "dev" never changes (§5.2) ``` | Term | Meaning | | --- | --- | | **Parent ref** | The branch a session merges into / **releases against** (also its update-on-grant source). Project branch for a top-level session; parent session's branch for a child. Cascade rewrites it (§5.2). | | **Root ref** | The top-level project branch a lineage flows to. The **lock-domain key**. Invariant under cascade. | | **Lock domain** | All locks + the waiter queue for one `rootRef`. The unit of isolation: one tree = one domain. | | **Member** | Any session in the tree (its `rootRef` matches). **All** members contend on a file. | | **Holder** | The session that currently owns a lock on a given `(domain, path)`. | | **Waiter** | A session suspended in a domain's queue until it can acquire its delta. | | **Delta** | The contended files a mid-work session is still waiting on (it already holds the rest). | ### 2.2 The whole tree contends; parent/child don't deadlock **No two sessions in a tree may edit the same file at once** — if a file is being edited it is locked, period. So `P` and its child `C1` both wanting `auth.swift` contend: whoever holds it edits, the other waits. This is broader than sibling-only coordination, and it is the explicit requirement. The earlier worry was a parent/child *deadlock* — "P can't release until it merges up; C1 can't proceed until P releases." It doesn't occur, because the holder's progress never depends on the waiter: - `P` holds `auth.swift` and releases it when `auth.swift` lands in **P's** parent (`dev`) — i.e. when P ships. P's ability to ship never depends on C1. - Once P ships, P is removed and C1 **re-targets** to `dev` (§5.2); the lock releases, C1 is granted, updates its worktree (now merging `dev`, which carries P's change), and proceeds. So the holder always makes independent progress, the waiter is always eventually granted, and at no instant are both editing the file. Cross-subtree divergence (cousins editing a file in turn from different bases) is still possible and resolves where it belongs — at the merge into the shared root — by the existing conflict flow, **not** by allowing simultaneous edits. --- ## 3. The parent pointer (data-model change) ### 3.1 `Session` gains three fields `Session` (`Sources/NucleicCore/Session.swift`) currently stores its own `branch` and the fork commit `baseSHA`, but **never the parent branch**. Add: ```swift /// The branch this session merges into and **releases its locks against** — its release /// target + update-on-grant source (LOCKING §4.4–4.5). A project branch (e.g. "dev") for a /// top-level session, or a parent session's branch ("nucleic/") for a nested child. /// Cascade rewrites this to the grandparent (§5.2). For a worktree-less session it is the /// branch it edits — "landing in the parent" means committing to that branch. public var parentRef: String? /// The top-level project branch this session's lineage flows to — its **lock domain** /// (LOCKING §2). All sessions sharing a `rootRef` contend on files; different roots don't. /// Invariant under cascade (a child re-targets `parentRef`, never `rootRef`). public var rootRef: String? /// Set when `parentRef` is another session's branch — the parent session's id. Drives the /// dependency tree and cascade (LOCKING §5). nil when the parent is a project branch. public var parentSessionID: SessionID? ``` ### 3.2 `createSession` records the parent In `AppStore.createSession` the base is already resolved (`let resolvedBase = base ?? project.defaultBranch`). Record it as the parent, and detect a session parent: ```swift let parent = liveSession(whoseBranchIs: resolvedBase.value) // a live session ⇒ nested child let session = Session( …, branch: worktree?.branch ?? resolvedBase.value, baseSHA: worktree?.baseSHA, parentRef: resolvedBase.value, // release target (worktree-less: its own branch) rootRef: parent?.rootRef ?? resolvedBase.value, // inherit the tree's root, else this base IS the root parentSessionID: parent?.id, …) ``` *(The detection scan is illustrative; the implementation resolves it without an actor-hop per controller — e.g. a `branch → SessionID` index maintained alongside `controllers`.)* ### 3.3 Repoint merge / unmerged / autoship targets to the parent Three call sites are hardcoded to `project.defaultBranch` and must use the session's `parentRef` (falling back to `defaultBranch` only for legacy rows where it's unset): | Call site | Today | Change | | --- | --- | --- | | `SessionController.integrate` | `into: project.defaultBranch` | `into: GitRef(parentRef) ?? project.defaultBranch` | | `SessionController.unmergedFiles` | `into: project.defaultBranch` | same | | `AppStore.shipIfCompleted` (autoship enqueue) | `target: project.defaultBranch` | `target: parentRef ?? project.defaultBranch` | | `AppStore.shipTargetLabel` (note text) | `project.defaultBranch.value` | `parentRef ?? …` | This repoint fixes the release half of root-cause #2 (§0.2): merge/release now point at each session's real `parentRef`, not a shared default. The *lock-domain* half is handled separately — mutual exclusion is keyed by `rootRef` (§2, §4), so independent trees don't interfere while everything within a tree does. ### 3.4 Worktree-less sessions participate A session created with `useWorktree: false` edits a branch checkout directly (no isolated worktree). It **still acquires and holds locks** — if a file is being edited it must be locked, worktree or not. Its `parentRef` and `rootRef` are the branch it edits (e.g. `dev`), so it joins that tree's lock domain and contends with every other session there. Because its working copy *is* the branch, "landed in the parent" collapses to "committed to the branch": its lock on a file releases once that file is committed (the `landed` check, §4.4, naturally sees the working tree match `HEAD`). Invariant 4 (release-on-merge-not-commit) governs worktree sessions whose branch is distinct from the parent; for a worktree-less session the branch *is* the parent, so a commit is the landing. ### 3.5 Migration `v14-parent-refs` ```sql ALTER TABLE session ADD COLUMN parent_ref TEXT; ALTER TABLE session ADD COLUMN root_ref TEXT; ALTER TABLE session ADD COLUMN parent_session_id TEXT; ``` A one-time backfill sets both refs for existing worktree sessions to `project.default_branch` (they were all top-level under one branch), so they immediately share that root's lock domain and show a parent. A nil `parentRef`/`rootRef` falls back to `defaultBranch` at read time for safety. `SessionRow` / `SessionRow.toSession()` (`Persistence/GRDBMetadataStore.swift`) gain the three columns. --- ## 4. `LockManager` — the dedicated system A new `actor LockManager` (`Sources/NucleicCore/LockManager.swift`) owns all lock state. *(2026-07-21: the actor relocates to `Sources/NucleicCore/Carbon/CarbonLockManager.swift` and runs on the domain's lock authority — CARBON_SHARDING §17.3/§17.7. Its state is backed by the replicated lock ledger; every semantic in this section is unchanged.)* It is decoupled from git/session internals via **injected collaborators** (the same pattern as `MergeQueue.setIntegrator`), so it is unit-testable with fakes: ```swift public actor LockManager { /// Resolve a session's lock context (worktree path, parentRef, and rootRef = lock domain). var context: (@Sendable (SessionID) async -> LockContext?)? /// Update-on-grant: `git merge ` into the session's worktree (LOCKING §4.5). var updateWorktree: (@Sendable (SessionID) async -> UpdateResult)? /// Release detector (batched): which of `paths` have landed in the session's parent? (§4.4) var landed: (@Sendable (_ session: SessionID, _ paths: [String]) async -> Set)? } public struct LockContext: Sendable { let worktree: String; let branch: String; let domain: LockDomain } public struct LockDomain: Hashable, Sendable { let ref: String } // value = parent branch ref public enum UpdateResult: Sendable { case clean; case conflicted([String]); case failed(String) } public enum AcquireOutcome: Sendable { case granted; case cancelled } ``` ### 4.1 State ```swift // Authoritative, persisted (LOCKING §6). private var held: [LockKey: LockRecord] // (domain, path) → holder private var queues: [LockDomain: [Waiter]] // ordered, FIFO with demotion private var heldBySession: [SessionID: Set] // reverse index for fast release struct LockKey: Hashable { let domain: LockDomain; let path: String } struct LockRecord: Sendable { let holder: SessionID; let acquiredAt: Date } struct Waiter { // continuation is transient (not persisted) let session: SessionID; let domain: LockDomain var delta: Set; let enqueuedAt: Date let continuation: CheckedContinuation } ``` ### 4.2 Acquisition — all-or-nothing, hold-edited-queue-for-delta ``` acquire(S, files): D = context(S).domain paths = normalize(files) // ConflictDetector.normalize / pathsOverlap mine = paths ∩ heldBySession[S] // already held — no-op needed = paths − mine blocked = { p ∈ needed : held[(D,p)].holder ∉ {nil, S} } if blocked is empty: for p in needed: held[(D,p)] = (S, now) // grant the whole delta return grant(S) // → §4.5 update-on-grant, then resume .granted else: # something is contended if heldBySession[S] is empty: # FRESH acquisition → strict all-or-nothing enqueue Waiter(S, D, delta: needed) # take NOTHING; hold no locks while blocked else: # MID-WORK expansion → hold edited, queue delta enqueue Waiter(S, D, delta: blocked) # keep `mine`; queue only the contended files suspend on continuation # resumed by poll() (§4.3) when delta frees ``` Two properties this guarantees: - **No "hold A while blocked on B" at first acquisition** (invariant 3). A fresh session takes zero locks until its *entire* set is free. - **Mid-work never loses committed progress.** Once a session holds locks (has started editing), a newly-needed contended file only *queues* — the session keeps what it has. This is the bounded exception the user chose, and it can't reintroduce the fresh-acquisition deadlock because the held set is monotonic and already "earned." Overlap uses the existing `ConflictDetector.pathsOverlap` (component-wise, so `src` ≠ `src2/x`) and `normalize` (`Sources/NucleicCore/Conflict.swift`), preserving directory-prefix semantics. Intent-only overlap (no files given) is handled as today, surfaced as a soft prompt rather than a hard lock. ### 4.3 Queue & demotion `poll(D)` runs whenever a lock in `D` releases and on a low-frequency timer backstop: ``` poll(D): reserved = {} # files granted earlier in THIS pass for w in queues[D] (in order): free = { p ∈ w.delta : held[(D,p)].holder ∈ {nil} and p ∉ reserved } if free == w.delta: # readiness, not position, gates the grant for p in w.delta: held[(D,p)] = (w.session, now); reserved ∪= w.delta remove w; grant(w.session) # → §4.5, resume .granted else if w is the head and still blocked: rotate w back one place # "demote by a place" (user's rule) reap any waiter whose session is terminal/gone → resume .cancelled ``` - **"Demote by a place."** A next-up waiter that can't get its whole delta moves back one slot so the following waiter becomes next-up. Over successive passes a perpetually-blocked head keeps yielding. - **No starvation.** Because a grant is gated by *readiness* (its delta is free) and not by queue position, a ready waiter behind a blocked one is granted in the same pass. Demotion exists to keep FIFO fairness *among waiters competing for the same contended file* from degrading into a stuck head — it is not the thing that prevents starvation; readiness is. - **Per-pass reservation** (`reserved`) stops two waiters from both being granted the same file in one pass — the analog of today's `aheadFootprints`. The poll timer replaces today's 2-second `waiterPollInterval`; with release now event-driven (a release calls `poll(D)` directly), the timer is only a crash/skew backstop and can be slower (e.g. 5 s). ### 4.4 Release — mediated + interceptor event + git-ancestry poll > **Scope (2026-06-17): locking applies ONLY to Nucleic Control projects.** `AppStore.lockDomain` > returns `nil` for any non-control project, so its sessions never acquire a lock (`arbitrate` > fails open) and none are reconstructed at launch. Rationale: a lock must release when its work > *lands* (a `git merge`), and the only place Nucleic observes that merge **with certainty** is > inside the control container, via the `git` interceptor shim ([[git-event]] / RUNTIME). Outside > it we could not reliably unlock, so we don't lock at all rather than strand files. A lock releases when its file's work has **landed in the parent**, by any path: 1. **Mediated (immediate).** When Nucleic performs the merge, release on success. In `AppStore.handleShipUpdate` the `.merged` case calls `lockManager.releaseAll(session)` instead of `forceReleaseLocks` — **after** `integrate` returns `.clean` (so a lock is never released before the merge fully completes). A future Nucleic-initiated manual merge does the same. 1b. **Interceptor event (immediate, certain).** When the in-container `git` shim reports a successful landing-capable op (merge/rebase/reset/commit/cherry-pick/revert/restore), `AppStore.observeGitOp` kicks `reconcileLocks` *immediately* — the agent's own `git merge` lands its work without Nucleic mediating, and the shim is the certain signal the poll only approximated. Reconcile is still content-verified (below), so the event is purely a fast-path trigger — only files that actually match the parent release. An agent-performed merge that fully lands an autoship session's work is also recorded as a ship (`markShippedIfLanded`). 2. **Detected (backstop poll).** For each holder `S`, the poll asks `landed(S, paths)` once over all of S's held paths (batched): ``` landed(S, paths) ≡ `git diff --name-only -- A B C` in S.worktree → the complement (paths it does NOT list) have landed # one process per holder ``` **Content equality is necessary but NOT sufficient (2026-07-27).** "The worktree matches the parent" is *also* how a worktree looks after `git reset --hard`, `git restore`, `git checkout -- ` or `git stash` — the work was discarded or parked, the parent never moved, and nothing landed. Releasing there frees a file the agent may `stash pop` straight back onto. So a landing additionally requires **the parent to have moved**: when a path is first observed diverging while held, `materializedLockWork` records the parent-side blob sha for it, and release requires that sha to have *changed*. Merge, squash and rebase all move it; a discard/park does not. The two consumers bias differently on the leftover case (settled, but the parent never moved): the **poll holds** it — mid-turn a park is routinely popped back — while the **turn-end sweep reaps** it, since the turn is over and a discarded edit shouldn't pin a file. **Never act on a tree mid-operation (2026-07-27).** A rebase/merge/cherry-pick/revert in flight checks the target out before replaying, so mid-operation *every* path the session changed transiently matches the parent — a snapshot that would dump the holder's whole lock set on state about to be undone. Both consumers skip a worktree with `rebase-merge`/`rebase-apply`/ `MERGE_HEAD`/`CHERRY_PICK_HEAD`/`REVERT_HEAD` present and retry once it settles. Given those two rules, the check is **per-file and content-based**, which is why it is robust where the old footprint was not: - Works for **merge, squash, and rebase** (content lands regardless of commit ancestry). - Detects **agent-performed and user-performed merges** — the core leak — because it never depended on `forceReleaseLocks` firing. - **Untracked/dirty residue can't pin it:** only the locked `path` is checked, so a stray untracked file elsewhere is irrelevant (root-cause #3, §0.2). - A **bare commit** that hasn't merged still differs from the parent ⇒ lock retained (invariant 4) — *except* a worktree-less session, whose branch **is** the parent, so its commit is the landing and releases the lock (§3.4). *Active* uncommitted edits to a locked file also still differ from the parent ⇒ the session legitimately keeps holding its own in-flight file (this is "active work," not "residue"). **Held paths and git paths are matched by OVERLAP, not equality (2026-07-27).** Acquisition already blocks siblings with `ConflictDetector.pathsOverlap` (directory-aware), but release compared held paths to `git --name-only` output by exact string, and the two speak different vocabularies: - A held path may be a **directory** — `check_conflict` invites "files/areas" — and git never emits a directory, so an area lock was always classified as nothing-to-protect and freed while files under it were unmerged. - Git reports a **submodule** as its gitlink (`sub`), never `sub/foo.txt`, so a lock on a file inside one never matched either. Both directions are fixed by asking "does any unmerged path overlap this held path?". `pathsOverlap` is componentwise, so `src` still never matches `src2/x`. A directory's parent-side blob resolves to its **tree** sha, which moves when anything beneath it moves — so the §4.4.2 parent-moved test works unchanged for areas. The footprint diffs also run **`--no-renames`** (`GitWorktreeManager.unmergedFiles`): with detection on, `--name-only` prints only a rename's *destination*, so a session that edited `Old.swift` and then `git mv`'d it dropped `Old.swift` from the set, and its lock was freed while that deletion was unmerged. Without detection git reports delete + add and both paths stay accounted for. The set is only ever larger, which is the safe direction here. **Known gap — case-only mismatch.** `normalize`/`pathsOverlap` are case-sensitive, so on a case-insensitive filesystem an agent that spells a path `sources/foo.swift` takes a lock that never matches git's `Sources/Foo.swift`. That is a *mutual-exclusion* hole (two sessions can hold "the same" file and not block each other), not a premature release, and fixing it means changing acquisition semantics — tracked separately. 3. **Lifecycle.** An archived or *finished* holder releases everything (`releaseAll`), preserving today's behavior that "a finished/put-away session holds nothing." **Exception — resumable terminals (2026-07-27).** `.error` and `.interrupted` are terminal *statuses* but resumable *chats*: an errored turn (backend crash, API failure, rate-limit abort) leaves the agent's dirty, unmerged edits on disk, and the user retries and keeps working in that same worktree. Releasing there violated invariant 4 outright and handed a sibling a file mid-edit — the reported integrity failure. Such a holder is **not** swept by lifecycle; it falls through to the content-verified landed check (2, above), which frees exactly what has landed and holds the rest. The idle backstop still applies (the same clock as a stale favorite), so a genuinely abandoned errored chat cannot strand a lock forever. A session **mid-transfer** is likewise not "gone" just because its controller was detached — the transfer quiesce commits its work but never merges it upward, so it is skipped too. 4. **Manual.** The "Release File Locks" button calls `releaseAll(openSessionID)` — kept as the escape hatch. 5. **Turn end (`releaseCompletedSessionLocks`).** A fifth path this doc previously omitted. On every classified turn end it frees the held paths with no unmerged divergence — work that landed, plus edit-gate/`check_conflict` declarations the agent never actually wrote (which would otherwise pin a file until relaunch). Content-verified and fail-closed on a git error, exactly like (2). **Ordering + staleness (2026-07-27).** It runs at the *head* of the turn-end task, before the seconds-long disposition classification, for two reasons. It must fire on **every** ending — previously a turn with no assistant prose (the canonical interrupted-mid-tool-call case, the very leak it exists to reap) and the dead-wait continuation both returned before reaching it. And running late made it **stale**: a follow-up turn queued mid-turn starts the instant the previous one ends, and its edit gate acquires a path *before* the CLI writes it (§4.5) — held, not yet divergent, and indistinguishable from a never-written declaration. Reaping that hands a sibling a file mid-write. So the sweep additionally **fails closed while a turn is in flight**: it skips entirely and the next turn's end reaps both turns' declarations. Holding a stale declaration a little longer is recoverable (poll, lifecycle, manual button); releasing a live one is not. Each release calls `poll(D)` so blocked waiters are granted immediately, not on the next tick. **Mesh release fence (2026-07-21).** Each of the four release paths above is the *publication* half of release: the domain's lock authority appends `released(paths, requiredHeads)` to the Carbon lock ledger, where `requiredHeads` names the Carbon heads carrying the landed content — for this (autoship/worktree) flow, the project-base `history` head covering the parent tip that now contains the work, emitted synchronously at the landing site before the release is appended (plus the session-branch `history` head for a nested child whose parent is another session's branch). *Observation* is per-device: a device's sessions see the lock released — and can be granted — only once that device holds those heads (manifests + shards, packs applied, local refs fast-forwarded). The landing host holds them by construction, so local behavior and a mesh of one are unchanged; a remote device that hasn't synced renders `releasing — syncing files` and its acquires queue. Full spec: CARBON_SHARDING §17.4; the nvrsion release path gets the same fence with the trunk `history` head (NVRSION §4). ### 4.5 Update-on-grant — bring parent (and ship target) in; re-ground the agent; pause on conflict `grant(S)` does **not** resume the agent until its worktree reflects the latest of the refs S's work is anchored to — and, because the agent `Read` the files *before* the lock, until the agent is told what moved underneath it. Detection is **decoupled from the merge**: the agent's edits are uncommitted mid-turn (commits are deferred to ship time), so a working-tree `git merge` frequently refuses on a dirty tree, precisely when divergence is highest. So "did a requested file move?" is a commit-to-commit diff (immune to the dirty tree), and the merge itself is best-effort. ``` grant(S, files): # `files` = the edit's locked set moved = {} for ref in dedup([S.parentTarget, S.shipTarget]): # one ref when they coincide (the default) if ref is an ancestor of HEAD: continue # nothing to pull (fast-path) moved ∪= `git diff --name-only preHEAD -- files` # detection: dirty-tree-immune result = updateWorktree(S, ref) ≡ best-effort `git merge ` in S.worktree (local; no fetch) switch result: case .clean: continue # worktree now reflects ref case .conflicted: keep S's locks; mark S .updateConflict (paused); surface; STOP case .dirtyRefusal: leave worktree; proceed on current base (still re-grounded by `moved`) if moved is empty: resume S with .granted # agent's files didn't move else: resume S with .grantedNeedsReground(files: moved, diff: scoped patch) ``` - **Two sources, deduped.** `parentTarget` (= `S.parentRef`, the fork base) is the **primary** source and the anchor for the lock domain, release accounting (§4.4), and the §5.2 cascade — it is how a parent session's in-progress work reaches a nested child. `shipTarget` (= `S.shipDestination`: a per-session `shipBranch`, else the project's `autoShipBranch`, else the fork base) is where S's work — and its siblings' — actually *lands*. When the two **diverge** (an override is set), update-on-grant reconciles against **both**. Otherwise a sibling that shipped its work to the ship branch and then released its lock would be invisible to the next granted session (which pulls only from `parentTarget`), and the staleness would surface only at S's own ship time. Reconciling against the ship target front-loads the same merge `resyncBranch` already performs *after* a ship. With no overrides the two coincide and this stays the single historical `git merge `. - **Order & first-conflict-wins.** `parentTarget` is merged **first** (cascade + lock anchor), then `shipTarget`. The first ref that conflicts pauses S holding its lock; any earlier clean merge stays applied (monotonic progress toward the destination). Re-ground detection (`moved`) accumulates across every reconciled ref. - **Local-only — no fetch.** Every reconciled ref is a *local* branch (fork base / cascade parent / ship target are all checked out and merged into locally, never an `origin/*` ref), and this runs on the grant hot path, so update-on-grant never `git fetch`es: a network round-trip per grant would add latency and a hang risk, and it would be redundant since sibling coordination is entirely local (every session shares this repo and lands work in local branches via `integrate`). A remote that advanced out-of-band is reconciled by the explicit sync path, not here. - **Immediate grants run update-on-grant too.** A session that acquires with *no waiting* (its files were immediately free) still runs this, **guarded by** the `is-ancestor` fast-path (a cheap no-op when a ref hasn't advanced), so a freshly-granted session is never working against a stale parent — whether or not it had to wait. Only an already-held re-acquire short-circuits with no merge. - **Re-ground.** `.grantedNeedsReground` keeps the lock but **blocks the agent's in-flight edit once**: the backend denies it with the scoped diff and a *transient/re-issue* message (not "stop"). The agent re-grounds and re-issues; the lock is held, so the retry re-grants instantly (no merge) and proceeds. This fires **at most once per file per parent content**, enforced by a per-session memory of the parent-side blob sha each re-ground was issued against (`SessionController.regroundedParentBlobs`, mirroring the nvrsion trunk's `observed` map). The held lock alone is NOT sufficient: the §4.4 reconcile loop can release the lock between the re-ground and the agent's re-issue (the denied edit leaves the file matching the parent, so `unmergedFiles` reports it landed), and the retried acquire then re-runs update-on-grant from scratch — with a dirty-refused merge (`HEAD` never advances past the detection diff) the same stateless detection would re-fire forever, livelocking the agent in re-ground/release cycles. The content memory makes the retry `.clean` for already-seen parent content, and re-fires only when the parent moves the file *again*. Detection is scoped to the locked `files`, so an unrelated parent move stays silent; the diff is capped (~8 KB / 5 files), falling back to a re-read instruction. Coverage is the structured edit tools (`Edit`/`Write`/`MultiEdit`/`NotebookEdit`); the real payoff is `Write`/`MultiEdit`, which would otherwise silently clobber the merged-in change. - **Clean** is the common path (the lock guaranteed no *sibling* touched these files; the parent may still have advanced via the parent session or unrelated files). - **Conflict** is surfaced like an autoship conflict (a transcript note + a sidebar "needs attention" affordance, reusing the `autoShipFailed` UX vocabulary) and the session is **paused holding its lock** — it does not proceed and does not release until the human (or a follow-up agent turn) resolves it. This satisfies "once a session receives the lock, update its worktree to capture/propagate changes," with a safe failure mode. ### 4.6 Backward-compatible agent surface The agent-facing contract is unchanged. `AppStore.arbitrate(sessionID:task:files:)` — invoked by the `check_conflict` MCP tool and by edit interception — becomes a thin wrapper: ```swift public func arbitrate(sessionID:task:files:) async -> ConflictResolution { switch await lockManager.acquire(sessionID, files: files) { case .granted: return .proceed case .cancelled: return .cancelled } } ``` The `Defer` / `Cancel` / `Wait for Access` prompt choices map onto: file a to-do / cancel / enter the queue. `lockQueueSnapshot()` reads from `LockManager.snapshot(domain:)` instead of `gatherActiveWork`. **Raw-shell writes acquire through the same seam (2026-07-28; NASH §5.6).** The structured edit tools are no longer the only callers. A Bash command that authors a worktree file (`echo … > f`, `cat > f`, `sed -i`, `tee`, `dd of=`, `truncate`) used to be *denied* by `WorktreeMutationGuard` — it emitted no `fileChange`, so it would otherwise have written a file nobody held. It now names its write targets, resolves them against the run's workdir (following `cd` across the command line), and arbitrates them through `ConflictCoordinator.arbitrateShellWrites` → `arbitrate`. Everything in this section then applies unchanged: a contended path queues the Bash call, `.deferred`/`.cancelled` come back as a tool denial, and a `refreshNotice` blocks the command once for a re-ground (§4.5). Release is likewise unchanged — the write lands in the trunk and the content-verified reconcile (§4.4) frees it. Two boundaries. A target that can't be named *before* the command runs (`patch` reading a diff, a glob, an unexpanded `$VAR`) is still denied, because a lock needs a path. And a write **outside** the worktree isn't a worktree mutation, so no lock is taken — the approval prompt and `HostExecPolicy` remain its gate. What a static parse misses is caught after the fact by nash's `redirect` event (`AppStore.claimShellWrite`), which claims the lock **non-blocking**: the write has already happened, so there is nothing left to gate, and a path another session holds is left alone rather than queued behind. **Codex acquires through a `PreToolUse` hook, not an approval (2026-07-30).** The seam above is reached differently per provider, and Codex had **no** working path to it. Claude routes every tool call through `canUseTool`, so each `Edit`/`Write`/`MultiEdit` and each raw-shell write arbitrates. Codex has no client-side choke point: it edits with its internal `apply_patch` tool (the `*** Update File:` envelope — invoked directly, or as `tools.apply_patch(…)` from inside a code-mode `exec` isolate, which is what the newer models do), and Nucleic only ever saw that as an `item/fileChange/requestApproval`. Codex sends that approval **only when its policy doesn't already allow the write**, and Nucleic runs it `approvalPolicy = on-request` with (inside a control container, where its own bwrap sandbox can't nest) `sandbox = danger-full-access` — so a patch inside the writable roots was auto-approved, the arbitration in `CodexAppServerBackend` never ran, and **every Codex edit landed unlocked**. `codex exec` was worse: `--ask-for-approval never` means no approval exists at all. The damage surfaced later as autoship merge conflicts and clobbered sibling work, exactly as §0.1's leak did. The fix (`CodexToolGate`, `Sources/NucleicCore/Codex/`) uses codex's Claude-Code-compatible **`PreToolUse` hook**, which *is* the analogue of `canUseTool`: codex runs it **synchronously before the tool executes**, it fires for the nested code-mode tools as well as top-level ones, and `permissionDecision: "deny"` blocks the call and hands the model the reason ("Command blocked by PreToolUse hook: …"). `allow`/`ask` are not honored — deny-or-nothing, which is all a lock gate needs. Two shapes arrive and both map onto paths this doc already specifies: | `tool_name` | `tool_input` | arbitrated as | | --- | --- | --- | | `apply_patch` | `{command: "*** Begin Patch…"}` | the envelope's `Add`/`Delete`/`Update File` + `Move to:` paths → `arbitrate` (§4.2), i.e. an `Edit` | | `Bash` | `{command: "echo … > f"}` | `arbitrateShellWrites` (above) — codex normalizes its shell tool to `Bash`, so the Claude/Grok logic applies verbatim | Everything else (`view_image`, `update_plan`, `web_search`, Nucleic's own MCP tools) is allowed without touching the lock system. Mechanics that matter: - **Where the hook lives.** Declared in the container's **root-owned** managed config (`/etc/codex/managed_config.toml`, written by the container seed beside nash's operator policy — NASH §4.3), not in the agent-writable `$HOME/.codex`. Codex reports a managed hook as `trustStatus: "managed"`, so it runs with no trust prompt and no `hooks.state` hash bookkeeping, and it covers **every** codex in the box — app-server, `codex exec`, a nested `codex` the agent runs itself. A gate the gated party can delete is not a gate. - **Blocking is the point.** The hook POSTs to `/pre-tool-use` on the per-container control server and the request stays open while the session sits in the waiter queue (§4.3) — the tool call parks instead of racing, exactly as an `Edit` does. - **A timed-out hook fails OPEN** (codex runs the tool anyway), so the hook answers on its own deadline (840 s) *inside* codex's (900 s), returning a retryable "still queued" denial rather than letting the timeout elapse into an unlocked edit. - **Failure posture, deliberately asymmetric.** Not wired (no gate URL/token — a codex outside a Nucleic Control run) → **allow**: there is no lock domain, and a coordination gap must never block an agent (`ConflictCoordinator` is fail-open by contract). Wired but unanswerable (unreachable, 401 from a retired token, unreadable reply) → **deny**: we know a lock should be taken and can't be, and an unlocked edit is the one outcome that silently corrupts a sibling's work. - **Scope** is locking's own (§4.4): Nucleic Control containers. A host (unsandboxed) codex run starts no control server, so there is no gate endpoint to consult — the same boundary the `git`/`gh` interceptors have. The `requestApproval` arbitration stays as a second gate for a run whose policy *does* ask; double arbitration is free, since a path this session already holds re-grants instantly (§4.2). --- ## 5. Nested worktrees (end-to-end) ### 5.1 Creating a child session A child is created exactly like any session, with the parent's branch as the base: ```swift createSession(in: project, title: …, base: GitRef(parent.session.branch), …) // → parentRef = parent.branch, parentSessionID = parent.id (auto-detected, §3.2) ``` UI entry points: - **Session context menu / dashboard:** "New child session…" on any active session. - **Add / Move flows:** a parent picker in the create sheet (extends the flows touched in `a897474 "Add clone + Nucleic Control to Add Project and Move flows"`), defaulting to a project branch (top-level) but allowing "under ". ### 5.2 Cascade — re-target to grandparent When a parent `P` lands (merges into its own parent `G`) and its worktree is discarded — or `P` is archived/removed — every child `C` with `parentSessionID == P` is re-targeted: ``` onParentResolved(P): G = (P.parentSessionID, P.parentRef) # grandparent (may be a project branch) for C in children(P): # parentSessionID == P C.parentRef = G.ref # new release target + update-on-grant source C.parentSessionID = G.sessionID # nil if G is a project branch lockManager.retarget(C, to: G.ref) # §5.3 — rootRef (lock domain) is unchanged note(C, "parent ‘\(P.title)’ landed — now based on \(G.ref)") ``` If `P` was top-level (`G` is a project branch), children re-target to that branch — the tree collapses toward the root as work lands. Cascade is recursive only in the sense that each level re-targets to *its* grandparent when *its* parent lands; a single merge re-targets one level. ### 5.3 Re-target only moves the release target Because the lock domain is `rootRef` and **`rootRef` is invariant under cascade** (C's root was always the tree's top-level branch, not P's branch), re-target does **not** migrate locks between domains — there is nothing to move. It only rewrites C's *release target*: ``` retarget(C, to: newParentRef): C.parentRef = newParentRef # release condition + update-on-grant source now point here # held locks stay exactly where they are (same rootRef domain, same queue) poll(C.rootRef) # newParentRef may already contain C's work ⇒ release/grant now ``` The only observable change: C now releases a lock when its file lands in the **grandparent** (its new `parentRef`) rather than the gone parent, and its next update-on-grant merges the grandparent in. No file is re-contended and no work is lost, because the domain — and therefore every holder/waiter relationship — is unchanged. (This is simpler than the original per-domain sketch precisely because per-root-tree keeps the whole subtree in one domain.) ### 5.4 Orphan handling Startup reconciliation (extends WORKTREE_MANAGER §8) and live checks: - A child whose `parentSessionID` no longer resolves (parent record gone without a clean cascade — e.g. a crash mid-merge) is re-targeted to the nearest surviving ancestor, or to `project.defaultBranch` if none survive, and flagged in the UI. - A child whose `parentRef` branch no longer exists in `git` is likewise re-pointed to `defaultBranch` and flagged. - The dependency tree view (§8) renders orphans distinctly until resolved. --- ## 6. Persistence — reconstruct held locks from git on launch Lock **ownership** is reconstructable from git: a session holds a lock on each file it has changed but not yet landed in its parent — exactly its `unmergedFiles` (§4.4), in its root-tree domain. So rather than a persisted table, held locks are **rebuilt at launch** from that authoritative state. This is *not* the old ongoing git-derivation (at runtime locks are explicit and authoritative in the `LockManager`); it is a one-time cold-start seed. ### 6.1 Launch reconstruction (extends WORKTREE_MANAGER §8) After sessions load (`AppStore.loadSessions` reconstructs the controllers), `reconstructLocks` runs: 1. For each **live** session (non-terminal, non-archived, worktree-backed), compute its `unmergedFiles` and seed a held lock on each path, keyed by its `rootRef` domain. 2. `LockManager.restore` grants each path to the first claimant and **skips a path already held**, so a stale double-claim can't overwrite a live one. 3. Start the reconcile loop, so any reconstructed lock whose work has since landed releases on the next pass. It is **self-correcting**: already-landed work isn't in `unmergedFiles`, so no stale lock survives a restart — without the launch-time landed-sweep a persisted table would need. ### 6.2 Why queue order isn't persisted A waiter is a *live* `CheckedContinuation` for an agent blocked mid-turn. A restart kills those agent processes, so there's no continuation to resume and no agent waiting — a persisted queue slot would be inert. Queued sessions simply re-acquire (and re-queue) on their next edit; held *ownership* is what matters across a restart, and §6.1 restores it. (A SQLite `file_lock` table could replace reconstruction later if launch-time git cost ever matters; it would still need the same landed-sweep that reconstruction gets for free.) --- ## 7. Swift surface (the stable contract) *(2026-07-21: home is now `Sources/NucleicCore/Carbon/CarbonLockManager.swift`; the contract below is unchanged, plus ledger-append/fold collaborators and the authority proxy — CARBON_SHARDING §17.5, §17.7.)* ```swift public actor LockManager { // Wiring (set once by AppStore, mirrors MergeQueue.setIntegrator). public func setCollaborators( context: @escaping @Sendable (SessionID) async -> LockContext?, updateWorktree: @escaping @Sendable (SessionID) async -> UpdateResult, landed: @escaping @Sendable (SessionID, [String]) async -> Set) // Acquisition (LOCKING §4.2). Suspends until granted or the caller is gone. public func acquire(_ session: SessionID, files: [String]) async -> AcquireOutcome // Release (LOCKING §4.4). public func release(_ session: SessionID, paths: [String]) // partial public func releaseAll(_ session: SessionID) // mediated / lifecycle / manual // Cascade (LOCKING §5). public func retarget(_ session: SessionID, to newParent: GitRef) async // Maintenance + introspection. public func poll(_ domain: LockDomain) async // also runs on a backstop timer public func snapshot(domain: LockDomain?) async -> LockQueueSnapshot // for the UI (§8) } ``` `LockQueueSnapshot` / `FileLock` / `LockParticipant` / `WaitingSession` (`Sources/NucleicCore/Conflict.swift`) are reused unchanged, now sourced from `LockManager` records rather than `gatherActiveWork`, and filterable per domain. ### 7.1 What gets retired | Removed / refactored | Replaced by | | --- | --- | | `AppStore.gatherActiveWork` (as the lock source) | `LockManager.held` records | | `forciblyReleased`, `grantedFootprints`, `conflictWaiters`, `pollWaiters`, `startWaiterPollIfNeeded`, `waiterPollInterval` | `LockManager.queues` + `poll` + persisted state | | `forceReleaseLocks` semantics (footprint suppression) | `LockManager.releaseAll` (true release) | | `handleShipUpdate(.merged)` → `forceReleaseLocks` | → `lockManager.releaseAll` after clean integrate | | `arbitrate` body | thin wrapper over `acquire` (§4.6) | | `lockQueueSnapshot` body | `LockManager.snapshot` | `SessionController.unmergedFiles` survives but is repointed to `parentRef` and is used for the diff/UI and as the basis of `hasLanded`, not as the lock authority. --- ## 8. UI (macOS + iOS) - **Dependency tree view** (dashboard): sessions rendered as a tree by `parentSessionID`, each node showing its domain's lock state (holders / waiters) and ship status. Orphans (§5.4) marked distinctly. - **Lock queue viewer:** the existing holders/waiters view, now per-domain, reading `snapshot(domain:)`. "Blocked by" links resolve within the domain. - **Update-on-grant conflict / pause:** reuse the autoship-conflict surface (transcript note + sidebar "needs attention") for `.updateConflict`; offer "resolve in worktree" or "hand to a new agent turn" (WORKTREE_MANAGER §9.5 conflict-to-agent loop). - **Create-child flow:** parent picker in the new-session sheet + "New child session…" action. - **iOS parity (SYNC_PROTOCOL):** sync `parentRef`, `parentSessionID`, and per-domain lock snapshots so the remote app renders the tree and lock/wait state read-only. New fields are additive to the wire model. --- ## 9. State transitions ### 9.1 A lock's life ``` acquire (delta free) merge into parent (mediated OR detected) (none) ──────────────────▶ held ───────────────────────────────────────────▶ released │ ▲ │ │ delta contended │ poll grants when delta frees │ ▼ │ │ queued ──────────────┘ │ │ demote one place each pass while next-up & blocked │ │ session terminal/archived/gone ─────────────────────────▶ released ◀┘ ``` ### 9.2 Release-trigger truth table | Event | Lock on landed file? | Mechanism | | --- | --- | --- | | Autoship merge succeeds | Released | Mediated (`releaseAll` after `.merged`) | | Agent runs `git merge` itself | Released | Detected (`hasLanded` poll) | | User merges in a terminal | Released | Detected | | Session commits but doesn't merge | **Held** | Still differs from parent | | Session has uncommitted edits to the file | **Held** | Active work differs from parent | | Unrelated untracked file appears | Unaffected | Per-file check ignores it | | Agent `git stash`es / `reset --hard`s / reverts the edit | **Held** by the poll, reaped at turn end | Content matches the parent but the parent never moved — not a landing (§4.4.2) | | Rebase/merge/cherry-pick in progress in the worktree | **Held** | Transient tree — release never acts mid-operation (§4.4.2) | | Lock on a directory/area, unmerged file under it | **Held** | Overlap match, not string equality (§4.4.2) | | Session `git mv`s a file it edited | **Held** (both paths) | `--no-renames`, so the source path stays visible (§4.4.2) | | Session archived / finished | Released (all) | Lifecycle | | Session errored / interrupted (resumable), work unmerged | **Held** | Resumable terminal — content check only (§4.4.3) | | Session errored / interrupted, then idle past the archive clock | Released (all) | Lifecycle backstop | | Session mid-transfer (controller detached) | **Held** | Not gone — quiesce commits, never merges up (§4.4.3) | | "Release File Locks" pressed | Released (all) | Manual | *(2026-07-21: every "Released" above is the release **publication**; a given device additionally observes the release only once it holds the landed content via Carbon — CARBON_SHARDING §17.4.)* --- ## 10. Test matrix Built on the existing hermetic git-temp-repo harness (`AppStoreTests`, `AutoshipMergeTests`, `MergeQueueTests`, `ConflictDetectorTests`). New: `LockManagerTests`, `NestedWorktreeTests`. | # | Scenario | Asserts | | --- | --- | --- | | 1 | Two siblings, disjoint files | Both acquire immediately; no queue | | 2 | Two siblings, same file | Second queues; granted after first's file lands | | 3 | All-or-nothing | Fresh session needing {A,B}, B held → holds **neither** A nor B while queued | | 4 | Hold-edited-queue-for-delta | Session holding {A}, needs contended {B} → keeps A, queues for B only | | 5 | Demotion | Blocked next-up yields; a ready waiter behind it is granted same pass; FIFO kept for the contended file | | 6 | Release on mediated merge | Autoship `.merged` → `releaseAll`; waiter granted | | 7 | **Release on agent merge** (the bug) | Agent `git merge` in worktree → `hasLanded` poll → released; waiter granted | | 8 | No release on bare commit | Commit without merge → lock retained | | 9 | Residue can't pin | Untracked junk present, file landed → released | | 10 | Parent isolation | Sessions in domains "dev" vs "main" editing same path → no contention | | 11 | Update-on-grant clean | Granted waiter's worktree contains parent's latest before it proceeds | | 12 | Update-on-grant conflict | Conflicting parent advance → lock held, session paused, surfaced | | 13 | Cascade re-target | Parent merges → child's `parentRef`/domain rewritten to grandparent | | 14 | Lock migration on re-target | Child's held locks carried to grandparent domain; contended delta queued | | 15 | Persistence/reconcile | Locks + queue survive restart; landed-during-downtime locks reaped; order preserved | | 16 | Orphan | Parent vanished → child re-targeted to nearest ancestor / default, flagged | | 17 | Mesh release fence (2026-07-21) | A remote member without `requiredHeads` still sees the lock held (`releasing — syncing files`); it observes release — and can be granted — only after manifests + shards are held and packs applied (CARBON_SHARDING §17.9) | | 18 | **Codex edit acquires** (2026-07-30) | A `PreToolUse` `apply_patch` call locks the envelope's paths before the patch applies; a contended path parks the tool call and it proceeds once the holder's work lands | | 19 | Codex gate fail-closed | A wired hook whose gate is unreachable / answers 401 denies the call (patch NOT applied, model told to retry); an unwired hook allows | --- ## 11. Rollout / phasing Each phase builds + `swift test` green before the next; commits land on `dev` per the gated flow. | Phase | Scope | Outcome | | --- | --- | --- | | **A — Parent model + target repoint** (§3) | `parentRef`/`parentSessionID`, migration `v14`, repoint integrate/unmerged/autoship to the parent | *By itself* stops most leaks and all cross-parent interference. Low risk, no new subsystem. | | **B — `LockManager`** (§4.1–4.3, §6, §7) | Authoritative locks, all-or-nothing, demotion queue, persistence; retire the implicit machinery; rewire `arbitrate`/`lockQueueSnapshot` | Dedicated lock system; deterministic, testable. | | **C — Release + update-on-grant** (§4.4–4.5) | Mediated `releaseAll`, `hasLanded` poll, merge-parent-in on grant + conflict pause | Closes the agent-merge leak; worktrees refresh on grant. | | **D — Nesting end-to-end** (§5, §8) | Cascade re-target, lock migration, create-child flow, tree view, orphan UX, iOS parity | Full nested capability. | | **E — Carbon-native mesh locking** (2026-07-21; CARBON_SHARDING §17) | Relocate `LockManager` → `CarbonLockManager`; lock ledger stream + authority fencing; the release fence over both the autoship and nvrsion release paths; lock viewer re-sourced from the ledger fold | Locks work across the mesh; release visibility gated on Carbon content propagation. Requires CARBON_SHARDING phases 1–3. | Phase A is independently shippable and is the fastest path to stopping the reported leak; B–D layer the dedicated system and nesting on top; E lifts the finished system into Carbon and across the mesh. --- ## 12. Considerations / open questions 1. **Poll cost.** ✅ Resolved: **batch** per holder — one `git diff --name-only -- A B C` per worktree per tick — and keep the timer a slow backstop (5–10 s) since release is event-driven (`poll(D)` runs on every release). Negligible at dozens of locks. 2. **Squash + identical content edge.** Content-based release also fires if the parent *coincidentally* reaches identical content for a file. That's harmless (the lock isn't needed if the parent already matches), but worth noting it's "content landed," not "this session merged." ✅ Accepted. 3. **Update-on-grant strategy.** Decision was **merge** parent in (preserves the agent's commit history). A future per-project toggle could offer rebase for linear history; out of scope for v0. 🟡 4. **Deep trees / cascade storms.** A tall tree where the root merges could re-target many descendants at once. Re-target is O(children) per level and cheap (metadata + a domain re-check), but the tree view should debounce. 🟡 Validate at depth > 3. 5. **Tree-wide contention.** ✅ Resolved (reverses the earlier carve-out): **no two sessions in a tree ever edit the same file at once** — parent and child contend like any siblings; the lock domain is the whole root tree (§2, §2.2). If a file is being edited it is locked. Cross-subtree divergence (cousins editing a file in turn from different bases) still resolves at the merge into the shared root, never by simultaneous edits. 6. **Intent-only overlap.** Locks are file-based; the existing intent/task-text overlap (`ConflictDetector` with no files) stays a soft advisory prompt, not a hard lock. ✅ 7. **Worktree-less sessions.** ✅ Resolved: they **participate** in locking (§3.4) — a file under edit is always locked, worktree or not. Their `parentRef`/`rootRef` are the branch they edit, so they share that tree's domain; release = commit to the branch. ```