@north-light/crouter 0.3.254 → 0.3.256

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 (49) 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.d.ts +2 -2
  47. package/dist/shared/generated-context.js +4 -4
  48. package/package.json +1 -1
  49. package/runtime.lock.json +2 -2
@@ -19,6 +19,7 @@ import { join } from 'node:path';
19
19
  import { CrtrClient, ApiError } from '../api/index.js';
20
20
  import { apiErrorToCliError } from '../core/io.js';
21
21
  import { autostartDisabled, ensureDaemon } from '../daemon/manage.js';
22
+ import { daemonStartupBlocked } from '../daemon/startup-block-marker.js';
22
23
  import { DAEMON_STARTUP_WINDOW_MS } from '../daemon/startup-policy.js';
23
24
  import { readState } from '../core/config.js';
24
25
  import { resolveRemoteCanvasTarget } from '../core/remote-canvas-target.js';
@@ -126,6 +127,9 @@ export function cliClient(opts) {
126
127
  // On a cold-start timeout, enrich the bare `daemon_unavailable` with the
127
128
  // tail of crtrd's event log instead of discarding it.
128
129
  coldStartDiagnostic: readColdStartDiagnostic,
130
+ // A blocked startup is standing: every poll left in the window would report
131
+ // the same thing this already knows, so fail now and carry the repair.
132
+ coldStartAbort: daemonStartupBlocked,
129
133
  coldStartPollWindowMs: DAEMON_STARTUP_WINDOW_MS,
130
134
  });
131
135
  }
@@ -0,0 +1 @@
1
+ export declare const sysWorktreesLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,53 @@
1
+ import { defineLeaf } from '../../core/command.js';
2
+ import { cliClient, rethrowAsCliError } from '../api-client.js';
3
+ function formatSize(bytes) {
4
+ if (bytes === null || !Number.isFinite(bytes) || bytes < 0)
5
+ return 'unmeasurable';
6
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
7
+ let value = bytes;
8
+ let unit = 0;
9
+ while (value >= 1024 && unit < units.length - 1) {
10
+ value /= 1024;
11
+ unit++;
12
+ }
13
+ return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
14
+ }
15
+ export const sysWorktreesLeaf = defineLeaf({
16
+ name: 'worktrees',
17
+ description: 'list managed worktrees quarantined by daemon cleanup',
18
+ whenToUse: 'daemon cleanup has stopped retrying managed worktrees it could not reconcile and you need the recorded refusal, remaining checkout, and disk cost for human intervention',
19
+ help: {
20
+ name: 'sys worktrees',
21
+ summary: 'list managed worktrees the daemon cleanup sweep quarantined',
22
+ params: [],
23
+ output: [
24
+ { name: 'worktrees', type: 'QuarantinedWorktreeDTO[]', required: true, constraint: 'Oldest refusal first. Each record: {node_id, path, branch, repo_root, reason, detail?, attempts, last_attempt, size_bytes}; size_bytes is null when the checkout path is gone or cannot be measured.' },
25
+ ],
26
+ outputKind: 'object',
27
+ effects: ['None. Read-only.'],
28
+ },
29
+ run: async () => {
30
+ try {
31
+ return { worktrees: await cliClient().listQuarantinedWorktrees() };
32
+ }
33
+ catch (err) {
34
+ rethrowAsCliError(err);
35
+ }
36
+ },
37
+ render: (result) => {
38
+ const worktrees = result['worktrees'];
39
+ if (worktrees.length === 0)
40
+ return '0 quarantined managed worktrees.';
41
+ return `${worktrees.length} quarantined managed worktree${worktrees.length === 1 ? '' : 's'}:\n\n${worktrees.map((worktree) => {
42
+ const details = [
43
+ `- \`${worktree.node_id}\` — ${worktree.reason} · ${formatSize(worktree.size_bytes)} · ${worktree.attempts} attempt${worktree.attempts === 1 ? '' : 's'} · ${worktree.last_attempt}`,
44
+ ` - path: \`${worktree.path}\``,
45
+ ` - branch: \`${worktree.branch}\``,
46
+ ` - repo root: \`${worktree.repo_root}\``,
47
+ ];
48
+ if (worktree.detail !== undefined)
49
+ details.push(` - detail: ${worktree.detail}`);
50
+ return details.join('\n');
51
+ }).join('\n')}`;
52
+ },
53
+ });
@@ -5,6 +5,7 @@ import { daemonBranch } from './sys/daemon.js';
5
5
  import { sysDoctorLeaf } from './sys/doctor.js';
6
6
  import { sysFeedbackLeaf } from './sys/feedback.js';
7
7
  import { sysLogsLeaf } from './sys/logs.js';
8
+ import { sysWorktreesLeaf } from './sys/worktrees.js';
8
9
  import { sysSettingsLeaf } from './sys/settings.js';
9
10
  import { sysSetupLeaf } from './sys/setup.js';
10
11
  import { supportBranch } from './sys/support.js';
@@ -25,6 +26,6 @@ export function registerSys() {
25
26
  summary: 'crtr system configuration, settings, diagnostics, and self-management',
26
27
  model: 'Configuration and settings own durable behavior; diagnostics and support observe it. Setup, migration, sync, and update change installed or generated state, while daemon control owns the supervisor lifecycle.',
27
28
  },
28
- children: [sysSettingsLeaf, configBranch, sysSetupLeaf, sysDoctorLeaf, contextBranch, sysFeedbackLeaf, sysLogsLeaf, supportBranch, sysUpdateLeaf, sysVersionLeaf, sysMigrateLeaf, sysSyncBranch, daemonBranch],
29
+ children: [sysSettingsLeaf, configBranch, sysSetupLeaf, sysDoctorLeaf, contextBranch, sysFeedbackLeaf, sysLogsLeaf, sysWorktreesLeaf, supportBranch, sysUpdateLeaf, sysVersionLeaf, sysMigrateLeaf, sysSyncBranch, daemonBranch],
29
30
  });
30
31
  }
@@ -4,12 +4,13 @@
4
4
  // parallel files; they share the fixture shape, not state.
5
5
  import { test, before, after, beforeEach } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { mkdtempSync, mkdirSync, rmSync, existsSync, statSync, writeFileSync, chmodSync } from 'node:fs';
7
+ import { mkdtempSync, mkdirSync, rmSync, existsSync, statSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
8
8
  import { spawn } from 'node:child_process';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { gitSync } from '../../git.js';
12
12
  import { createManagedWorktree, closeManagedWorktree, managedWorktreePath, rollbackManagedWorktree } from '../../worktree.js';
13
+ import { reconcileManagedWorktree } from '../../worktree-sweep.js';
13
14
  import { createNode, getNode } from '../../canvas/canvas.js';
14
15
  import { closeDb } from '../../canvas/db.js';
15
16
  let home;
@@ -35,6 +36,10 @@ function node(id, over = {}) {
35
36
  };
36
37
  }
37
38
  before(() => {
39
+ // NOT realpath'd on purpose: the macOS tmpdir is reached through the
40
+ // `/var` → `/private/var` symlink, so the recorded managed-worktree path and
41
+ // git's canonical `worktree list` output differ as strings while naming one
42
+ // directory. Every path-ownership check must judge identity, not spelling.
38
43
  home = mkdtempSync(join(tmpdir(), 'crtr-worktree-home-'));
39
44
  process.env['CRTR_HOME'] = home;
40
45
  // Bare "remote" wired up as origin so the test can prove landing NEVER pushes
@@ -89,6 +94,71 @@ test('closeManagedWorktree leaves the active caller checkout intact with named d
89
94
  assert.equal(meta?.cwd, wt.repo_root, 'a later revive uses the durable base checkout');
90
95
  assert.equal(meta?.pi_session_id, null, 'the live transcript remains sole-writer-owned, so close invalidates its stale resume identity');
91
96
  assert.equal(meta?.pi_session_file, null, 'a later revive launches fresh rather than loading the header that still names this checkout');
97
+ return reconcileManagedWorktree(nodeId, { stillEligible: () => true }).then((outcome) => {
98
+ assert.equal(outcome.status, 'complete', 'the daemon-owned sweep consumes pending cleanup after the caller exits');
99
+ assert.equal(existsSync(wt.path), false, 'the sweep removes the deferred caller checkout');
100
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 1, 'the sweep deletes the now-contained managed branch');
101
+ const swept = getNode(nodeId)?.managed_worktree;
102
+ assert.deepEqual({ state: swept?.state, cleanup: swept?.cleanup }, { state: 'closed', cleanup: 'complete' });
103
+ });
104
+ });
105
+ test('close lands underneath disjoint tracked changes in the base checkout', () => {
106
+ const nodeId = 'wt-close-disjoint-base-dirt';
107
+ writeFileSync(join(sourceDir, 'base-local.txt'), 'base version\n');
108
+ run(['add', 'base-local.txt'], sourceDir);
109
+ run(['commit', '-m', 'base local file'], sourceDir);
110
+ const wt = createManagedWorktree(sourceDir, nodeId);
111
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
112
+ writeFileSync(join(wt.path, 'incoming-disjoint.txt'), 'child work\n');
113
+ run(['add', 'incoming-disjoint.txt'], wt.path);
114
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'disjoint child work'], wt.path);
115
+ writeFileSync(join(sourceDir, 'base-local.txt'), 'base local edit\n');
116
+ const result = closeManagedWorktree(nodeId);
117
+ assert.equal(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), result.landed_sha, 'the child commit fast-forwards main despite disjoint local dirt');
118
+ assert.equal(readFileSync(join(sourceDir, 'base-local.txt'), 'utf8'), 'base local edit\n', 'the base checkout local edit survives untouched');
119
+ assert.match(gitSync(['status', '--porcelain'], sourceDir).stdout, /M base-local.txt/, 'the base local edit remains uncommitted');
120
+ assert.equal(gitSync(['rev-parse', '--verify', '--quiet', 'refs/stash'], sourceDir).status, 1, 'disjoint changes require no stash');
121
+ run(['reset', '--hard'], sourceDir);
122
+ });
123
+ test('close stashes and restores overlapping tracked changes without leaving a stash', () => {
124
+ const nodeId = 'wt-close-overlap-base-dirt';
125
+ // The child commit and the base local edit touch the SAME file (so landing
126
+ // takes the stash path) but in far-apart regions, so the stash re-apply
127
+ // merges cleanly rather than conflicting.
128
+ const original = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n') + '\n';
129
+ writeFileSync(join(sourceDir, 'overlap.txt'), original);
130
+ run(['add', 'overlap.txt'], sourceDir);
131
+ run(['commit', '-m', 'overlap base file'], sourceDir);
132
+ const wt = createManagedWorktree(sourceDir, nodeId);
133
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
134
+ const childEdit = original.replace('line 1\n', 'line 1 child-changed\n');
135
+ writeFileSync(join(wt.path, 'overlap.txt'), childEdit);
136
+ run(['add', 'overlap.txt'], wt.path);
137
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'overlap child work'], wt.path);
138
+ const baseEdit = original.replace('line 20\n', 'line 20 base-changed\n');
139
+ writeFileSync(join(sourceDir, 'overlap.txt'), baseEdit);
140
+ const result = closeManagedWorktree(nodeId);
141
+ assert.equal(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), result.landed_sha, 'the child commit fast-forwards main under the local edit');
142
+ const landed = childEdit.replace('line 20\n', 'line 20 base-changed\n');
143
+ assert.equal(readFileSync(join(sourceDir, 'overlap.txt'), 'utf8'), landed, 'the disjoint local edit is restored over the landed child change');
144
+ assert.equal(result.base_checkout_stash, undefined, 'a clean stash apply leaves no preserved-stash note');
145
+ assert.match(gitSync(['status', '--porcelain'], sourceDir).stdout, /M overlap.txt/, 'the restored base edit remains uncommitted');
146
+ assert.equal(gitSync(['rev-parse', '--verify', '--quiet', 'refs/stash'], sourceDir).status, 1, 'a clean stash apply drops the temporary stash');
147
+ run(['reset', '--hard'], sourceDir);
148
+ });
149
+ test('close refuses an incoming path that collides with an untracked base file', () => {
150
+ const nodeId = 'wt-close-untracked-collision';
151
+ const wt = createManagedWorktree(sourceDir, nodeId);
152
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
153
+ writeFileSync(join(wt.path, 'incoming-collision.txt'), 'child work\n');
154
+ run(['add', 'incoming-collision.txt'], wt.path);
155
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'incoming collision'], wt.path);
156
+ writeFileSync(join(sourceDir, 'incoming-collision.txt'), 'base untracked work\n');
157
+ assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'land_base_failed', 'close must refuse rather than overwrite an untracked base file');
158
+ assert.equal(readFileSync(join(sourceDir, 'incoming-collision.txt'), 'utf8'), 'base untracked work\n', 'the base untracked file remains intact');
159
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'a refused land remains unreconciled');
160
+ assert.ok(getNode(nodeId)?.managed_worktree?.land_intent, 'the blocked close records the intent the sweep may later finish');
161
+ rmSync(join(sourceDir, 'incoming-collision.txt'));
92
162
  });
93
163
  test('two concurrent creates from linked worktrees serialize on their shared repository', async () => {
94
164
  const worktreeModule = new URL('../../worktree.ts', import.meta.url).href;
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { gitSync } from '../../git.js';
11
11
  import { createManagedWorktree, closeManagedWorktree } from '../../worktree.js';
12
+ import { reconcileManagedWorktree } from '../../worktree-sweep.js';
12
13
  import { createApiServer } from '../../../daemon/api/server.js';
13
14
  import { apiSocketPath } from '../../canvas/paths.js';
14
15
  import { CrtrClient } from '../../../api/index.js';
@@ -37,6 +38,10 @@ function node(id, over = {}) {
37
38
  };
38
39
  }
39
40
  before(() => {
41
+ // NOT realpath'd on purpose: the macOS tmpdir is reached through the
42
+ // `/var` → `/private/var` symlink, so the recorded managed-worktree path and
43
+ // git's canonical `worktree list` output differ as strings while naming one
44
+ // directory. Every path-ownership check must judge identity, not spelling.
40
45
  home = mkdtempSync(join(tmpdir(), 'crtr-worktree-home-'));
41
46
  process.env['CRTR_HOME'] = home;
42
47
  // Bare "remote" wired up as origin so the test can prove landing NEVER pushes
@@ -85,6 +90,102 @@ test('autoDropCleanManagedWorktree drops a clean, zero-commit worktree', async (
85
90
  assert.equal(meta?.cwd, wt.repo_root, 'auto-dropped node cwd repoints to the durable base checkout');
86
91
  assert.equal(gitSync(['rev-parse', '--git-dir'], meta.cwd).status, 0, 'repointed cwd resolves to a real git checkout');
87
92
  assert.ok(existsSync(join(wt.path, '.git')), 'the intact caller worktree remains a git checkout');
93
+ const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
94
+ assert.equal(outcome.status, 'complete', 'the daemon-owned sweep consumes pending cleanup after the caller exits');
95
+ assert.equal(existsSync(wt.path), false, 'the sweep removes the deferred caller checkout');
96
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 1, 'the sweep deletes the clean managed branch');
97
+ const swept = getNode(nodeId)?.managed_worktree;
98
+ assert.deepEqual({ state: swept?.state, cleanup: swept?.cleanup }, { state: 'closed', cleanup: 'complete' });
99
+ });
100
+ test('the sweep refuses a managed checkout with uncommitted changes', async () => {
101
+ const nodeId = 'wt-sweep-dirty';
102
+ const wt = createManagedWorktree(sourceDir, nodeId);
103
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
104
+ writeFileSync(join(wt.path, 'uncommitted.txt'), 'must survive\n');
105
+ const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
106
+ assert.equal(outcome.status, 'refused');
107
+ assert.equal(outcome.status === 'refused' ? outcome.reason : undefined, 'dirty_worktree');
108
+ assert.ok(existsSync(join(wt.path, 'uncommitted.txt')), 'the uncommitted file remains in the checkout');
109
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 0, 'the managed branch remains');
110
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'the record remains unreconciled');
111
+ });
112
+ // The lock the sweep holds serializes worktree operations, NOT node lifecycle:
113
+ // a revive can land in any await between the containment proof and the removal.
114
+ // So eligibility is re-asked immediately before every destructive step, and a
115
+ // node that came back keeps the checkout it is now running in.
116
+ test('the sweep stops before removing a checkout whose node revived mid-pass', async () => {
117
+ const nodeId = 'wt-sweep-revived-mid-pass';
118
+ const wt = createManagedWorktree(sourceDir, nodeId);
119
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
120
+ // True on the first ask (the pass starts), false afterwards (the revive won).
121
+ // Everything about this worktree is otherwise disposable: it is clean and its
122
+ // tip is its base, so ONLY the recheck can save it.
123
+ let asks = 0;
124
+ const outcome = await reconcileManagedWorktree(nodeId, {
125
+ stillEligible: () => {
126
+ asks += 1;
127
+ return asks === 1;
128
+ },
129
+ });
130
+ assert.ok(asks > 1, 'eligibility is re-asked before the destructive steps, not once per pass');
131
+ assert.equal(outcome.status, 'skipped');
132
+ assert.equal(outcome.status === 'skipped' ? outcome.reason : undefined, 'node_no_longer_terminal');
133
+ assert.ok(existsSync(join(wt.path, '.git')), 'the revived node keeps the checkout it is running in');
134
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 0, 'its branch survives');
135
+ const record = getNode(nodeId)?.managed_worktree;
136
+ assert.equal(record?.state, 'open', 'the record is untouched, so a later pass can resume it');
137
+ assert.equal(record?.sweep, undefined, 'a revived node is not charged a refusal or a backoff');
138
+ });
139
+ test('the sweep refuses unlanded commits when close never recorded land intent', async () => {
140
+ const nodeId = 'wt-sweep-no-land-intent';
141
+ const wt = createManagedWorktree(sourceDir, nodeId);
142
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
143
+ writeFileSync(join(wt.path, 'unlanded.txt'), 'must survive\n');
144
+ run(['add', 'unlanded.txt'], wt.path);
145
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'unlanded work'], wt.path);
146
+ const branchSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
147
+ const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
148
+ assert.equal(outcome.status, 'refused');
149
+ assert.equal(outcome.status === 'refused' ? outcome.reason : undefined, 'unlanded_work');
150
+ assert.ok(existsSync(wt.path), 'the checkout remains available for a human');
151
+ assert.equal(gitSync(['rev-parse', wt.branch], sourceDir).stdout.trim(), branchSha, 'the unlanded branch remains intact');
152
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'the record remains unreconciled');
153
+ });
154
+ test('the sweep removes a rebased branch whose patches already landed', async () => {
155
+ const nodeId = 'wt-sweep-patch-identity';
156
+ const wt = createManagedWorktree(sourceDir, nodeId);
157
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
158
+ writeFileSync(join(wt.path, 'patch-identity.txt'), 'same content\n');
159
+ run(['add', 'patch-identity.txt'], wt.path);
160
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'child patch'], wt.path);
161
+ const childSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
162
+ writeFileSync(join(sourceDir, 'patch-identity.txt'), 'same content\n');
163
+ run(['add', 'patch-identity.txt'], sourceDir);
164
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'landed independently'], sourceDir);
165
+ assert.notEqual(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), childSha, 'the independently landed patch has a different commit identity');
166
+ const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
167
+ assert.equal(outcome.status, 'complete', 'patch identity proves the rebased branch content is already present');
168
+ assert.equal(existsSync(wt.path), false, 'the redundant checkout is removed');
169
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 1, 'the redundant branch is deleted');
170
+ assert.equal(getNode(nodeId)?.managed_worktree?.cleanup, 'complete');
171
+ });
172
+ test('the sweep removes a squash-merged branch whose merge is a content no-op', async () => {
173
+ const nodeId = 'wt-sweep-content-noop';
174
+ const wt = createManagedWorktree(sourceDir, nodeId);
175
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
176
+ writeFileSync(join(wt.path, 'squash-one.txt'), 'one\n');
177
+ run(['add', 'squash-one.txt'], wt.path);
178
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'first child patch'], wt.path);
179
+ writeFileSync(join(wt.path, 'squash-two.txt'), 'two\n');
180
+ run(['add', 'squash-two.txt'], wt.path);
181
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'second child patch'], wt.path);
182
+ run(['merge', '--squash', wt.branch], sourceDir);
183
+ run(['commit', '-m', 'squash landed child work'], sourceDir);
184
+ const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
185
+ assert.equal(outcome.status, 'complete', 'a content-no-op merge proves squash-landed work is safe to remove');
186
+ assert.equal(existsSync(wt.path), false, 'the redundant checkout is removed');
187
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 1, 'the squash-landed branch is deleted');
188
+ assert.equal(getNode(nodeId)?.managed_worktree?.cleanup, 'complete');
88
189
  });
89
190
  // The same real-commit case close already protects: a worktree with actual
90
191
  // unlanded work must NEVER be auto-dropped by push-final.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseStatusPorcelain, planLandIntoCheckout } from '../worktree-landing.js';
4
+ test('parseStatusPorcelain separates tracked modifications, untracked files, and rename destinations', () => {
5
+ assert.deepEqual(parseStatusPorcelain(' M modified.txt\nM staged.txt\nR old-name.txt -> new-name.txt\n?? untracked.txt\n!! ignored.txt\n'), { tracked: ['modified.txt', 'staged.txt', 'new-name.txt'], untracked: ['untracked.txt'] });
6
+ });
7
+ test('planLandIntoCheckout selects direct landing, a tracked-change stash, or an untracked collision refusal', () => {
8
+ assert.deepEqual(planLandIntoCheckout({ tracked: ['local.txt'], untracked: ['scratch.txt'] }, ['incoming.txt']), { kind: 'direct' });
9
+ assert.deepEqual(planLandIntoCheckout({ tracked: ['local.txt'], untracked: ['scratch.txt'] }, ['local.txt']), { kind: 'stash', overlapping: ['local.txt'] });
10
+ assert.deepEqual(planLandIntoCheckout({ tracked: ['local.txt'], untracked: ['scratch.txt'] }, ['scratch.txt']), { kind: 'refuse', collisions: ['scratch.txt'] });
11
+ });
@@ -836,16 +836,32 @@ export function rebuildIndex() {
836
836
  recordSpawn(meta.node_id, prov);
837
837
  }
838
838
  }
839
- /** True when this node's identity is still required to own a managed checkout. */
840
- function hasOpenManagedWorktree(nodeId) {
841
- return getNode(nodeId)?.managed_worktree?.state === 'open';
839
+ /** True when this node's identity is still required to own a managed checkout.
840
+ *
841
+ * Ownership outlives the OPEN state. A closed record whose cleanup is still
842
+ * pending names a checkout and a branch that are physically still there, and
843
+ * this node record is the only proof of who they belong to — deleting it turns
844
+ * a retained residual into an ownerless orphan the daemon sweep can never
845
+ * safely reclaim, because it must never guess at a worktree it cannot prove it
846
+ * created. So prune may take this node only once cleanup is COMPLETE. A
847
+ * historical closed record written before the cleanup field existed carries no
848
+ * such proof and counts as incomplete.
849
+ *
850
+ * An abandoned record is deliberately NOT protected: abandon already removed
851
+ * the checkout and retains its branch for a person on purpose, so there is no
852
+ * residual for the sweep to own. */
853
+ function hasUnreconciledManagedWorktree(nodeId) {
854
+ const wt = getNode(nodeId)?.managed_worktree;
855
+ if (wt == null || wt.state === 'abandoned')
856
+ return false;
857
+ return wt.state === 'open' || wt.cleanup !== 'complete';
842
858
  }
843
859
  /** Whether a row may be removed by an automatic collection sweep. */
844
860
  export function isCollectible(row, opts = {}) {
845
861
  if (!['done', 'dead', 'canceled'].includes(row.status)
846
862
  || row.pi_pid !== null
847
863
  || row.frozen_at !== null
848
- || hasOpenManagedWorktree(row.node_id)
864
+ || hasUnreconciledManagedWorktree(row.node_id)
849
865
  || row.node_id === envNodeId())
850
866
  return false;
851
867
  return opts.pastTtl === true || !['crashed', 'stranded', null].includes(row.terminal_reason ?? null);
@@ -860,7 +876,7 @@ export function isCollectible(row, opts = {}) {
860
876
  * state are removed in one canvas write boundary. Best-effort dir removal
861
877
  * follows the commit. */
862
878
  export function deleteNode(nodeId) {
863
- if (hasOpenManagedWorktree(nodeId))
879
+ if (hasUnreconciledManagedWorktree(nodeId))
864
880
  return { deleted: false };
865
881
  const result = withCanvasWrite((db) => {
866
882
  db.prepare('DELETE FROM nodes WHERE node_id = ?').run(nodeId);
@@ -875,9 +891,9 @@ export function deleteNodes(nodeIds) {
875
891
  const ids = [...new Set(nodeIds)];
876
892
  if (ids.length === 0)
877
893
  return [];
878
- const blocked = ids.filter(hasOpenManagedWorktree);
894
+ const blocked = ids.filter(hasUnreconciledManagedWorktree);
879
895
  if (blocked.length > 0) {
880
- throw new Error(`refusing to delete nodes with open managed worktrees: ${blocked.join(', ')}`);
896
+ throw new Error(`refusing to delete nodes with unreconciled managed worktrees: ${blocked.join(', ')}`);
881
897
  }
882
898
  const placeholders = ids.map(() => '?').join(',');
883
899
  const deleted = withCanvasWrite((db) => {
@@ -941,7 +957,7 @@ export function pruneNodes(opts) {
941
957
  status: r['status'],
942
958
  created: r['created'],
943
959
  }))
944
- .filter((node) => !hasOpenManagedWorktree(node.node_id));
960
+ .filter((node) => !hasUnreconciledManagedWorktree(node.node_id));
945
961
  const candidates = [...terminal, ...stale];
946
962
  if (dryRun || candidates.length === 0)
947
963
  return { pruned: candidates, dryRun };
@@ -153,6 +153,12 @@ export interface PsLivenessSnapshot {
153
153
  childrenOf: ReadonlyMap<number, number[]>;
154
154
  }
155
155
  export declare function captureLivenessSnapshot(): PsLivenessSnapshot | null;
156
+ /** The same sample without blocking the event loop, for callers running inside
157
+ * crtrd's detached work: one `ps` read classifies every pid they care about,
158
+ * where a per-pid `spawnSync` probe would stall broker supervision and the API
159
+ * socket for the duration of each probe. `null` means the probe failed, which
160
+ * every caller must preserve as unknown rather than absence. */
161
+ export declare function captureLivenessSnapshotAsync(): Promise<PsLivenessSnapshot | null>;
156
162
  /** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
157
163
  * pid/ppid table (BFS) — NOT process-GROUP membership. The pi SDK's bash tool
158
164
  * spawns its shell child with
@@ -6,7 +6,7 @@
6
6
  // above runtime/). It is a pure process-existence utility, NOT data-model access,
7
7
  // so it is exempt from the "only canvas.ts touches the db" rule. canvas.ts,
8
8
  // revive.ts, placement.ts, and crtrd.ts all probe liveness through it.
9
- import { spawnSync } from 'node:child_process';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
10
  import { readFileSync } from 'node:fs';
11
11
  import { readKernelBootId } from './boot-id.js';
12
12
  /** Classify the current `ps` row for a pid. BSD ps and procps-ng both report a
@@ -330,12 +330,16 @@ export function composeIdentity(pid, lstart, deps = {}) {
330
330
  const bootId = deps.bootId !== undefined ? deps.bootId : bootIdBase();
331
331
  return `${bootId ?? lstart}#${ticks}`;
332
332
  }
333
+ /** The `ps` invocation both snapshot captures share, so the synchronous and
334
+ * asynchronous forms can never sample different columns. */
335
+ const PS_SNAPSHOT_ARGS = ['-Ao', 'pid=,ppid=,stat=,lstart=,command='];
336
+ const PS_SNAPSHOT_TIMEOUT_MS = 2000;
333
337
  export function captureLivenessSnapshot() {
334
338
  let stdout;
335
339
  try {
336
- const r = spawnSync('ps', ['-Ao', 'pid=,ppid=,stat=,lstart=,command='], {
340
+ const r = spawnSync('ps', [...PS_SNAPSHOT_ARGS], {
337
341
  encoding: 'utf8',
338
- timeout: 2000,
342
+ timeout: PS_SNAPSHOT_TIMEOUT_MS,
339
343
  maxBuffer: 8 * 1024 * 1024,
340
344
  });
341
345
  if (r.status !== 0 || typeof r.stdout !== 'string')
@@ -345,6 +349,47 @@ export function captureLivenessSnapshot() {
345
349
  catch {
346
350
  return null;
347
351
  }
352
+ return parseLivenessTable(stdout);
353
+ }
354
+ /** The same sample without blocking the event loop, for callers running inside
355
+ * crtrd's detached work: one `ps` read classifies every pid they care about,
356
+ * where a per-pid `spawnSync` probe would stall broker supervision and the API
357
+ * socket for the duration of each probe. `null` means the probe failed, which
358
+ * every caller must preserve as unknown rather than absence. */
359
+ export async function captureLivenessSnapshotAsync() {
360
+ const stdout = await new Promise((resolve) => {
361
+ let child;
362
+ try {
363
+ child = spawn('ps', [...PS_SNAPSHOT_ARGS]);
364
+ }
365
+ catch {
366
+ return resolve(null);
367
+ }
368
+ let out = '';
369
+ let settled = false;
370
+ const settle = (value) => {
371
+ if (settled)
372
+ return;
373
+ settled = true;
374
+ clearTimeout(timer);
375
+ resolve(value);
376
+ };
377
+ const timer = setTimeout(() => {
378
+ try {
379
+ child.kill('SIGKILL');
380
+ }
381
+ catch { /* already gone */ }
382
+ settle(null);
383
+ }, PS_SNAPSHOT_TIMEOUT_MS);
384
+ timer.unref?.();
385
+ child.stdout.on('data', (d) => (out += d.toString()));
386
+ child.stderr.on('data', () => { });
387
+ child.on('error', () => settle(null));
388
+ child.on('close', (status) => settle(status === 0 ? out : null));
389
+ });
390
+ return stdout === null ? null : parseLivenessTable(stdout);
391
+ }
392
+ function parseLivenessTable(stdout) {
348
393
  const processes = new Map();
349
394
  const childrenOf = new Map();
350
395
  for (const line of stdout.split('\n')) {
@@ -48,10 +48,41 @@ export interface LaunchSpec {
48
48
  /** Extra env injected into the pi process. */
49
49
  env: Record<string, string>;
50
50
  }
51
+ /** The repository fingerprint a refusal was decided against. An examination
52
+ * that reads the same three values can skip the whole proof cascade: nothing
53
+ * about the refusal can have changed. */
54
+ export interface ManagedWorktreeObservation {
55
+ /** Tip of `refs/heads/<base_ref>`, or null when it no longer resolves. */
56
+ base: string | null;
57
+ /** Tip of `refs/heads/<branch>`, or null when the branch is gone. */
58
+ branch: string | null;
59
+ /** `absent`, or a hash of the checkout's HEAD plus `git status --porcelain`. */
60
+ checkout: string | null;
61
+ }
62
+ /** Reconciliation bookkeeping owned SOLELY by the daemon's managed-worktree
63
+ * sweep. Present only while a record is not yet reconciled; cleared when
64
+ * cleanup completes. */
65
+ export interface ManagedWorktreeSweep {
66
+ /** Structured code for why the last pass did not complete cleanup. */
67
+ reason: string;
68
+ detail?: string;
69
+ observed: ManagedWorktreeObservation;
70
+ /** Consecutive passes that examined this record without completing it. */
71
+ attempts: number;
72
+ last_attempt: string;
73
+ /** The sweep does not re-examine this record before this instant. */
74
+ next_attempt: string;
75
+ /** Set once `attempts` reaches the terminal backoff tier: this record needs a
76
+ * human, and `crtr sys worktrees` lists it with its reason. It is still
77
+ * fingerprint-checked at the capped interval, so work that lands later is
78
+ * still cleaned up. */
79
+ quarantined?: true;
80
+ }
51
81
  export interface ManagedWorktree {
52
82
  state: 'open' | 'closed' | 'abandoned';
53
83
  /** A closed worktree remains registered until its owner has exited, so Git
54
- * never deletes the caller's live checkout beneath it. */
84
+ * never deletes the caller's live checkout beneath it. The daemon sweep is
85
+ * the one authority that turns `pending` into `complete`. */
55
86
  cleanup?: 'pending' | 'complete';
56
87
  path: string;
57
88
  branch: string;
@@ -66,6 +97,15 @@ export interface ManagedWorktree {
66
97
  at: string;
67
98
  by: string;
68
99
  };
100
+ /** Recorded by `close` when the rebase succeeded but fast-forwarding the base
101
+ * was blocked. The sweep lands a dead node's commits ONLY for a record
102
+ * carrying this: the daemon finishes a job someone started, it never invents
103
+ * the intent to land. */
104
+ land_intent?: {
105
+ at: string;
106
+ blocked: string;
107
+ };
108
+ sweep?: ManagedWorktreeSweep;
69
109
  }
70
110
  /** Immutable companion provenance. The origin session/leaf/model coordinates
71
111
  * remain canonical in the review row rather than being duplicated into node
@@ -1,3 +1,9 @@
1
+ /** The pid currently holding this lock, read straight off the marker name (the
2
+ * token opens with the owner's pid). Null when the lock is free, unowned, or
3
+ * names a token we cannot parse. Lets a caller tell "my child is doing the
4
+ * work" apart from "my child is queued behind someone else's work" without
5
+ * any new bookkeeping. */
6
+ export declare function exclusiveLockOwnerPid(path: string): number | null;
1
7
  export interface ExclusiveLockOptions {
2
8
  /** Grace before a lock that names no owner at all is cleared. */
3
9
  staleMs?: number;
@@ -91,6 +91,18 @@ function reclaimIfOwnerGone(path, staleMs) {
91
91
  throw error;
92
92
  }
93
93
  }
94
+ /** The pid currently holding this lock, read straight off the marker name (the
95
+ * token opens with the owner's pid). Null when the lock is free, unowned, or
96
+ * names a token we cannot parse. Lets a caller tell "my child is doing the
97
+ * work" apart from "my child is queued behind someone else's work" without
98
+ * any new bookkeeping. */
99
+ export function exclusiveLockOwnerPid(path) {
100
+ const token = observedMarkerToken(path);
101
+ if (token === null)
102
+ return null;
103
+ const pid = Number(token.split('.', 1)[0]);
104
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
105
+ }
94
106
  function tryAcquire(path, staleMs) {
95
107
  const token = `${process.pid}.${randomUUID()}`;
96
108
  try {
@@ -11,7 +11,7 @@ export interface GitSyncOptions {
11
11
  timeoutMs?: number;
12
12
  }
13
13
  export declare function gitSync(args: string[], cwd?: string, input?: string, options?: GitSyncOptions): GitResult;
14
- export declare function gitAsync(args: string[], cwd?: string): Promise<GitResult>;
14
+ export declare function gitAsync(args: string[], cwd?: string, input?: string): Promise<GitResult>;
15
15
  export declare function clone(url: string, dest: string, opts?: {
16
16
  ref?: string;
17
17
  depth?: number;
package/dist/core/git.js CHANGED
@@ -10,9 +10,13 @@ export function gitSync(args, cwd, input, options = {}) {
10
10
  const timedOut = res.error?.code === 'ETIMEDOUT';
11
11
  return { status, stdout, stderr, ...(completed ? {} : { executionFailed: true }), ...(processError === undefined ? {} : { processError }), ...(timedOut ? { timedOut: true } : {}) };
12
12
  }
13
- export async function gitAsync(args, cwd) {
13
+ export async function gitAsync(args, cwd, input) {
14
14
  return new Promise((resolve) => {
15
15
  const child = spawn('git', args, { cwd });
16
+ if (input === undefined)
17
+ child.stdin.end();
18
+ else
19
+ child.stdin.end(input);
16
20
  let stdout = '';
17
21
  let stderr = '';
18
22
  child.stdout.on('data', (d) => (stdout += d.toString()));
@@ -37,6 +37,12 @@ export interface FleetRegistry {
37
37
  register(nodeId: string, handle: HostHandle): void;
38
38
  /** The reviveNode double-launch guard: a fleet entry IS liveness. */
39
39
  has(nodeId: string): boolean;
40
+ /** True while ANY launch claim exists for this node — a registered broker OR
41
+ * an in-flight reservation. `has` alone reads false during the window
42
+ * between `reserveSlot` and registration, so a caller asking "could this
43
+ * node come back to life right now?" (the managed-worktree sweep, before it
44
+ * destroys a checkout) must ask this instead. */
45
+ isClaimed(nodeId: string): boolean;
40
46
  /** Live-fleet checks pass (yield-stall, wedge, fatal-fault). */
41
47
  get(nodeId: string): FleetEntry | undefined;
42
48
  /** The capacity count: registered brokers only, excluding reservations. */