@north-light/crouter 0.3.303 → 0.3.304

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 (73) hide show
  1. package/dist/api/__tests__/integration/client.test.js +37 -36
  2. package/dist/api/client.d.ts +32 -55
  3. package/dist/api/client.js +102 -98
  4. package/dist/api/dto/messages.d.ts +6 -5
  5. package/dist/api/errors.d.ts +3 -0
  6. package/dist/api/errors.js +10 -0
  7. package/dist/api/index.d.ts +1 -1
  8. package/dist/api/index.js +1 -1
  9. package/dist/builtin-memory/internal/plugins.md +1 -1
  10. package/dist/clients/attach/viewer.js +532 -532
  11. package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
  12. package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
  13. package/dist/commands/api-client.d.ts +3 -3
  14. package/dist/commands/api-client.js +3 -3
  15. package/dist/commands/memory/read.js +2 -1
  16. package/dist/commands/node/bash.js +6 -6
  17. package/dist/commands/node/message.js +3 -3
  18. package/dist/commands/sys/daemon.js +11 -13
  19. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
  20. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  21. package/dist/core/__tests__/helpers/harness.d.ts +2 -0
  22. package/dist/core/__tests__/helpers/harness.js +9 -0
  23. package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
  24. package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
  25. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
  26. package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
  27. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
  28. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
  29. package/dist/core/bash-jobs.d.ts +20 -8
  30. package/dist/core/bash-jobs.js +40 -21
  31. package/dist/core/canvas/types.d.ts +4 -2
  32. package/dist/core/command.js +1 -1
  33. package/dist/core/fault-classifier.d.ts +1 -1
  34. package/dist/core/runtime/broker/event-projection.d.ts +0 -3
  35. package/dist/core/runtime/broker/event-projection.js +2 -15
  36. package/dist/core/runtime/broker/fault-retry.js +8 -0
  37. package/dist/core/runtime/broker-persona-guidance.js +12 -0
  38. package/dist/core/runtime/broker.js +0 -5
  39. package/dist/core/runtime/fault.js +1 -1
  40. package/dist/core/runtime/host.js +10 -1
  41. package/dist/core/runtime/spawn.js +5 -6
  42. package/dist/core/worktree-close.d.ts +4 -0
  43. package/dist/core/worktree-close.js +225 -0
  44. package/dist/core/worktree-containment.d.ts +14 -0
  45. package/dist/core/worktree-containment.js +48 -0
  46. package/dist/core/worktree-mutation-async.d.ts +13 -0
  47. package/dist/core/worktree-mutation-async.js +281 -0
  48. package/dist/core/worktree-sweep.js +17 -79
  49. package/dist/core/worktree.d.ts +1 -0
  50. package/dist/core/worktree.js +1 -1
  51. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
  52. package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
  53. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  54. package/dist/daemon/api/handlers/messages.js +7 -5
  55. package/dist/daemon/api/handlers/reports.js +3 -2
  56. package/dist/daemon/api/handlers/worktree.js +7 -6
  57. package/dist/daemon/fleet.d.ts +1 -1
  58. package/dist/daemon/fleet.js +30 -12
  59. package/dist/daemon/manage.d.ts +8 -5
  60. package/dist/daemon/manage.js +63 -40
  61. package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
  62. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
  63. package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
  64. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
  65. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
  66. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
  67. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
  68. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
  69. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  70. package/dist/pi-extensions/canvas-bash-valve.js +73 -43
  71. package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
  72. package/package.json +1 -1
  73. package/runtime.lock.json +5 -5
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ test('daemon status reports its failed probe without telling the operator to stop a live process', () => {
8
+ const home = mkdtempSync(join(tmpdir(), 'crtr-daemon-status-'));
9
+ const pidfile = join(home, 'crtrd.pid');
10
+ const owner = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
11
+ try {
12
+ assert.ok(owner.pid !== undefined, 'fixture process has a pid');
13
+ writeFileSync(pidfile, String(owner.pid));
14
+ const result = spawnSync(process.execPath, [join(process.cwd(), 'dist', 'cli.js'), '--json', 'sys', 'daemon', 'status'], {
15
+ encoding: 'utf8',
16
+ env: { ...process.env, CRTR_HOME: home, CRTR_PIDFILE: pidfile, CRTR_NO_DAEMON_AUTOSTART: '1' },
17
+ timeout: 10_000,
18
+ });
19
+ assert.equal(result.status, 0, result.stderr);
20
+ const output = JSON.parse(result.stdout);
21
+ assert.deepEqual({ running: output.running, serving: output.serving }, { running: true, serving: false });
22
+ assert.match(output.probe_error ?? '', /ENOENT|ECONNREFUSED/);
23
+ assert.equal(output.next, undefined, 'a single failed health probe is not a reason to tell the operator to stop the owner');
24
+ }
25
+ finally {
26
+ owner.kill('SIGKILL');
27
+ rmSync(home, { recursive: true, force: true });
28
+ }
29
+ });
@@ -38,9 +38,9 @@ export declare function readColdStartDiagnostic(): string | undefined;
38
38
  * default: on a cold socket the injected `onColdSocket` hook fires
39
39
  * `ensureDaemon()` (spawns crtrd detached via the branded host), then the
40
40
  * client polls `/healthz` to a bounded deadline and retries once (the poll
41
- * lives inside `CrtrClient`, A-2). If crtrd never becomes reachable — or
42
- * autostart is disabled — the client throws `daemon_unavailable` (spec §7.1,
43
- * §8). This is the ONLY CLI reference to `ensureDaemon`.
41
+ * lives inside `CrtrClient`, A-2). If crtrd never becomes reachable, the
42
+ * client surfaces the final observed transport failure. This is the ONLY CLI
43
+ * reference to `ensureDaemon`.
44
44
  *
45
45
  * `onColdSocket` is fire-and-forget — `ensureDaemon()` spawns crtrd and
46
46
  * returns immediately, so the client's OWN `/healthz` poll is the only
@@ -104,9 +104,9 @@ export function readColdStartDiagnostic() {
104
104
  * default: on a cold socket the injected `onColdSocket` hook fires
105
105
  * `ensureDaemon()` (spawns crtrd detached via the branded host), then the
106
106
  * client polls `/healthz` to a bounded deadline and retries once (the poll
107
- * lives inside `CrtrClient`, A-2). If crtrd never becomes reachable — or
108
- * autostart is disabled — the client throws `daemon_unavailable` (spec §7.1,
109
- * §8). This is the ONLY CLI reference to `ensureDaemon`.
107
+ * lives inside `CrtrClient`, A-2). If crtrd never becomes reachable, the
108
+ * client surfaces the final observed transport failure. This is the ONLY CLI
109
+ * reference to `ensureDaemon`.
110
110
  *
111
111
  * `onColdSocket` is fire-and-forget — `ensureDaemon()` spawns crtrd and
112
112
  * returns immediately, so the client's OWN `/healthz` poll is the only
@@ -22,7 +22,8 @@ async function fetchSubject(nodeId) {
22
22
  if (nodeId === undefined)
23
23
  return null;
24
24
  try {
25
- return await CrtrClient.forLocalSocket({ autostart: false }).nodeSubject(nodeId);
25
+ // This optional gate lookup must not hold a memory read while a daemon is unavailable: an absent subject simply skips gated docs.
26
+ return await CrtrClient.forLocalSocket({ autostart: false, coldStartPollWindowMs: 0 }).nodeSubject(nodeId);
26
27
  }
27
28
  catch {
28
29
  return null;
@@ -117,7 +117,7 @@ const nodeBashKill = defineLeaf({
117
117
  tier: 'hidden',
118
118
  help: {
119
119
  name: 'node bash kill',
120
- summary: 'terminate a background bash job’s process group and tell the owning node it was canceled',
120
+ summary: 'best-effort stop a background bash job and tell the owning node it was canceled',
121
121
  params: [
122
122
  { kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list.' },
123
123
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
@@ -125,11 +125,11 @@ const nodeBashKill = defineLeaf({
125
125
  ],
126
126
  output: [
127
127
  { name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
128
- { name: 'signaled', type: 'boolean', required: true, constraint: 'True when the process group was still alive and received SIGTERM.' },
128
+ { name: 'signaled', type: 'boolean', required: true, constraint: 'True when at least one observed supervisor-tree process received SIGTERM.' },
129
129
  ],
130
130
  outputKind: 'object',
131
131
  effects: [
132
- 'Sends SIGTERM (then SIGKILL) to the job’s whole process group, stopping the command and anything it forked.',
132
+ 'Snapshots the supervisor’s observable process tree, sends SIGTERM then SIGKILL to those processes, and cannot stop a descendant that detached and reparented before the snapshot.',
133
133
  'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was stopped — which steers that node mid-turn.',
134
134
  ],
135
135
  },
@@ -159,8 +159,8 @@ const nodeBashKill = defineLeaf({
159
159
  if (stopped.kind === 'not-stoppable') {
160
160
  throw new InputError({
161
161
  error: 'not_stoppable',
162
- message: `job ${jobId} recorded no process group`,
163
- next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
162
+ message: `job ${jobId} recorded no trusted process identity`,
163
+ next: `It started before crtr persisted its supervisor identity. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
164
164
  });
165
165
  }
166
166
  await cliClient().sendMessage(node.node_id, {
@@ -169,7 +169,7 @@ const nodeBashKill = defineLeaf({
169
169
  });
170
170
  return { node_id: node.node_id, signaled: stopped.signaled };
171
171
  },
172
- render: (result) => `Canceled a background bash job for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
172
+ render: (result) => `Canceled a background bash job for ${result['node_id']}${result['signaled'] === true ? '' : ' (no observed job process was still running)'}.`,
173
173
  });
174
174
  const nodeBashExtend = defineLeaf({
175
175
  name: 'extend',
@@ -38,7 +38,7 @@ async function resolveMsgTarget(input, hasBody) {
38
38
  }
39
39
  function messageOutput() {
40
40
  return [
41
- { name: 'revived', type: 'boolean', required: false, constraint: 'True when the target was revived.' },
41
+ { name: 'revived', type: 'boolean', required: false, constraint: 'True when this request synchronously revived the target; ordinary durable delivery to an active or idle dormant target queues lifecycle revival instead.' },
42
42
  { name: 'guidance', type: 'string', required: true, constraint: 'Immediate action confirmation.' },
43
43
  ];
44
44
  }
@@ -67,7 +67,7 @@ function messageRequestParams() {
67
67
  { kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. The schema alone is a complete request.', positionalStdinConflict: missingMessageTargetForBody },
68
68
  { kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
69
69
  { kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
70
- { kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal'], required: false, default: 'normal', constraint: 'Delivery urgency. Every accepted tier may revive a dormant target, because the request must be answered; deferred is rejected for that reason.' },
70
+ { kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal'], required: false, default: 'normal', constraint: 'Delivery urgency. Every accepted tier queues lifecycle revival for an active or idle dormant target because the request must be answered; deferred is rejected for that reason.' },
71
71
  { kind: 'flag', name: 'output-schema', type: 'string', required: true, constraint: `JSON Schema the result must satisfy. ${OUTPUT_SCHEMA_TRANSPORT} The target answers with \`crtr push result\` (or declines with \`crtr push result --decline "<reason>" --code <token>\`), then keeps working. ${OUTPUT_SCHEMA_CONSTRAINTS}` },
72
72
  { kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Non-empty hidden ambient context delivered beside the message, or alone.' },
73
73
  { kind: 'flag', name: 'reopen', type: 'bool', required: false, hidden: true, constraint: 'Clear a finalized target latch before delivery or fresh revive.' },
@@ -125,7 +125,7 @@ const nodeMessageSend = defineLeaf({
125
125
  params: messageSendParams(),
126
126
  output: messageOutput(),
127
127
  outputKind: 'object',
128
- effects: ['Appends one inbox entry. Critical, urgent, and normal delivery may revive a dormant target; deferred waits for its next natural cycle.', '--reopen commits the target resident and clears any finalization latch before delivery, allowing it to take a new mandate; it is valid for live, parked, and finalized targets. Without it, a finalized target rejects delivery before an inbox entry is appended or a revive is attempted.'],
128
+ effects: ['Appends one inbox entry. Critical, urgent, and normal delivery queues lifecycle revival for an active or idle dormant target; deferred waits for its next natural cycle.', '--reopen commits the target resident and clears any finalization latch before delivery, allowing it to take a new mandate; it is valid for live, parked, and finalized targets. Without it, a finalized target rejects delivery before an inbox entry is appended or a revive is attempted.'],
129
129
  },
130
130
  run: (input) => runMessageEngine(input),
131
131
  render: (r) => String(r['guidance']),
@@ -8,7 +8,7 @@
8
8
  // This subtree starts, checks, and stops the daemon process.
9
9
  import { defineLeaf, defineBranch } from '../../core/command.js';
10
10
  import { InputError } from '../../core/io.js';
11
- import { isDaemonServing, spawnDaemon, stopDaemonProcess, sweepStrayDaemons } from '../../daemon/manage.js';
11
+ import { probeDaemonServing, spawnDaemon, stopDaemonProcess, sweepStrayDaemons } from '../../daemon/manage.js';
12
12
  import { isDaemonRunning, readPidfile, isPidAlive } from '../../daemon/pidfile.js';
13
13
  import { cliClient } from '../api-client.js';
14
14
  // daemon start
@@ -26,7 +26,7 @@ const daemonStart = defineLeaf({
26
26
  { name: 'existing_pid', type: 'number', required: false, constraint: 'PID of the already-running daemon (when started=false).' },
27
27
  { name: 'running', type: 'boolean', required: false, constraint: 'True only when a live pidfile owner does not serve its Unix API socket.' },
28
28
  { name: 'serving', type: 'boolean', required: false, constraint: 'False only when a live pidfile owner does not answer /healthz.' },
29
- { name: 'next', type: 'string', required: false, constraint: 'Recovery instruction when running=true and serving=false.' },
29
+ { name: 'probe_error', type: 'string', required: false, constraint: 'The final /healthz probe failure when a live owner missed readiness.' },
30
30
  ],
31
31
  outputKind: 'object',
32
32
  effects: [
@@ -50,9 +50,9 @@ const daemonStatus = defineLeaf({
50
50
  params: [],
51
51
  output: [
52
52
  { name: 'running', type: 'boolean', required: true, constraint: 'True when the daemon pidfile owner is alive.' },
53
- { name: 'serving', type: 'boolean', required: false, constraint: 'True when the live daemon answers /healthz; false when it does not.' },
53
+ { name: 'serving', type: 'boolean', required: false, constraint: 'True when the live daemon answers /healthz; false when one probe does not.' },
54
54
  { name: 'pid', type: 'number', required: false, constraint: 'PID of the running daemon.' },
55
- { name: 'next', type: 'string', required: false, constraint: 'Recovery instruction when running=true and serving=false.' },
55
+ { name: 'probe_error', type: 'string', required: false, constraint: 'The failed /healthz observation when the live owner did not answer.' },
56
56
  ],
57
57
  outputKind: 'object',
58
58
  effects: ['Read-only: reads the pidfile, probes the pid, and requests /healthz without autostart.'],
@@ -62,15 +62,13 @@ const daemonStatus = defineLeaf({
62
62
  const running = pid !== null && isPidAlive(pid);
63
63
  if (!running)
64
64
  return { running: false };
65
- const serving = await isDaemonServing();
66
- return serving
67
- ? { running: true, serving: true, pid }
68
- : {
69
- running: true,
70
- serving: false,
71
- pid,
72
- next: 'Run `crtr sys daemon stop` to release the non-serving daemon before starting it again.',
73
- };
65
+ try {
66
+ await probeDaemonServing();
67
+ return { running: true, serving: true, pid };
68
+ }
69
+ catch (error) {
70
+ return { running: true, serving: false, pid, probe_error: error.message };
71
+ }
74
72
  },
75
73
  });
76
74
  // daemon restart
@@ -23,8 +23,6 @@ function projectionFor(session, generation) {
23
23
  notifyTurnAccepted: () => undefined,
24
24
  emitStartupMilestone: () => undefined,
25
25
  formatModelSpec: () => null,
26
- refreshIntent: async () => false,
27
- onRefreshIntentError: () => undefined,
28
26
  });
29
27
  }
30
28
  test('agent_start arms the provider watchdog before another engine event arrives', (t) => {
@@ -294,7 +294,7 @@ test('verifyDaemonStartup throws after its tiny startup window expires', async (
294
294
  sleepMs: (ms) => {
295
295
  now += ms;
296
296
  },
297
- }), /did not become ready within 25ms/);
297
+ }), /has no live pidfile owner/);
298
298
  });
299
299
  // REGRESSION (bug #102): the verifier polls with a real async sleep, NOT a
300
300
  // synchronous Atomics.wait, so the event loop stays free to dispatch the spawned
@@ -101,6 +101,8 @@ export interface Harness {
101
101
  inbox(nodeId: string): InboxEntry[];
102
102
  injected(nodeId: string): Injected[];
103
103
  fakeCmd(nodeId: string, cmd: Record<string, unknown>): void;
104
+ /** Stop only this harness's API server to exercise a broker daemon-read failure. */
105
+ stopApiServer(): Promise<void>;
104
106
  dialogResults(nodeId: string): {
105
107
  resolved: unknown;
106
108
  ms: number;
@@ -637,6 +637,15 @@ export async function createHarness(opts = {}) {
637
637
  fakeCmd(nodeId, cmd) {
638
638
  sendCmd(nodeId, cmd);
639
639
  },
640
+ async stopApiServer() {
641
+ try {
642
+ apiServerProc.kill('SIGTERM');
643
+ }
644
+ catch {
645
+ /* already gone */
646
+ }
647
+ await waitFor(() => apiServerProc.exitCode === null ? null : true, { label: 'harness API server exit' });
648
+ },
640
649
  dialogResults(nodeId) {
641
650
  return readLines(join(nodeDir(nodeId), 'fake-pi.dialog.jsonl'))
642
651
  .map((l) => {
@@ -10,6 +10,7 @@ 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 { closeManagedWorktreeAsync } from '../../worktree-close.js';
13
14
  import { reconcileManagedWorktree } from '../../worktree-sweep.js';
14
15
  import { createNode, getNode } from '../../canvas/canvas.js';
15
16
  import { closeDb } from '../../canvas/db.js';
@@ -223,6 +224,55 @@ test('closeManagedWorktree fast-forwards local main to the worktree tip without
223
224
  const originMainAfter = gitSync(['rev-parse', 'origin/main'], sourceDir).stdout.trim();
224
225
  assert.equal(originMainAfter, originMainBefore, 'origin/main must not move — landing is local-only');
225
226
  });
227
+ test('async close leaves an upstream-contained tip and a diverged local base byte-for-byte unchanged (#958)', async () => {
228
+ const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-contained-close-'));
229
+ const remote = mkdtempSync(join(tmpdir(), 'crtr-worktree-contained-close-remote-'));
230
+ try {
231
+ run(['init', '-b', 'main'], repo);
232
+ run(['config', 'user.email', 'test@example.com'], repo);
233
+ run(['config', 'user.name', 'Test'], repo);
234
+ writeFileSync(join(repo, 'README.md'), 'initial\n');
235
+ run(['add', '.'], repo);
236
+ run(['commit', '-m', 'initial'], repo);
237
+ run(['init', '--bare'], remote);
238
+ run(['remote', 'add', 'origin', remote], repo);
239
+ run(['push', '-u', 'origin', 'main'], repo);
240
+ const nodeId = 'wt-contained-diverged-base';
241
+ const wt = createManagedWorktree(repo, nodeId);
242
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
243
+ writeFileSync(join(wt.path, 'delivered.txt'), 'already delivered\n');
244
+ run(['add', '.'], wt.path);
245
+ run(['commit', '-m', 'delivered work'], wt.path);
246
+ const deliveredHead = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
247
+ run(['push', 'origin', `${wt.branch}:main`], wt.path);
248
+ run(['fetch', 'origin'], repo);
249
+ writeFileSync(join(repo, 'local-only.txt'), 'must not receive a replay\n');
250
+ run(['add', '.'], repo);
251
+ run(['commit', '-m', 'independent local base work'], repo);
252
+ const before = {
253
+ childHead: gitSync(['rev-parse', 'HEAD'], wt.path).stdout,
254
+ baseRef: gitSync(['rev-parse', 'refs/heads/main'], repo).stdout,
255
+ baseHead: gitSync(['rev-parse', 'HEAD'], repo).stdout,
256
+ status: gitSync(['status', '--porcelain=v1'], repo).stdout,
257
+ worktree: gitSync(['worktree', 'list', '--porcelain'], repo).stdout,
258
+ index: readFileSync(join(repo, '.git', 'index')),
259
+ };
260
+ const result = await closeManagedWorktreeAsync(nodeId);
261
+ assert.equal(result.landed_sha, deliveredHead);
262
+ assert.deepEqual({
263
+ childHead: gitSync(['rev-parse', 'HEAD'], wt.path).stdout,
264
+ baseRef: gitSync(['rev-parse', 'refs/heads/main'], repo).stdout,
265
+ baseHead: gitSync(['rev-parse', 'HEAD'], repo).stdout,
266
+ status: gitSync(['status', '--porcelain=v1'], repo).stdout,
267
+ worktree: gitSync(['worktree', 'list', '--porcelain'], repo).stdout,
268
+ index: readFileSync(join(repo, '.git', 'index')),
269
+ }, before, 'a delivered child must not rebase or move the divergent local base');
270
+ }
271
+ finally {
272
+ rmSync(repo, { recursive: true, force: true });
273
+ rmSync(remote, { recursive: true, force: true });
274
+ }
275
+ });
226
276
  test('custom local base is recorded and fast-forwarded without touching main or origin', () => {
227
277
  const nodeId = 'wt-custom-base';
228
278
  run(['branch', 'feature/custom-base'], sourceDir);
@@ -4,20 +4,28 @@
4
4
  // (worktree-land.test.ts) only so the two run as two parallel files.
5
5
  import { test, before, after, beforeEach } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { mkdtempSync, rmSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
8
- import { tmpdir } from 'node:os';
7
+ import { mkdtempSync, mkdirSync, renameSync, rmSync, existsSync, writeFileSync, chmodSync, realpathSync } from 'node:fs';
8
+ import { spawn } from 'node:child_process';
9
+ import { homedir, tmpdir } from 'node:os';
9
10
  import { join } from 'node:path';
10
11
  import { gitSync } from '../../git.js';
11
- import { createManagedWorktree, closeManagedWorktree } from '../../worktree.js';
12
+ import { withExclusiveDirectoryLockAsync } from '../../exclusive-lock.js';
13
+ import { createManagedWorktree, closeManagedWorktree, worktreeLockPathForCommonDir } from '../../worktree.js';
12
14
  import { reconcileManagedWorktree } from '../../worktree-sweep.js';
13
15
  import { createApiServer } from '../../../daemon/api/server.js';
16
+ import { ManagedWorktreeSweepReconciler } from '../../../daemon/reconcilers/managed-worktree-sweep.js';
14
17
  import { apiSocketPath } from '../../canvas/paths.js';
15
- import { CrtrClient } from '../../../api/index.js';
18
+ import { ApiError, CrtrClient } from '../../../api/index.js';
16
19
  import { createNode, getNode } from '../../canvas/canvas.js';
20
+ import { headlessBrokerHost } from '../../runtime/host.js';
21
+ import { reviveNode } from '../../runtime/revive.js';
22
+ import { bindTestFleet } from '../helpers/fleet.js';
23
+ import { resetFleetForTests } from '../../runtime/fleet.js';
17
24
  import { closeDb } from '../../canvas/db.js';
18
25
  let home;
19
26
  let remoteDir;
20
27
  let sourceDir;
28
+ const scratchRoot = join(homedir(), '.crouter', 'canvas', 'scratch');
21
29
  function run(args, cwd) {
22
30
  const res = gitSync(args, cwd);
23
31
  if (res.status !== 0) {
@@ -42,7 +50,8 @@ before(() => {
42
50
  // `/var` → `/private/var` symlink, so the recorded managed-worktree path and
43
51
  // git's canonical `worktree list` output differ as strings while naming one
44
52
  // directory. Every path-ownership check must judge identity, not spelling.
45
- home = mkdtempSync(join(tmpdir(), 'crtr-worktree-home-'));
53
+ mkdirSync(scratchRoot, { recursive: true });
54
+ home = mkdtempSync(join(scratchRoot, 'crtr-worktree-home-'));
46
55
  process.env['CRTR_HOME'] = home;
47
56
  // Bare "remote" wired up as origin so the test can prove landing NEVER pushes
48
57
  // to it (managed worktrees are based on and land onto local `main` only).
@@ -97,6 +106,104 @@ test('autoDropCleanManagedWorktree drops a clean, zero-commit worktree', async (
97
106
  const swept = getNode(nodeId)?.managed_worktree;
98
107
  assert.deepEqual({ state: swept?.state, cleanup: swept?.cleanup }, { state: 'closed', cleanup: 'complete' });
99
108
  });
109
+ test('a dead managed child keeps its open checkout through the sweep and revives there (#962)', async () => {
110
+ const repo = mkdtempSync(join(scratchRoot, 'crtr-worktree-962-'));
111
+ const nodeId = 'wt-sweep-dead-open';
112
+ const originalLaunch = headlessBrokerHost.launch;
113
+ try {
114
+ run(['init', '-b', 'main'], repo);
115
+ run(['config', 'user.email', 'test@example.com'], repo);
116
+ run(['config', 'user.name', 'Test'], repo);
117
+ writeFileSync(join(repo, 'README.md'), 'hello\n');
118
+ run(['add', '.'], repo);
119
+ run(['commit', '-m', 'init'], repo);
120
+ const wt = createManagedWorktree(repo, nodeId);
121
+ createNode(node(nodeId, { cwd: wt.path, status: 'dead', managed_worktree: wt }));
122
+ const reconciler = new ManagedWorktreeSweepReconciler();
123
+ let pass;
124
+ reconciler.run(Date.now(), {
125
+ lifecycle: {
126
+ acceptsDetachedWork: () => true,
127
+ registerDetached: (work) => { pass = work; },
128
+ },
129
+ fleet: { isClaimed: () => false },
130
+ });
131
+ await pass;
132
+ const afterSweep = getNode(nodeId);
133
+ assert.equal(afterSweep.managed_worktree?.state, 'open', 'a failed launch does not authorize disposal');
134
+ assert.equal(afterSweep.cwd, wt.path, 'the sweep leaves the durable launch cwd unchanged');
135
+ assert.ok(existsSync(wt.path), 'the isolated checkout remains available for recovery');
136
+ const testFleet = bindTestFleet();
137
+ const launchCwds = [];
138
+ headlessBrokerHost.launch = (id, _inv, options) => {
139
+ launchCwds.push(options.cwd);
140
+ const handle = { pid: process.pid, exited: new Promise(() => { }) };
141
+ testFleet.fleet.register(id, handle);
142
+ return handle;
143
+ };
144
+ const revived = await reviveNode(nodeId, { resume: false, capacity: 'freeze' });
145
+ assert.equal(revived.outcome, 'launched');
146
+ assert.deepEqual(launchCwds, [wt.path], 'a fresh revive launches in the retained managed checkout');
147
+ const closedId = 'wt-sweep-closed';
148
+ const closedWt = createManagedWorktree(repo, closedId);
149
+ createNode(node(closedId, { cwd: closedWt.path, status: 'done', managed_worktree: closedWt }));
150
+ closeManagedWorktree(closedId);
151
+ const cleanup = await reconcileManagedWorktree(closedId, { stillEligible: () => true });
152
+ assert.equal(cleanup.status, 'complete', 'a successful close is still physically cleaned after its engine exits');
153
+ assert.equal(getNode(closedId)?.cwd, repo, 'a successful close deliberately repoints future launch cwd to the repository root');
154
+ launchCwds.length = 0;
155
+ const closedRevive = await reviveNode(closedId, { resume: false, capacity: 'freeze' });
156
+ assert.equal(closedRevive.outcome, 'launched');
157
+ assert.deepEqual(launchCwds, [repo], 'a closed node still revives against the repository root');
158
+ }
159
+ finally {
160
+ headlessBrokerHost.launch = originalLaunch;
161
+ resetFleetForTests();
162
+ rmSync(repo, { recursive: true, force: true });
163
+ }
164
+ });
165
+ test('the sweep does not conclude a missing record while its branch survives in a moved repository', async () => {
166
+ const repo = mkdtempSync(join(scratchRoot, 'crtr-worktree-moved-root-'));
167
+ const movedRepo = join(scratchRoot, `crtr-worktree-moved-root-destination-${Date.now()}`);
168
+ const nodeId = 'wt-sweep-moved-root';
169
+ try {
170
+ run(['init', '-b', 'main'], repo);
171
+ run(['config', 'user.email', 'test@example.com'], repo);
172
+ run(['config', 'user.name', 'Test'], repo);
173
+ writeFileSync(join(repo, 'README.md'), 'hello\n');
174
+ run(['add', '.'], repo);
175
+ run(['commit', '-m', 'init'], repo);
176
+ const wt = createManagedWorktree(repo, nodeId);
177
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
178
+ run(['worktree', 'remove', wt.path], repo);
179
+ renameSync(repo, movedRepo);
180
+ const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
181
+ assert.deepEqual(outcome.status === 'refused' ? outcome.reason : undefined, 'repo_unavailable');
182
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'a missing recorded path cannot prove the worktree branch is gone');
183
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], movedRepo).status, 0, 'the moved repository still holds the managed branch');
184
+ }
185
+ finally {
186
+ rmSync(repo, { recursive: true, force: true });
187
+ rmSync(movedRepo, { recursive: true, force: true });
188
+ }
189
+ });
190
+ test('the sweep retries containment when the base upstream advances', async () => {
191
+ const nodeId = 'wt-sweep-upstream-advance';
192
+ const wt = createManagedWorktree(sourceDir, nodeId);
193
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
194
+ writeFileSync(join(wt.path, 'delivered.txt'), 'delivered remotely\n');
195
+ run(['add', 'delivered.txt'], wt.path);
196
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'delivered work'], wt.path);
197
+ const refused = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
198
+ assert.equal(refused.status, 'refused');
199
+ const before = getNode(nodeId)?.managed_worktree?.sweep?.observed;
200
+ assert.ok(before, 'the initial refusal records the observation that caused backoff');
201
+ run(['push', 'origin', `${wt.branch}:main`], wt.path);
202
+ run(['fetch', 'origin'], sourceDir);
203
+ const completed = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
204
+ assert.equal(completed.status, 'complete', 'a newly contained branch is re-proved instead of remaining in backoff');
205
+ assert.equal(existsSync(wt.path), false, 'the newly contained checkout is removed');
206
+ });
100
207
  test('the sweep refuses a managed checkout with uncommitted changes', async () => {
101
208
  const nodeId = 'wt-sweep-dirty';
102
209
  const wt = createManagedWorktree(sourceDir, nodeId);
@@ -297,6 +404,78 @@ test('autoDropCleanManagedWorktree refuses a worktree with real uncommitted chan
297
404
  assert.equal(result, null, 'real uncommitted changes must never be silently dropped');
298
405
  assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open');
299
406
  });
407
+ test('async close returns the typed lock refusal before the client socket timeout (#1024)', async () => {
408
+ const nodeId = 'wt-async-close-busy';
409
+ const wt = createManagedWorktree(sourceDir, nodeId);
410
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
411
+ const commonDir = realpathSync(join(sourceDir, gitSync(['rev-parse', '--git-common-dir'], sourceDir).stdout.trim()));
412
+ const lockModule = new URL('../../exclusive-lock.ts', import.meta.url).href;
413
+ const child = spawn(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '--eval', `
414
+ const { withExclusiveDirectoryLockAsync } = await import(${JSON.stringify(lockModule)});
415
+ await withExclusiveDirectoryLockAsync(${JSON.stringify(worktreeLockPathForCommonDir(commonDir))}, async () => {
416
+ console.log('locked');
417
+ await new Promise((resolve) => setTimeout(resolve, 40_000));
418
+ });
419
+ `], { env: { ...process.env, CRTR_HOME: home }, stdio: ['ignore', 'pipe', 'pipe'] });
420
+ try {
421
+ await new Promise((resolve, reject) => {
422
+ let output = '';
423
+ child.stdout.on('data', (chunk) => { output += chunk; if (output.includes('locked'))
424
+ resolve(); });
425
+ child.once('error', reject);
426
+ child.once('exit', (code) => reject(new Error(`lock holder exited before acquiring the lock (${code})`)));
427
+ });
428
+ const server = createApiServer();
429
+ try {
430
+ await new Promise((resolve) => setTimeout(resolve, 25));
431
+ const client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
432
+ const started = Date.now();
433
+ await assert.rejects(client.closeWorktree(nodeId), (error) => error instanceof ApiError && error.code === 'worktree_lock_timeout');
434
+ assert.ok(Date.now() - started < 30_000, 'the daemon must serialize the typed busy refusal before the 30s client timeout');
435
+ }
436
+ finally {
437
+ await server.close();
438
+ }
439
+ }
440
+ finally {
441
+ child.kill('SIGTERM');
442
+ }
443
+ });
444
+ test('async close yields while an async repository holder runs, keeping API requests responsive (#1024)', async () => {
445
+ const zeroId = 'wt-async-close-zero';
446
+ const committedId = 'wt-async-close-committed';
447
+ const zero = createManagedWorktree(sourceDir, zeroId);
448
+ const committed = createManagedWorktree(sourceDir, committedId);
449
+ createNode(node(zeroId, { cwd: zero.path, managed_worktree: zero }));
450
+ createNode(node(committedId, { cwd: committed.path, managed_worktree: committed }));
451
+ writeFileSync(join(committed.path, 'committed.txt'), 'work\n');
452
+ run(['add', 'committed.txt'], committed.path);
453
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'work'], committed.path);
454
+ const commonDir = realpathSync(join(sourceDir, gitSync(['rev-parse', '--git-common-dir'], sourceDir).stdout.trim()));
455
+ let release;
456
+ const held = new Promise((resolve) => { release = resolve; });
457
+ const holder = withExclusiveDirectoryLockAsync(worktreeLockPathForCommonDir(commonDir), async () => { await held; });
458
+ await new Promise((resolve) => setTimeout(resolve, 25));
459
+ const server = createApiServer();
460
+ try {
461
+ await new Promise((resolve) => setTimeout(resolve, 25));
462
+ const client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
463
+ const zeroClose = client.closeWorktree(zeroId);
464
+ const committedClose = client.closeWorktree(committedId);
465
+ const responsive = await Promise.race([client.listNodes().then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 250))]);
466
+ assert.equal(responsive, true, 'a waiting close must not block another daemon request');
467
+ release();
468
+ await holder;
469
+ const [zeroResult, committedResult] = await Promise.all([zeroClose, committedClose]);
470
+ assert.equal(zeroResult.landed_sha, zero.base_sha, 'the zero-commit close returns its actual result after the holder releases');
471
+ assert.equal(committedResult.landed_sha, gitSync(['rev-parse', 'HEAD'], committed.path).stdout.trim(), 'the committed close returns its actual landed result after the holder releases');
472
+ }
473
+ finally {
474
+ release();
475
+ await holder;
476
+ await server.close();
477
+ }
478
+ });
300
479
  test('abandon preserves committed and dirty work, records its retained tip, and releases finalization', async () => {
301
480
  const nodeId = 'wt-abandon';
302
481
  const wt = createManagedWorktree(sourceDir, nodeId);
@@ -23,6 +23,9 @@ const PROFILE_SET_SHAPED_PARAMS = [
23
23
  { kind: 'flag', name: 'name', type: 'string', required: true, constraint: 'var name' },
24
24
  { kind: 'stdin', name: 'value', required: true, constraint: 'value piped on stdin' },
25
25
  ];
26
+ const STRING_FLAG_PARAMS = [
27
+ { kind: 'flag', name: 'arg', type: 'string', required: true, constraint: 'argument' },
28
+ ];
26
29
  test('a lone positional supplies the stdin body on a leaf declaring no positional param', async () => {
27
30
  const result = await parseArgv(YIELD_SHAPED_PARAMS, ['refresh against the original goal']);
28
31
  assert.equal(result['message'], 'refresh against the original goal');
@@ -42,6 +45,17 @@ test('allowPositional:false keeps a stdin-only leaf rejecting positionals by ari
42
45
  return true;
43
46
  });
44
47
  });
48
+ test('a value bound with = may start with --', async () => {
49
+ const result = await parseArgv(STRING_FLAG_PARAMS, ['--arg=--help']);
50
+ assert.equal(result['arg'], '--help');
51
+ });
52
+ test('a separate flag value starting with -- is rejected as missing', async () => {
53
+ await assert.rejects(() => parseArgv(STRING_FLAG_PARAMS, ['--arg', '--help']), (err) => {
54
+ assert.equal(err.code, 'missing_parameter');
55
+ assert.equal(err.message, '--arg requires a value');
56
+ return true;
57
+ });
58
+ });
45
59
  test('an extra positional on a stdin-bearing leaf is rejected without echoing it', async () => {
46
60
  const secret = 'SUPERSECRET_ARGV';
47
61
  await assert.rejects(() => parseArgv(PROFILE_SET_SHAPED_PARAMS, ['some-profile', secret, '--name', 'REVIEW_TOKEN']), (err) => {
@@ -161,3 +161,16 @@ test('G3 — a deliver whose prompt rejects acks ok:false and records deliver_fa
161
161
  assert.equal(okAck.type === 'ack' && okAck.ok, true, 'G3: an accepted prompt acks delivered:true');
162
162
  assert.equal(okAck.type === 'ack' ? okAck.detail : undefined, 'prompt');
163
163
  });
164
+ // A message_end is only a broker/viewer relay. It must not depend on a daemon
165
+ // read: a daemon outage after boot leaves tool results and the run settlement intact.
166
+ test('G4 — a daemon read failure after boot does not interrupt a tool result or its settlement', async () => {
167
+ const c = await ctrl('g4-ctrl');
168
+ const pid = brokerPid();
169
+ await h.stopApiServer();
170
+ const token = tok('G4-TOOL-RESULT');
171
+ h.fakeCmd(id, { cmd: 'stream', text: token, updates: 0 });
172
+ await c.waitFrame((f) => f.type === 'tool_execution_end', 'G4 tool result relayed after the daemon server exits');
173
+ await c.waitFrame((f) => f.type === 'agent_settled', 'G4 run settles after the daemon server exits');
174
+ await delay(200);
175
+ assert.equal(isPidAlive(pid), true, 'G4: broker remains alive after relaying and settling the tool result');
176
+ });