feat(engine,daemon): worktree session leases - #525
Conversation
There was a problem hiding this comment.
Important
One recommended change: forking a session whose managed worktree directory has vanished (orphaned) leases it and starts the child in a nonexistent cwd. Everything else checks out — the lease transaction, the deleting ordering, the migration, and the new turn gate all hold up.
The lease-table design is sound, and the two things I most expected to be wrong aren't. Verified rather than assumed:
- The new per-worktree turn gate cannot deadlock.
Semaphore.makeUnsafe(1)is not reentrant, so I traced all fouradmitTurncall sites: each body persists intent and returns beforelaunchRun/relaunch/startLive/watchTurnrun, so the permit is released before any adapter work, and theturn.submitsaga'sprepared !== undefinedpath skips the dispatcher'sadmitTurnentirely (session-input-dispatcher.ts:148). Nothing awaits another session's turn from inside a permit — the sibling check fails fast withbusy. onConflictDoNothingis correctly targeted at the composite PK, so only the idempotent same-worktree replay is swallowed; a session trying to lease a second worktree still tripsworktree_sessions_session_uniqueand throws. A bareON CONFLICT DO NOTHINGwould have silently defeated that index in SQLite — the explicittargetis load-bearing, and the comment above it is right.
🔍 Migration checks
All four pass, so no action needed — recorded because they're invisible in the diff:
0015_snapshot.jsonprevIdequals0014_snapshot.jsonid, and_journal.json's newwhenis strictly greater than the previous entry (a non-monotonic timestamp re-runs non-idempotent DDL at boot).- Every
WorktreeRecordSchemafield still has a column after theDROP COLUMN. - The
NOT LIKE 'orphan-worktree-%'backfill filter exactly matches the format the deletedorphanSessionIdhelper produced (orphan-worktree-${sha256hex}, confirmed against base27ac29b9), so sentinel rows are deliberately left leaseless. DROP INDEXcorrectly precedesALTER TABLE … DROP COLUMN, and the FK cascadedelete()now relies on really fires —db/database.tssetsforeign_keys = ONon the shared connection.
No wire version move is needed. WorktreeRecordSchema / WorktreeLeaseSchema / WorktreeStateSchema are referenced only by apps/daemon/src/worktree-store.ts and never by a wire payload, so removing sessionId and adding the deleting variant is store-only. Leaving WIRE_PROTOCOL_VERSION at 82 and MIN_COMPATIBLE_WIRE_VERSION at 76 is correct.
⚠️ Boot cleanup is newly destructive — worth a release note
reconcile()'s cleanup branch went from !hasSession && record.state === 'active' to plain !held, so a holder-less record whose directory still exists is now cleaned up regardless of state. Two consequences the diff doesn't make obvious:
scanUnknown-adopted directories — ones LinkCode never created — were previously kept forever (they'reorphaned, so the oldstate === 'active'condition skipped them). They are now removed. BecausescanUnknownruns at the end ofreconcile, adoption and removal land one boot apart, which is what the renamed test encodes.- Post-migration, every pre-existing sentinel orphan row becomes holder-less (correctly excluded from the backfill) and enters the same path on the first boot after upgrade.
I confirmed this can't eat uncommitted work: cleanupRecord gates on inspectWorktreeCleanup (branch matches, status --porcelain --untracked-files=all empty, upstream configured, nothing unpushed) and then runs git worktree remove without --force; any failure or throw marks the record orphaned and preserves the directory. identifyManagedWorktree also refuses to adopt a standalone repo. So the guard is real — but given the PR explicitly defers lease/orphan UI, "LinkCode deletes a clean, fully-pushed worktree it didn't create, on boot, with no user opt-in" is behavior worth surfacing in the release notes rather than discovering.
✅ Verification
pnpm vitest run over engine-worktree.test.ts, worktree-service.test.ts, and both new worktree-store.test.ts files: 34 passed / 4 files. I did not run the full typecheck/lint gates (slow; CI covers them).
Technical details — why the record `cwd` matters for the fork finding
The fork gap depends on a fact that isn't visible in this diff: a managed-worktree session's SessionRecord.cwd is the worktree path, not the original repo root.
lifecycle-service.ts:
const resolved = yield* worktrees.provision(resolvedIntent, sessionId);
// …
const record: SessionRecord = {
sessionId,
kind: resolved.kind,
cwd: resolved.cwd,and provision returns withoutBranch(options, worktreePath), i.e. cwd = worktreePath.
So in launch, resolveForRecord(source, undefined, childId) resolves { cwd: record.cwd } — the worktree path — and that is what the child adapter is started in. That's exactly right in the normal case (it's how the child shares the tree), and it's precisely why an orphaned source is a problem: the path no longer exists on disk.
abandon() does release the child's lease on every failure path here (Effect.onExit fires on failure and interruption while the record is still provisional, and cleanupDeletedSession runs even if stop errors), so this leaks nothing and pins no worktree — it's an error-surface gap, not corruption.
Claude Opus | 𝕏
| const lease = | ||
| sourceWorktreePath === undefined | ||
| ? Effect.void | ||
| : worktrees.acquire(childId, sourceWorktreePath); |
There was a problem hiding this comment.
acquireLease refuses only a missing row or deleting — not orphaned. An orphaned record means the directory is gone but a lease is still held (boot reconcile sets it when !existsSync(worktreePath) and the worktree is held), so this acquire succeeds and the child is then started with cwd = a path that no longer exists, failing with an opaque spawn/git error.
The resume path already has the typed guard for exactly this state:
verifyResume(sessionId) {
const record = this.get(sessionId);
if (!record || existsSync(record.worktreePath)) return Effect.void;
return Effect.fail(new RequestError({ code: 'worktree_missing',
message: `The managed worktree is missing at ${record.worktreePath}. Restore it or delete this session.` }));
}Nothing equivalent runs on the fork path, and since this PR deletes the unsupported guard that previously blocked forking a worktree session outright, this is newly reachable: user deletes the worktree dir → daemon restarts → record marked orphaned, session keeps its lease → fork. Resuming that session gives a clean, actionable error; forking it does not.
The comment above says the captured path makes a source deleted meanwhile fail "typed conflict … instead of starting the child in a directory being torn down" — which is true for deleting, but orphaned is the case where the directory is already gone, and it falls through. Simplest fix is to reject orphaned in acquire/acquireLease alongside deleting, or to call verifyResume(source.sessionId) at admit so the failure carries worktree_missing.
Worth noting the blast radius is contained: abandon() does release the child's lease on this path, so nothing is pinned.
| ); | ||
| } | ||
|
|
||
| private worktreeGate(worktreePath: string): Semaphore.Semaphore { |
There was a problem hiding this comment.
nit: worktreeGates is never pruned, so an entry survives for the daemon's lifetime even after the worktree is removed and its record deleted. Harmless in practice (one small entry per distinct worktree path, and the pre-existing semaphores map in worktree-service.ts has the same shape), so this is consistency-with-existing-practice rather than a leak worth fixing — just flagging it in case cleanupRecord is a natural place to drop the gate too.

Summary
Phase 5 of CODE-627 — Conversation turn graph & immutable attachment store. Linear: https://linear.app/arcbox/issue/CODE-640/featenginedaemon-worktree-session-leases
Stack: #524 ← this PR (
ruocheng/code-640, baseruocheng/code-639) ← top of the stack. Merge bottom-up; this PR's diff is only its own commits.A managed worktree is shared by every session forked from it. Ownership moves from a
session_idcolumn to aworktree_sessionslease table on the shared graph connection (primary key worktree path + session id, unique session id, cascade toworktrees, deliberately no foreign key tosessions);WorktreeRecorddropssessionIdand gains adeletingstate. Releasing the last lease marks the worktreedeletingin the same transaction, before any filesystem work, and a new lease on adeletingor removed worktree is refused — a fork racing the last-lease cleanup fails typedconflictinstead of landing on a half-deleted directory. Cleanup runs only after the last release; boot reconcile sweeps leases whose session is gone, finishesdeletingrows a previous daemon never cleaned, and re-inspects holder-lessorphanedworktrees, removing them once clean and pushed (the old "delete the deleted session again to retry" path has no lease left to find). A fork child leases its source's worktree, captured at admit, before its adapter starts there. At most one leaseholder may have a running turn:SessionOrchestrator.admitTurnruns every turn-start's admit-and-persist under a per-worktree permit and returns typedbusywhile a co-leaseholder is running or holds an open operation;turn.submit, legacyagent.input, prompt rewrite and the automation driver all go through it. Migration 0015 backfills leases fromworktrees.session_id(orphan rows get none) before dropping the column.Commits
Verification
Every commit passed
pnpm check:ciandpnpm testat its own tip; the tip (09f5a40f) is atpnpm check:ci0 errors,pnpm test3474 passed / 1 skipped. Adversarial review pair (engine axis and daemon-migration axis, isolated read-only worktrees): the engine axis found a P1 (prompt rewrite and the automation driver bypassed the worktree gate) and a P2 (a fork after the source's lease release started the child unleased) — both fixed, the P1 with a reproduce-first test; the daemon axis found no P1/P2 and verified the migration against the shipped SQLite 3.53.4 (DROP COLUMNafterDROP INDEX, one transaction, no diff fromdrizzle-kit generate). Fixes were folded into the commits they revise; the record is on CODE-640. Integration tests on a real git repo with a bare remote cover fork plus parent-first delete (the child keeps the worktree, the child's delete cleans once), thedeletingconflict, the one-turn gate forturn.submit,agent.inputand rewrite, and the boot sweep; the daemon store test replays migrations 0000–0014 to prove the backfill and asserts the physical schema. Real development daemon: the running daemon applied 0015 on its real database; one user-approved paid claude turn then forked a live source on a managed worktree (two leases on one path, a distinct claude history under the worktree's project directory), deleting the parent first kept the child's worktree and deleting the child removed it; the webview showed the worktree thread under its project with a branch badge, and the sidebar's Close thread ran the whole lease cleanup. Not exercised live: the co-leaseholderbusymessage (needs a second paid turn; integration-tested). Deferred, recorded on the issue: no client UI for lease state or orphaned worktrees; a legacyagent.inputrefused by the gate repliesrequest.failed busywithout an in-conversation echo.Checklist
pnpm check:ciandpnpm testboth pass (no Rust changes)worktree_sessions, backfilled from the dropped column)