44 KiB
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.
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:
- Explicit suppression —
AppStore.forceReleaseLocksinserts the session into aforciblyReleasedset. Wired into exactly one place: the.mergedcase ofhandleShipUpdate(autoship only). - Natural emptying — the
target...HEADthree-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 <parent> 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
- Locks are authoritative and explicit. A lock is a record in the
LockManager, not a value derived from a livegit diff. The diff is used only to detect release (§4.4), and only over the specific locked files. - 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 immediateparentRef(§4.4). - 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.)
- 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.
- 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.
- 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. - 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.
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:
Pholdsauth.swiftand releases it whenauth.swiftlands 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 mergingdev, 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:
/// 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/<slug>") 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:
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
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. It is
decoupled from git/session internals via injected collaborators (the same pattern as
MergeQueue.setIntegrator), so it is unit-testable with fakes:
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 <parentRef>` 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<String>)?
}
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
// 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<LockKey>] // 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<String>; let enqueuedAt: Date
let continuation: CheckedContinuation<AcquireOutcome, Never>
}
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'saheadFootprints.
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.lockDomainreturnsnilfor any non-control project, so its sessions never acquire a lock (arbitratefails open) and none are reconstructed at launch. Rationale: a lock must release when its work lands (agit merge), and the only place Nucleic observes that merge with certainty is inside the control container, via thegitinterceptor 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:
-
Mediated (immediate). When Nucleic performs the merge, release on success. In
AppStore.handleShipUpdatethe.mergedcase callslockManager.releaseAll(session)instead offorceReleaseLocks— afterintegratereturns.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-containergitshim reports a successful landing-capable op (merge/rebase/reset/commit/cherry-pick/revert/restore),AppStore.observeGitOpkicksreconcileLocksimmediately — the agent's owngit mergelands 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). -
Detected (backstop poll). For each holder
S, the poll askslanded(S, paths)once over all of S's held paths (batched):landed(S, paths) ≡ `git diff --name-only <S.parentRef> -- A B C` in S.worktree → the complement (paths it does NOT list) have landed # one process per holderThe worktree's content for
pathmatches the parent ⇒ the work is reflected upstream ⇒ release. This is per-file and content-based, which is exactly 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
forceReleaseLocksfiring. - Untracked/dirty residue can't pin it: only the locked
pathis 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").
-
Lifecycle. A terminal or archived holder releases everything (
releaseAll), preserving today's behavior that "a finished/put-away session holds nothing." -
Manual. The "Release File Locks" button calls
releaseAll(openSessionID)— kept as the escape hatch.
Each release calls poll(D) so blocked waiters are granted immediately, not on the next tick.
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 <ref> -- files` # detection: dirty-tree-immune
result = updateWorktree(S, ref) ≡ best-effort `git merge <ref>` 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-sessionshipBranch, else the project'sautoShipBranch, 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 fromparentTarget), and the staleness would surface only at S's own ship time. Reconciling against the ship target front-loads the same mergeresyncBranchalready performs after a ship. With no overrides the two coincide and this stays the single historicalgit merge <parentRef>. - Order & first-conflict-wins.
parentTargetis merged first (cascade + lock anchor), thenshipTarget. 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 nevergit fetches: 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 viaintegrate). 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-ancestorfast-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.
.grantedNeedsRegroundkeeps 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 session — sound, because a held lock prevents any same-domain session from advancing that file's parent copy while held. Detection is scoped to the lockedfiles, 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 isWrite/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
autoShipFailedUX 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:
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.
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:
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
parentSessionIDno longer resolves (parent record gone without a clean cascade — e.g. a crash mid-merge) is re-targeted to the nearest surviving ancestor, or toproject.defaultBranchif none survive, and flagged in the UI. - A child whose
parentRefbranch no longer exists ingitis likewise re-pointed todefaultBranchand 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:
- For each live session (non-terminal, non-archived, worktree-backed), compute its
unmergedFilesand seed a held lock on each path, keyed by itsrootRefdomain. LockManager.restoregrants each path to the first claimant and skips a path already held, so a stale double-claim can't overwrite a live one.- 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)
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<String>)
// 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 |
| Session archived / finished / interrupted | Released (all) | Lifecycle |
| "Release File Locks" pressed | Released (all) | Manual |
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 |
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. |
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.
12. Considerations / open questions
- Poll cost. ✅ Resolved: batch per holder — one
git diff --name-only <parent> -- A B Cper 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. - 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.
- 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. 🟡
- 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.
- 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.
- Intent-only overlap. Locks are file-based; the existing intent/task-text overlap
(
ConflictDetectorwith no files) stays a soft advisory prompt, not a hard lock. ✅ - Worktree-less sessions. ✅ Resolved: they participate in locking (§3.4) — a file under
edit is always locked, worktree or not. Their
parentRef/rootRefare the branch they edit, so they share that tree's domain; release = commit to the branch.