@north-light/crouter 0.3.255 → 0.3.257

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/api/client.d.ts +13 -1
  2. package/dist/api/client.js +17 -0
  3. package/dist/api/dto/worktree.d.ts +17 -0
  4. package/dist/api/routes.d.ts +1 -0
  5. package/dist/api/routes.js +1 -0
  6. package/dist/clients/attach/viewer.js +936 -797
  7. package/dist/commands/api-client.js +4 -0
  8. package/dist/commands/sys/worktrees.d.ts +1 -0
  9. package/dist/commands/sys/worktrees.js +53 -0
  10. package/dist/commands/sys.js +2 -1
  11. package/dist/core/__tests__/integration/worktree-land.test.js +71 -1
  12. package/dist/core/__tests__/integration/worktree-reap.test.js +101 -0
  13. package/dist/core/__tests__/worktree-landing.test.d.ts +1 -0
  14. package/dist/core/__tests__/worktree-landing.test.js +11 -0
  15. package/dist/core/canvas/canvas.js +24 -8
  16. package/dist/core/canvas/pid.d.ts +6 -0
  17. package/dist/core/canvas/pid.js +48 -3
  18. package/dist/core/canvas/types.d.ts +41 -1
  19. package/dist/core/exclusive-lock.d.ts +6 -0
  20. package/dist/core/exclusive-lock.js +12 -0
  21. package/dist/core/git.d.ts +1 -1
  22. package/dist/core/git.js +5 -1
  23. package/dist/core/runtime/fleet.d.ts +6 -0
  24. package/dist/core/worktree-landing.d.ts +44 -0
  25. package/dist/core/worktree-landing.js +56 -0
  26. package/dist/core/worktree-quarantine.d.ts +19 -0
  27. package/dist/core/worktree-quarantine.js +54 -0
  28. package/dist/core/worktree-sweep.d.ts +60 -0
  29. package/dist/core/worktree-sweep.js +493 -0
  30. package/dist/core/worktree.d.ts +37 -0
  31. package/dist/core/worktree.js +149 -23
  32. package/dist/daemon/__tests__/startup-block-marker.test.d.ts +1 -0
  33. package/dist/daemon/__tests__/startup-block-marker.test.js +86 -0
  34. package/dist/daemon/api/handlers/worktree.js +5 -0
  35. package/dist/daemon/crtrd-cli.js +12 -7
  36. package/dist/daemon/crtrd.js +6 -1
  37. package/dist/daemon/fleet.d.ts +1 -0
  38. package/dist/daemon/fleet.js +3 -0
  39. package/dist/daemon/manage.js +22 -1
  40. package/dist/daemon/reconcilers/managed-worktree-sweep.d.ts +24 -0
  41. package/dist/daemon/reconcilers/managed-worktree-sweep.js +190 -0
  42. package/dist/daemon/reconcilers/storage-maintenance.d.ts +11 -2
  43. package/dist/daemon/reconcilers/storage-maintenance.js +7 -2
  44. package/dist/daemon/startup-block-marker.d.ts +25 -0
  45. package/dist/daemon/startup-block-marker.js +99 -0
  46. package/dist/shared/generated-context.js +1 -1
  47. package/package.json +1 -1
  48. package/runtime.lock.json +2 -2
@@ -0,0 +1,493 @@
1
+ // The ONE authority that physically disposes of a managed worktree after its
2
+ // owning node is gone.
3
+ //
4
+ // Close, cancel, crash, and prune do not each grow a cleanup hook: they only
5
+ // change durable state, and this primitive derives everything it does from that
6
+ // state. It is idempotent and restartable, because Git effects and canvas
7
+ // metadata are not one transaction — a crash between any two steps is resumed
8
+ // by the next pass rather than repaired by hand.
9
+ //
10
+ // EVERY Git call here is `gitAsync`. This runs inside crtrd, whose event loop
11
+ // also drives broker supervision and the API socket; a `spawnSync` in this path
12
+ // would stall the whole daemon for the duration of every Git call, and a Git
13
+ // call on a large repository is not short.
14
+ //
15
+ // The synchronous twin of the landing sequence lives in `worktree.ts` (`close`,
16
+ // which runs in an API handler). The judgment both forms share — what is safe
17
+ // to stash, what must be refused — lives once in `worktree-landing.ts`.
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, realpathSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+ import { getNode, updateNode } from './canvas/index.js';
22
+ import { withExclusiveDirectoryLockAsync } from './exclusive-lock.js';
23
+ import { gitAsync } from './git.js';
24
+ import { parseStatusPorcelain, planLandIntoCheckout, refDeleteTransactionStdin } from './worktree-landing.js';
25
+ import { WORKTREE_LOCK_WAIT_MS, checkoutPathForBranchInList, persistClosedWorktreeState, samePath, worktreeLockPathForCommonDir } from './worktree.js';
26
+ /** Escalating recheck interval, in minutes, indexed by consecutive refusal. The
27
+ * last tier is the cap AND the quarantine threshold: at that point the record
28
+ * needs a human, so it is listed by `crtr sys worktrees` and costs one cheap
29
+ * fingerprint read an hour until something about it actually changes. */
30
+ const BACKOFF_MINUTES = [2, 5, 15, 30, 60];
31
+ const QUARANTINE_ATTEMPTS = BACKOFF_MINUTES.length;
32
+ /** A record this sweep still owns: open, or closed without proven cleanup. An
33
+ * abandoned record is deliberately out of scope — abandon already removed the
34
+ * checkout and retains its branch for a person on purpose. */
35
+ export function isReconcilable(wt) {
36
+ if (wt == null || wt.state === 'abandoned')
37
+ return false;
38
+ return wt.state === 'open' || wt.cleanup !== 'complete';
39
+ }
40
+ /** Whether the sweep should examine this record on a pass starting at `now`. A
41
+ * record with no recorded refusal is always due. */
42
+ export function isDueForSweep(wt, now) {
43
+ const next = wt.sweep?.next_attempt;
44
+ if (next === undefined)
45
+ return true;
46
+ const at = Date.parse(next);
47
+ return Number.isNaN(at) || at <= now;
48
+ }
49
+ // Git plumbing, all async
50
+ async function revParseRef(cwd, ref) {
51
+ const res = await gitAsync(['rev-parse', '--verify', '--quiet', ref], cwd);
52
+ return res.status === 0 ? res.stdout.trim() : null;
53
+ }
54
+ function detailOf(res) {
55
+ return (res.stderr.trim() || res.stdout.trim()).trim();
56
+ }
57
+ async function commonGitDirAsync(cwd) {
58
+ const res = await gitAsync(['rev-parse', '--git-common-dir'], cwd);
59
+ if (res.status !== 0)
60
+ return null;
61
+ try {
62
+ return realpathSync(resolve(cwd, res.stdout.trim()));
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ async function isRebaseInProgressAsync(cwd) {
69
+ for (const logical of ['rebase-merge', 'rebase-apply']) {
70
+ const res = await gitAsync(['rev-parse', '--git-path', logical], cwd);
71
+ if (res.status !== 0)
72
+ return null;
73
+ if (existsSync(resolve(cwd, res.stdout.trim())))
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ /** The repository's main checkout, from a `git worktree list --porcelain` blob:
79
+ * Git always lists it first. Every administrative command runs from there, so
80
+ * removing a linked checkout never runs Git from inside the directory it is
81
+ * deleting. */
82
+ function mainWorktreePath(porcelain) {
83
+ for (const line of porcelain.split('\n')) {
84
+ if (line.startsWith('worktree '))
85
+ return line.slice('worktree '.length).trim();
86
+ }
87
+ return null;
88
+ }
89
+ /** Git's own spelling of the recorded checkout when it is registered to the
90
+ * recorded branch, else null. Ownership is a path IDENTITY question, not a
91
+ * string question — `samePath` resolves both spellings — and the answer is
92
+ * Git's canonical path so every later Git call names the checkout the way the
93
+ * repository's administrative metadata does. */
94
+ function registeredCheckoutPath(porcelain, path, branch) {
95
+ let current = null;
96
+ for (const line of porcelain.split('\n')) {
97
+ if (line.startsWith('worktree '))
98
+ current = line.slice('worktree '.length).trim();
99
+ else if (current !== null && line === `branch refs/heads/${branch}` && samePath(current, path))
100
+ return current;
101
+ }
102
+ return null;
103
+ }
104
+ /** Refs that could prove delivery: the recorded local base, and the base's
105
+ * upstream (a branch delivered by push rather than by a local land). */
106
+ async function containmentCandidates(cwd, wt) {
107
+ const candidates = [];
108
+ const localBaseRef = `refs/heads/${wt.base_ref}`;
109
+ const localBase = await revParseRef(cwd, localBaseRef);
110
+ if (localBase !== null)
111
+ candidates.push({ ref: localBaseRef, sha: localBase });
112
+ const upstream = await gitAsync(['rev-parse', '--symbolic-full-name', '--verify', `${wt.base_ref}@{upstream}`], cwd);
113
+ const upstreamRef = upstream.status === 0 && upstream.stdout.trim().startsWith('refs/')
114
+ ? upstream.stdout.trim()
115
+ : `refs/remotes/origin/${wt.base_ref}`;
116
+ const upstreamSha = await revParseRef(cwd, upstreamRef);
117
+ if (upstreamSha !== null)
118
+ candidates.push({ ref: upstreamRef, sha: upstreamSha });
119
+ return candidates;
120
+ }
121
+ /** Does removing this branch lose content the candidate does not already have?
122
+ *
123
+ * The invariant is about CONTENT, not history. A rebase rewrites every SHA, so
124
+ * a fully-landed branch can look diverged; a squash-merge leaves no matching
125
+ * commit at all. An ancestry-only proof would refuse exactly the cases this
126
+ * sweep exists to clean up, so three layers run in increasing cost, and any one
127
+ * of them is sufficient:
128
+ *
129
+ * 1. ancestry — the tip is already reachable from the candidate.
130
+ * 2. patch identity — every commit not in the candidate by SHA has a
131
+ * patch-equivalent commit there (`git cherry`). The rebase case.
132
+ * 3. content no-op — merging the branch in would change nothing. The
133
+ * squash-merge case, and most remaining "looks bad but isn't" states.
134
+ *
135
+ * Only when all three fail does the branch genuinely carry unlanded content.
136
+ * Every layer fails CLOSED: a Git version without `merge-tree --write-tree`, an
137
+ * unreadable object, any non-zero exit — none of them prove anything, so the
138
+ * branch survives. */
139
+ async function proveContained(cwd, wt, branchSha) {
140
+ const candidates = await containmentCandidates(cwd, wt);
141
+ if (candidates.length === 0)
142
+ return null;
143
+ for (const candidate of candidates) {
144
+ if ((await gitAsync(['merge-base', '--is-ancestor', branchSha, candidate.sha], cwd)).status === 0) {
145
+ return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'ancestry' };
146
+ }
147
+ }
148
+ for (const candidate of candidates) {
149
+ const cherry = await gitAsync(['cherry', candidate.sha, branchSha], cwd);
150
+ if (cherry.status !== 0)
151
+ continue;
152
+ const lines = cherry.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
153
+ if (lines.every((line) => line.startsWith('-'))) {
154
+ return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'patch_identity' };
155
+ }
156
+ }
157
+ for (const candidate of candidates) {
158
+ const candidateTree = await revParseRef(cwd, `${candidate.sha}^{tree}`);
159
+ if (candidateTree === null)
160
+ continue;
161
+ const merged = await gitAsync(['merge-tree', '--write-tree', candidate.sha, branchSha], cwd);
162
+ if (merged.status !== 0)
163
+ continue; // conflict, or a Git too old for this flag
164
+ if (merged.stdout.split('\n')[0]?.trim() === candidateTree) {
165
+ return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'content_noop' };
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+ // Change detection
171
+ /** Base tip, branch tip, and checkout state as they are right now. A pass that
172
+ * reads the same three values as the refusal that preceded it can stop there:
173
+ * nothing that decided the refusal has moved. An unreadable checkout records
174
+ * `null`, which never compares equal, so uncertainty always re-proves. */
175
+ async function observe(wt) {
176
+ const present = existsSync(wt.path);
177
+ const cwd = present ? wt.path : wt.repo_root;
178
+ const base = await revParseRef(cwd, `refs/heads/${wt.base_ref}`);
179
+ const branch = await revParseRef(cwd, `refs/heads/${wt.branch}`);
180
+ if (!present)
181
+ return { base, branch, checkout: 'absent' };
182
+ const head = await gitAsync(['rev-parse', 'HEAD'], wt.path);
183
+ const status = await gitAsync(['status', '--porcelain'], wt.path);
184
+ const checkout = head.status !== 0 || status.status !== 0
185
+ ? null
186
+ : createHash('sha256').update(`${head.stdout.trim()}\n${status.stdout}`).digest('hex');
187
+ return { base, branch, checkout };
188
+ }
189
+ function sameObservation(a, b) {
190
+ if (a.checkout === null || b.checkout === null)
191
+ return false;
192
+ return a.base === b.base && a.branch === b.branch && a.checkout === b.checkout;
193
+ }
194
+ // Recording an unfinished pass
195
+ /** Persist why this pass did not finish, with the fingerprint it decided
196
+ * against and an escalated recheck time. This NEVER writes completion and
197
+ * never drops the authoritative record: the checkout, the branch, and the node
198
+ * metadata that proves who owns them all survive a refusal. */
199
+ function recordUnfinished(nodeId, wt, outcome, observed, now, guard) {
200
+ if (outcome.status !== 'refused' && outcome.status !== 'failed')
201
+ return;
202
+ // A node that came back to life owns its own record again: backoff written
203
+ // from a pass it invalidated would sit on the revived node's metadata.
204
+ if (!guard.stillEligible())
205
+ return;
206
+ const attempts = (wt.sweep?.attempts ?? 0) + 1;
207
+ const minutes = BACKOFF_MINUTES[Math.min(attempts, BACKOFF_MINUTES.length) - 1] ?? BACKOFF_MINUTES[0];
208
+ const current = getNode(nodeId)?.managed_worktree;
209
+ if (current == null || !isReconcilable(current))
210
+ return;
211
+ updateNode(nodeId, {
212
+ managed_worktree: {
213
+ ...current,
214
+ sweep: {
215
+ reason: outcome.reason,
216
+ ...(outcome.detail === undefined ? {} : { detail: outcome.detail }),
217
+ observed,
218
+ attempts,
219
+ last_attempt: new Date(now).toISOString(),
220
+ next_attempt: new Date(now + minutes * 60_000).toISOString(),
221
+ ...(attempts >= QUARANTINE_ATTEMPTS ? { quarantined: true } : {}),
222
+ },
223
+ },
224
+ });
225
+ }
226
+ // Landing a blocked close the sweep is allowed to finish
227
+ /** The asynchronous twin of `worktree.ts`'s `landIntoBaseCheckout`. Same
228
+ * sequence, same guarantees: stash only what overlaps, `apply` rather than
229
+ * `pop` so a conflict cannot consume the stash, and never leave a checkout
230
+ * other nodes share in a conflicted state. */
231
+ async function landIntoBaseCheckoutAsync(baseCheckout, branch, baseSha, landedSha, guard) {
232
+ const status = await gitAsync(['status', '--porcelain'], baseCheckout);
233
+ if (status.status !== 0)
234
+ return { ok: false, detail: detailOf(status) };
235
+ const diff = await gitAsync(['diff', '--name-only', baseSha, landedSha], baseCheckout);
236
+ if (diff.status !== 0)
237
+ return { ok: false, detail: detailOf(diff) };
238
+ const incoming = diff.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
239
+ const plan = planLandIntoCheckout(parseStatusPorcelain(status.stdout), incoming);
240
+ if (plan.kind === 'refuse') {
241
+ return { ok: false, detail: `untracked files in ${baseCheckout} would be overwritten by the incoming commits: ${plan.collisions.join(', ')}` };
242
+ }
243
+ // The last recheck before this sequence starts. Once a stash exists the
244
+ // remaining steps MUST run to completion — abandoning a stashed checkout
245
+ // halfway is worse than finishing a land the node no longer needs.
246
+ if (!guard.stillEligible())
247
+ return { ok: false, detail: 'node is no longer terminal', ineligible: true };
248
+ const merge = async () => {
249
+ const res = await gitAsync(['merge', '--ff-only', landedSha], baseCheckout);
250
+ return res.status === 0 ? { ok: true } : { ok: false, detail: detailOf(res) };
251
+ };
252
+ if (plan.kind === 'direct')
253
+ return merge();
254
+ const beforeSha = await revParseRef(baseCheckout, 'refs/stash');
255
+ const push = await gitAsync(['stash', 'push', '--message', `crtr landing ${branch}`], baseCheckout);
256
+ if (push.status !== 0)
257
+ return { ok: false, detail: detailOf(push) };
258
+ const stashSha = await revParseRef(baseCheckout, 'refs/stash');
259
+ if (stashSha === null || stashSha === beforeSha)
260
+ return merge(); // nothing was saved
261
+ const landed = await merge();
262
+ if (!landed.ok) {
263
+ if ((await gitAsync(['stash', 'apply', stashSha], baseCheckout)).status === 0)
264
+ await dropStashIfStillTip(baseCheckout, stashSha);
265
+ return landed;
266
+ }
267
+ if ((await gitAsync(['stash', 'apply', stashSha], baseCheckout)).status === 0) {
268
+ await dropStashIfStillTip(baseCheckout, stashSha);
269
+ return { ok: true };
270
+ }
271
+ await gitAsync(['reset', '--hard', landedSha], baseCheckout);
272
+ return { ok: true, stash: `local changes in ${baseCheckout} conflicted with the landed commits and were PRESERVED as stash ${stashSha}; re-apply them with \`git -C ${baseCheckout} stash apply ${stashSha}\`.` };
273
+ }
274
+ async function dropStashIfStillTip(cwd, stashSha) {
275
+ if ((await revParseRef(cwd, 'refs/stash')) === stashSha)
276
+ await gitAsync(['stash', 'drop'], cwd);
277
+ }
278
+ /** Finish the land this node already asked for and was blocked on. The sweep
279
+ * reaches here ONLY for a record carrying `land_intent`: a node that crashed
280
+ * mid-work and never called close does not get its half-finished commits
281
+ * fast-forwarded onto a shared base branch. */
282
+ async function finishBlockedLand(wt, porcelain, guard) {
283
+ const baseSha = await revParseRef(wt.path, `refs/heads/${wt.base_ref}`);
284
+ if (baseSha === null)
285
+ return { ok: false, detail: `local base branch '${wt.base_ref}' does not resolve` };
286
+ if (!guard.stillEligible())
287
+ return { ok: false, detail: 'node is no longer terminal', ineligible: true };
288
+ const rebase = await gitAsync(['rebase', baseSha], wt.path);
289
+ if (rebase.status !== 0) {
290
+ // A conflicted rebase must never be left in progress: the next pass would
291
+ // read a mid-operation checkout and refuse forever.
292
+ await gitAsync(['rebase', '--abort'], wt.path);
293
+ return { ok: false, detail: detailOf(rebase) };
294
+ }
295
+ const landedSha = await revParseRef(wt.path, 'HEAD');
296
+ if (landedSha === null)
297
+ return { ok: false, detail: 'could not read the rebased worktree tip' };
298
+ if ((await gitAsync(['merge-base', '--is-ancestor', baseSha, landedSha], wt.path)).status !== 0) {
299
+ return { ok: false, detail: `the rebased branch is not a fast-forward of '${wt.base_ref}'` };
300
+ }
301
+ const baseCheckout = checkoutPathForBranchInList(porcelain, wt.base_ref);
302
+ if (baseCheckout === null) {
303
+ if (!guard.stillEligible())
304
+ return { ok: false, detail: 'node is no longer terminal', ineligible: true };
305
+ const update = await gitAsync(['update-ref', `refs/heads/${wt.base_ref}`, landedSha, baseSha], wt.path);
306
+ return update.status === 0 ? { ok: true, landedSha } : { ok: false, detail: detailOf(update) };
307
+ }
308
+ const head = await gitAsync(['rev-parse', 'HEAD'], baseCheckout);
309
+ if (head.status !== 0 || head.stdout.trim() !== baseSha) {
310
+ return { ok: false, detail: `base checkout ${baseCheckout} is not at ${baseSha}` };
311
+ }
312
+ const landed = await landIntoBaseCheckoutAsync(baseCheckout, wt.branch, baseSha, landedSha, guard);
313
+ return landed.ok ? { ok: true, landedSha, ...(landed.stash === undefined ? {} : { stash: landed.stash }) } : landed;
314
+ }
315
+ /** The one outcome for "the node came back" — never a failure, never recorded as
316
+ * a refusal, because nothing about the worktree is wrong. */
317
+ const NODE_REVIVED = { status: 'skipped', reason: 'node_no_longer_terminal' };
318
+ /** Dispose of one node's managed worktree, or explain why it must survive.
319
+ *
320
+ * Ownership is proved from crouter's own node record, never from
321
+ * `git worktree list`: a checkout the user or another tool created has no node
322
+ * record and is never reachable from here. Under the repository lock the
323
+ * recorded path is confirmed REGISTERED to the recorded branch — a path match
324
+ * alone is not ownership — and removal is `git worktree remove <recorded
325
+ * path>` without `--force`. `git worktree prune` is never run: it is
326
+ * repo-wide and would touch administrative entries for worktrees crouter does
327
+ * not own.
328
+ *
329
+ * Effects are ordered checkout → branch → metadata so every interruption is
330
+ * resumable: a crash before removal retries from pending; after removal but
331
+ * before the branch delete, the branch is still an intact ref the next pass
332
+ * finds; after both, the next pass observes both absent and marks complete. */
333
+ export async function reconcileManagedWorktree(nodeId, guard, now = Date.now()) {
334
+ const wt = getNode(nodeId)?.managed_worktree;
335
+ if (!isReconcilable(wt) || wt == null)
336
+ return { status: 'skipped', reason: 'already_reconciled' };
337
+ const cwd = existsSync(wt.path) ? wt.path : wt.repo_root;
338
+ if (!existsSync(cwd)) {
339
+ return finish(nodeId, wt, { status: 'refused', reason: 'repo_unavailable', detail: `neither the managed checkout (${wt.path}) nor its repository root (${wt.repo_root}) is present` }, { base: null, branch: null, checkout: null }, now, guard);
340
+ }
341
+ const commonDir = await commonGitDirAsync(cwd);
342
+ if (commonDir === null) {
343
+ return finish(nodeId, wt, { status: 'failed', reason: 'git_dir_unreadable', detail: `could not resolve the git common dir from ${cwd}` }, { base: null, branch: null, checkout: null }, now, guard);
344
+ }
345
+ let observed = { base: null, branch: null, checkout: null };
346
+ let outcome;
347
+ try {
348
+ outcome = await withExclusiveDirectoryLockAsync(worktreeLockPathForCommonDir(commonDir), async () => {
349
+ if (!guard.stillEligible())
350
+ return NODE_REVIVED;
351
+ const locked = getNode(nodeId)?.managed_worktree;
352
+ if (!isReconcilable(locked) || locked == null)
353
+ return { status: 'skipped', reason: 'already_reconciled' };
354
+ observed = await observe(locked);
355
+ if (locked.sweep !== undefined && sameObservation(locked.sweep.observed, observed)) {
356
+ return { status: 'refused', reason: locked.sweep.reason, ...(locked.sweep.detail === undefined ? {} : { detail: locked.sweep.detail }), unchanged: true };
357
+ }
358
+ return existsSync(locked.path)
359
+ ? await reconcilePresentCheckout(nodeId, locked, guard)
360
+ : await concludeAbsentCheckout(nodeId, locked, observed, guard);
361
+ }, {
362
+ timeoutMs: WORKTREE_LOCK_WAIT_MS,
363
+ timeoutError: () => new Error(`timed out waiting for the managed-worktree lock on ${commonDir}`),
364
+ });
365
+ }
366
+ catch (err) {
367
+ outcome = { status: 'failed', reason: 'lock_unavailable', detail: err instanceof Error ? err.message : String(err) };
368
+ }
369
+ return finish(nodeId, wt, outcome, observed, now, guard);
370
+ }
371
+ function finish(nodeId, wt, outcome, observed, now, guard) {
372
+ recordUnfinished(nodeId, wt, outcome, observed, now, guard);
373
+ return outcome;
374
+ }
375
+ /** Record completion against the record as it stands NOW, never the copy this
376
+ * pass began with: the fresh read is what keeps a concurrently-rewritten record
377
+ * (a revived node's, or one a human edited) from being overwritten with this
378
+ * pass's stale fields. A record that moved out from under the pass is left
379
+ * alone — its checkout and branch are already gone, so the next pass observes
380
+ * both absent and concludes it cleanly. */
381
+ function persistComplete(nodeId, wt, guard) {
382
+ if (!guard.stillEligible())
383
+ return NODE_REVIVED;
384
+ const current = getNode(nodeId)?.managed_worktree;
385
+ if (current == null || current.path !== wt.path || current.branch !== wt.branch) {
386
+ return { status: 'skipped', reason: 'record_changed' };
387
+ }
388
+ persistClosedWorktreeState(nodeId, current, 'complete');
389
+ return { status: 'complete' };
390
+ }
391
+ /** Resume a record whose checkout is already gone — the crash-after-removal
392
+ * case, and the case where the checkout was removed by hand. Filesystem
393
+ * absence proves nothing about commits, so the branch is still proved before
394
+ * it is deleted. */
395
+ async function concludeAbsentCheckout(nodeId, wt, observed, guard) {
396
+ const admin = wt.repo_root;
397
+ if (observed.branch === null) {
398
+ // Distinguish "the ref is gone" from "the read failed"; only the first
399
+ // authorizes concluding this record.
400
+ const exists = await gitAsync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], admin);
401
+ if (exists.status !== 1)
402
+ return { status: 'failed', reason: 'branch_inspection_failed', detail: detailOf(exists) };
403
+ return persistComplete(nodeId, wt, guard);
404
+ }
405
+ const list = await gitAsync(['worktree', 'list', '--porcelain'], admin);
406
+ if (list.status !== 0)
407
+ return { status: 'failed', reason: 'worktree_list_failed', detail: detailOf(list) };
408
+ // The recorded path being absent does NOT mean the branch has no checkout:
409
+ // `git worktree move` relocates one. Deleting a branch checked out elsewhere
410
+ // would bypass Git's own protection and could abandon uncommitted work there.
411
+ const elsewhere = checkoutPathForBranchInList(list.stdout, wt.branch);
412
+ if (elsewhere !== null) {
413
+ return { status: 'refused', reason: 'branch_checked_out_elsewhere', detail: `${wt.branch} is checked out at ${elsewhere}` };
414
+ }
415
+ const proof = await proveContained(admin, wt, observed.branch);
416
+ if (proof === null) {
417
+ return { status: 'refused', reason: 'unlanded_work', detail: `${wt.branch} carries content no base or upstream ref contains; its checkout is already gone, so the branch is the only copy` };
418
+ }
419
+ return deleteBranchAndComplete(nodeId, wt, admin, observed.branch, proof, guard);
420
+ }
421
+ /** The ordinary case: a checkout that still exists and whose owner is gone. */
422
+ async function reconcilePresentCheckout(nodeId, wt, guard) {
423
+ const list = await gitAsync(['worktree', 'list', '--porcelain'], wt.path);
424
+ if (list.status !== 0)
425
+ return { status: 'failed', reason: 'worktree_list_failed', detail: detailOf(list) };
426
+ const registeredPath = registeredCheckoutPath(list.stdout, wt.path, wt.branch);
427
+ if (registeredPath === null) {
428
+ return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${wt.branch}; crtr will not act on a path it cannot prove it owns` };
429
+ }
430
+ const main = mainWorktreePath(list.stdout);
431
+ if (main === null || samePath(main, registeredPath) || !existsSync(main)) {
432
+ return { status: 'refused', reason: 'repo_unavailable', detail: `the repository's main checkout is unavailable, so ${wt.path} cannot be removed from outside itself` };
433
+ }
434
+ const midOperation = await isRebaseInProgressAsync(wt.path);
435
+ if (midOperation === null)
436
+ return { status: 'failed', reason: 'git_path_failed', detail: `could not inspect in-progress operations in ${wt.path}` };
437
+ if (midOperation)
438
+ return { status: 'refused', reason: 'rebase_in_progress', detail: `${wt.path} is mid-rebase` };
439
+ const head = await gitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
440
+ if (head.status !== 0)
441
+ return { status: 'failed', reason: 'branch_check_failed', detail: detailOf(head) };
442
+ if (head.stdout.trim() !== wt.branch) {
443
+ return { status: 'refused', reason: 'wrong_branch', detail: `expected ${wt.branch}, found ${head.stdout.trim() === 'HEAD' ? 'detached HEAD' : head.stdout.trim()}` };
444
+ }
445
+ // Uncommitted work blocks removal unconditionally. No containment proof
446
+ // overrides this: those reason about committed content, and nothing here has
447
+ // any claim on changes a person or a dead node never committed.
448
+ const status = await gitAsync(['status', '--porcelain'], wt.path);
449
+ if (status.status !== 0)
450
+ return { status: 'failed', reason: 'status_failed', detail: detailOf(status) };
451
+ if (status.stdout.trim() !== '') {
452
+ return { status: 'refused', reason: 'dirty_worktree', detail: `${wt.path} has uncommitted changes` };
453
+ }
454
+ let branchSha = await revParseRef(wt.path, 'HEAD');
455
+ if (branchSha === null)
456
+ return { status: 'failed', reason: 'head_unreadable', detail: `could not read HEAD in ${wt.path}` };
457
+ let proof = await proveContained(wt.path, wt, branchSha);
458
+ let stash;
459
+ if (proof === null && wt.land_intent !== undefined) {
460
+ const landed = await finishBlockedLand(wt, list.stdout, guard);
461
+ if (!landed.ok)
462
+ return landed.ineligible === true ? NODE_REVIVED : { status: 'refused', reason: 'land_blocked', detail: landed.detail };
463
+ branchSha = landed.landedSha;
464
+ stash = landed.stash;
465
+ proof = await proveContained(wt.path, wt, branchSha);
466
+ }
467
+ if (proof === null) {
468
+ return { status: 'refused', reason: 'unlanded_work', detail: `${wt.branch} carries commits its base does not contain, and this node never asked to land them` };
469
+ }
470
+ // The last read-only moment. Everything past here destroys something, so the
471
+ // guard answers immediately before each step rather than once at the top.
472
+ if (!guard.stillEligible())
473
+ return NODE_REVIVED;
474
+ const removed = await gitAsync(['worktree', 'remove', registeredPath], main);
475
+ if (removed.status !== 0)
476
+ return { status: 'refused', reason: 'worktree_remove_failed', detail: detailOf(removed) };
477
+ const completed = await deleteBranchAndComplete(nodeId, wt, main, branchSha, proof, guard);
478
+ return completed.status === 'complete' && stash !== undefined ? { status: 'complete', stash } : completed;
479
+ }
480
+ /** Compare-and-delete the branch against the EXACT SHAs the proof inspected,
481
+ * then record completion. A lost race leaves the branch intact, so the work
482
+ * stays reachable — and leaves the record incomplete, so its node metadata is
483
+ * still protected from prune and the next pass retries. */
484
+ async function deleteBranchAndComplete(nodeId, wt, admin, branchSha, proof, guard) {
485
+ if (!guard.stillEligible())
486
+ return NODE_REVIVED;
487
+ const stdin = refDeleteTransactionStdin(wt.branch, branchSha, proof.containingRef, proof.containingSha);
488
+ const transaction = await gitAsync(['update-ref', '--stdin'], admin, stdin);
489
+ if (transaction.status !== 0) {
490
+ return { status: 'refused', reason: 'branch_delete_failed', detail: detailOf(transaction) };
491
+ }
492
+ return persistComplete(nodeId, wt, guard);
493
+ }
@@ -14,6 +14,27 @@ export declare function managedWorktreesRoot(): string;
14
14
  export declare function managedWorktreePath(nodeId: string): string;
15
15
  export declare function hasOpenManagedWorktree(node: NodeMeta | null | undefined): boolean;
16
16
  export declare function openManagedWorktreeForNode(nodeId: string): ManagedWorktree | null;
17
+ /** Shared parse for a `git worktree list --porcelain` blob already in hand —
18
+ * callers that must not throw on a read failure (an absent-path proof that
19
+ * needs to fail closed rather than error) call `gitSync` themselves and pass
20
+ * the output here instead of going through `checkoutPathForBranch`. */
21
+ export declare function checkoutPathForBranchInList(porcelain: string, branch: string): string | null;
22
+ /** Are these two spellings the same directory?
23
+ *
24
+ * Git canonicalizes every path it prints in `worktree list --porcelain`, while
25
+ * a recorded managed-worktree path preserves the spelling of `CRTR_HOME`. When
26
+ * the canvas home is reached through a symlink (macOS's `/var` → `/private/var`
27
+ * is the everyday case) the two forms name one directory but differ as
28
+ * strings, so a raw `===` reads a checkout crtr created as unowned. Resolution
29
+ * failures fall back to `resolve`, which is the pre-canonical comparison — no
30
+ * worse than the string compare it replaces. */
31
+ export declare function samePath(a: string, b: string): boolean;
32
+ export declare const WORKTREE_LOCK_WAIT_MS = 30000;
33
+ /** The lock directory naming one repository's common Git dir. Every process
34
+ * that mutates a managed worktree — the synchronous commands here and the
35
+ * daemon's asynchronous sweep — derives its lock path through this function,
36
+ * so the two locking styles contend for the SAME name. */
37
+ export declare function worktreeLockPathForCommonDir(commonDir: string): string;
17
38
  export declare function rollbackManagedWorktree(wt: ManagedWorktree): void;
18
39
  export declare function createManagedWorktree(cwd: string, nodeId: string, baseRef?: string): ManagedWorktree;
19
40
  export declare function isRebaseInProgress(cwd: string): boolean;
@@ -29,7 +50,23 @@ export interface CloseManagedWorktreeResult {
29
50
  /** Always false while the pending-cleanup checkout has this branch checked out. */
30
51
  branch_deleted: boolean;
31
52
  branch_delete_error?: string;
53
+ /** Present ONLY when landing had to stash local changes in the base checkout
54
+ * and re-applying them conflicted: names the preserved stash and how to
55
+ * re-apply it. Its absence means nothing local was left over. */
56
+ base_checkout_stash?: string;
32
57
  }
58
+ /** The ONE authoritative closed-worktree state transition, shared by every
59
+ * closing path (explicit `closeManagedWorktreeLocked` land, the clean
60
+ * `autoDropCleanManagedWorktreeLocked` push-final drop, and the daemon sweep's
61
+ * `reconcileManagedWorktree`): persist
62
+ * `managed_worktree.state: 'closed'`, name its cleanup state, and repoint the
63
+ * node's launch `cwd` to the durable base checkout. The caller broker stays
64
+ * alive and is Pi's sole transcript writer, so its session header cannot be
65
+ * safely rewritten here; clear the session coordinates and let the later
66
+ * revive launch fresh instead. Both closing paths MUST go through this helper
67
+ * instead of hand-rolling the `updateNode` call, so neither can drift out of
68
+ * sync. */
69
+ export declare function persistClosedWorktreeState(nodeId: string, wt: ManagedWorktree, cleanup?: 'pending' | 'complete'): void;
33
70
  export declare function closeManagedWorktree(nodeId: string): CloseManagedWorktreeResult;
34
71
  export interface AbandonManagedWorktreeResult {
35
72
  node_id: string;