@north-light/crouter 0.3.303 → 0.3.305

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 (102) 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/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +87 -0
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +31 -26
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +33 -26
  13. package/dist/clients/attach/viewer.js +584 -584
  14. package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
  15. package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
  16. package/dist/commands/api-client.d.ts +3 -3
  17. package/dist/commands/api-client.js +3 -3
  18. package/dist/commands/memory/read.js +2 -1
  19. package/dist/commands/node/bash.js +6 -6
  20. package/dist/commands/node/create.js +7 -1
  21. package/dist/commands/node/message.js +3 -3
  22. package/dist/commands/node/wait.js +1 -1
  23. package/dist/commands/node-worktree.js +5 -1
  24. package/dist/commands/sys/daemon.js +11 -13
  25. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
  26. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  27. package/dist/core/__tests__/fixtures/fake-engine.js +14 -15
  28. package/dist/core/__tests__/helpers/harness.d.ts +2 -0
  29. package/dist/core/__tests__/helpers/harness.js +9 -0
  30. package/dist/core/__tests__/integration/tmux-surface.test.js +53 -0
  31. package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
  32. package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
  33. package/dist/core/__tests__/kickoff.test.js +40 -0
  34. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
  35. package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
  36. package/dist/core/__tests__/seam/broker-provider-retry.test.js +25 -0
  37. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
  38. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
  39. package/dist/core/__tests__/seam/deadline-wake-delivery.test.d.ts +1 -0
  40. package/dist/core/__tests__/seam/deadline-wake-delivery.test.js +138 -0
  41. package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.d.ts +1 -0
  42. package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.js +60 -0
  43. package/dist/core/bash-jobs.d.ts +20 -8
  44. package/dist/core/bash-jobs.js +40 -21
  45. package/dist/core/canvas/types.d.ts +4 -2
  46. package/dist/core/command.js +1 -1
  47. package/dist/core/fault-classifier.d.ts +7 -1
  48. package/dist/core/fault-classifier.js +9 -4
  49. package/dist/core/provider-failure.d.ts +30 -0
  50. package/dist/core/provider-failure.js +86 -0
  51. package/dist/core/runtime/broker/event-projection.d.ts +0 -3
  52. package/dist/core/runtime/broker/event-projection.js +2 -15
  53. package/dist/core/runtime/broker/fault-retry.js +16 -0
  54. package/dist/core/runtime/broker-persona-guidance.js +12 -0
  55. package/dist/core/runtime/broker.js +0 -5
  56. package/dist/core/runtime/fault.d.ts +2 -1
  57. package/dist/core/runtime/fault.js +39 -1
  58. package/dist/core/runtime/host.js +10 -1
  59. package/dist/core/runtime/kickoff.d.ts +3 -0
  60. package/dist/core/runtime/kickoff.js +24 -4
  61. package/dist/core/runtime/reopen.d.ts +2 -2
  62. package/dist/core/runtime/reopen.js +6 -4
  63. package/dist/core/runtime/revive.js +3 -3
  64. package/dist/core/runtime/spawn.js +5 -6
  65. package/dist/core/runtime/tmux-bindings.js +14 -3
  66. package/dist/core/termrender/version.d.ts +1 -1
  67. package/dist/core/termrender/version.js +1 -1
  68. package/dist/core/worktree-close.d.ts +4 -0
  69. package/dist/core/worktree-close.js +228 -0
  70. package/dist/core/worktree-containment.d.ts +14 -0
  71. package/dist/core/worktree-containment.js +48 -0
  72. package/dist/core/worktree-mutation-async.d.ts +13 -0
  73. package/dist/core/worktree-mutation-async.js +281 -0
  74. package/dist/core/worktree-sweep.js +24 -82
  75. package/dist/core/worktree.d.ts +1 -0
  76. package/dist/core/worktree.js +7 -4
  77. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
  78. package/dist/daemon/api/__tests__/reopen-delivery.test.js +31 -0
  79. package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
  80. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +36 -4
  81. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  82. package/dist/daemon/api/handlers/messages.js +33 -14
  83. package/dist/daemon/api/handlers/reports.js +3 -2
  84. package/dist/daemon/api/handlers/worktree.js +7 -6
  85. package/dist/daemon/fleet.d.ts +1 -1
  86. package/dist/daemon/fleet.js +30 -12
  87. package/dist/daemon/manage.d.ts +8 -5
  88. package/dist/daemon/manage.js +63 -40
  89. package/dist/daemon/reconcilers/broker-supervision.js +10 -1
  90. package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
  91. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
  92. package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
  93. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
  94. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
  95. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
  96. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
  97. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
  98. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  99. package/dist/pi-extensions/canvas-bash-valve.js +73 -43
  100. package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
  101. package/package.json +1 -1
  102. 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',
@@ -82,8 +82,9 @@ function nodeNewParams() {
82
82
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name.' },
83
83
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
84
84
  { kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
85
- { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent resident root with no parent or subscription. Mutually exclusive with --worktree.' },
85
+ { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent root with no parent or subscription. Mutually exclusive with --worktree.' },
86
86
  { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Pass it ONLY when the user will open this node’s viewer pane and work with it back and forth; a child that only other nodes message stays terminal. Applies to managed children and roots; roots default to resident.' },
87
+ { kind: 'flag', name: 'terminal', type: 'bool', required: false, constraint: 'Birth the node as terminal: it finalizes when its work is done and leaves nothing wakeable behind. Only meaningful with --root, whose default is resident; managed children are already terminal. Pass it for bounded programmatic work no one will open a viewer on. Mutually exclusive with --resident.' },
87
88
  { kind: 'flag', name: 'worktree', type: 'string', required: false, constraint: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.' },
88
89
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the new node from an existing node id, absolute session path, or partial pi session uuid.' },
89
90
  {
@@ -190,6 +191,10 @@ async function runNodeCreation(input) {
190
191
  const parent = input['parent'];
191
192
  const root = input['root'] === true;
192
193
  const resident = input['resident'] === true;
194
+ const terminal = input['terminal'] === true;
195
+ if (resident && terminal) {
196
+ throw new InputError({ error: 'lifecycle_conflict', message: '--resident and --terminal cannot both be passed', field: 'terminal', next: 'Pass the one birth lifecycle you want, or neither to take the default (resident for --root, terminal for a managed child).' });
197
+ }
193
198
  const worktreeBaseRaw = input['worktree'];
194
199
  if (worktreeBaseRaw !== undefined && worktreeBaseRaw.trim() === '') {
195
200
  throw new InputError({ error: 'worktree_base_required', message: '--worktree requires a base branch', field: 'worktree', next: 'Pass an existing local branch that landing can fast-forward.' });
@@ -257,6 +262,7 @@ async function runNodeCreation(input) {
257
262
  ...(root ? { root: true } : { parent: parentId ?? null }),
258
263
  ...(creator !== undefined ? { creator } : {}),
259
264
  ...(resident ? { root_lifecycle: 'resident' } : {}),
265
+ ...(terminal ? { root_lifecycle: 'terminal' } : {}),
260
266
  ...(worktreeBase !== undefined ? { worktree: worktreeBase } : {}),
261
267
  ...(forkFrom !== undefined ? { fork_from: forkFrom } : {}),
262
268
  ...(modelSpec !== undefined ? { model: modelSpec } : {}),
@@ -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']),
@@ -46,7 +46,7 @@ function deadlineHeredoc(body, nodeId) {
46
46
  const lines = new Set(body.split('\n'));
47
47
  while (lines.has(delimiter))
48
48
  delimiter = `CRTR_DEADLINE_${++suffix}`;
49
- return `crtr cron cancel "$CRTR_CRON_ID" && crtr node message send --to ${shellQuote(nodeId)} --tier urgent <<'${delimiter}'\n${body}\n${delimiter}`;
49
+ return `crtr cron cancel "$CRTR_CRON_ID"\ncrtr node message send --to ${shellQuote(nodeId)} --tier urgent <<'${delimiter}'\n${body}\n${delimiter}`;
50
50
  }
51
51
  const nodeWaitDeadline = defineLeaf({
52
52
  name: 'deadline',
@@ -85,7 +85,8 @@ const worktreeClose = defineLeaf({
85
85
  { name: 'worktree_removed', type: 'boolean', required: true, constraint: 'Always false for an active caller: its checkout remains intact rather than being removed beneath the node.' },
86
86
  { name: 'worktree_remove_error', type: 'string', required: true, constraint: 'The named deferred-cleanup state and safe manual follow-up after the caller exits.' },
87
87
  { name: 'branch_deleted', type: 'boolean', required: true, constraint: 'False while the deferred-cleanup checkout still has this branch checked out.' },
88
- { name: 'branch_delete_error', type: 'string', required: true, constraint: 'The safe branch-cleanup follow-up after the checkout is removed.' }
88
+ { name: 'branch_delete_error', type: 'string', required: true, constraint: 'The safe branch-cleanup follow-up after the checkout is removed.' },
89
+ { name: 'base_checkout_stash', type: 'string', required: false, constraint: 'Present when conflicting base-checkout changes were retained in a stash; names the exact stash SHA and command required to recover them.' }
89
90
  ],
90
91
  outputKind: 'object',
91
92
  effects: [
@@ -116,6 +117,9 @@ const worktreeClose = defineLeaf({
116
117
  if (r['branch_deleted'] === false) {
117
118
  notes.push(String(r['branch_delete_error'] ?? `local branch ${branch} could not be deleted automatically.`));
118
119
  }
120
+ if (typeof r['base_checkout_stash'] === 'string') {
121
+ notes.push(`Warning: ${r['base_checkout_stash']}`);
122
+ }
119
123
  if (notes.length > 0)
120
124
  return `${base} Note: ${notes.join(' ')}`;
121
125
  return base;
@@ -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
@@ -35,6 +35,7 @@ import { join, dirname } from 'node:path';
35
35
  import { randomUUID } from 'node:crypto';
36
36
  import { DefaultResourceLoader, getAgentDir } from '@earendil-works/pi-coding-agent';
37
37
  import { MANAGED_PROVIDER_COOLING_DIAGNOSTIC } from '../../runtime/managed-provider-cooling.js';
38
+ import { PROVIDER_FAILURE_DIAGNOSTIC } from '../../provider-failure.js';
38
39
  import registerCanvasBashValve, { createValveOperations } from '../../../pi-extensions/canvas-bash-valve.js';
39
40
  /** Test seams for prompt-scoped failures before session_start. The one-shot
40
41
  * variant records its first failure per node so the next cycling retry can
@@ -1085,6 +1086,18 @@ class FakeSession {
1085
1086
  // before the stophook sees agent_end while the run is active; the
1086
1087
  // subscriber receives agent_settled after it clears.
1087
1088
  this.streaming = true;
1089
+ const diagnostics = [
1090
+ ...(cmd.deadline === undefined ? [] : [{
1091
+ type: MANAGED_PROVIDER_COOLING_DIAGNOSTIC,
1092
+ timestamp: Date.now(),
1093
+ details: { rateLimitedUntil: cmd.deadline },
1094
+ }]),
1095
+ ...(cmd.providerFailure === undefined ? [] : [{
1096
+ type: PROVIDER_FAILURE_DIAGNOSTIC,
1097
+ timestamp: Date.now(),
1098
+ details: cmd.providerFailure,
1099
+ }]),
1100
+ ];
1088
1101
  const stopMessages = [
1089
1102
  {
1090
1103
  role: 'assistant',
@@ -1093,21 +1106,7 @@ class FakeSession {
1093
1106
  // turn; the stophook classifies it for the active-fault marker.
1094
1107
  // Forward it so a test can drive a CONNECTION stall faithfully.
1095
1108
  ...(cmd.errorMessage === undefined ? {} : { errorMessage: cmd.errorMessage }),
1096
- // A managed-provider cooling terminal carries the earliest pool
1097
- // deadline as a typed diagnostic that survives verbatim into
1098
- // agent_end.messages; the stophook reads it (extractCoolingDeadline)
1099
- // to stamp retry.nextAt. Attached only when a test drives `deadline`.
1100
- ...(cmd.deadline === undefined
1101
- ? {}
1102
- : {
1103
- diagnostics: [
1104
- {
1105
- type: MANAGED_PROVIDER_COOLING_DIAGNOSTIC,
1106
- timestamp: Date.now(),
1107
- details: { rateLimitedUntil: cmd.deadline },
1108
- },
1109
- ],
1110
- }),
1109
+ ...(diagnostics.length === 0 ? {} : { diagnostics }),
1111
1110
  content: [{ type: 'text', text: cmd.text ?? '' }],
1112
1111
  },
1113
1112
  ];
@@ -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) => {
@@ -275,3 +275,56 @@ test('tmux installer reads pair-array manifests and replaces owned pairs', (t) =
275
275
  isolatedTmux(socket, ['kill-server']);
276
276
  }
277
277
  });
278
+ // Regression: a passthrough wrapper survives the user re-sourcing their own tmux
279
+ // config over it. Crouter wraps an occupied root key and fingerprints the result;
280
+ // every `source-file` (and every fresh tmux server) re-installs the user's own
281
+ // bind-key line over that wrapper. Reading the mismatch as a deliberate rebind
282
+ // relinquished the key permanently, so the attach viewer silently stopped
283
+ // receiving its own scroll keys and no reconciliation ever restored them.
284
+ test('tmux passthrough re-wraps a re-sourced config but yields to a real rebind', (t) => {
285
+ if (spawnSync('tmux', ['-V']).status !== 0) {
286
+ t.skip('tmux is unavailable');
287
+ return;
288
+ }
289
+ const name = `crtr-passthrough-${process.pid}-${Date.now()}`;
290
+ const boot = spawnSync('tmux', ['-L', name, '-f', '/dev/null', 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
291
+ assert.equal(boot.status, 0, boot.stderr);
292
+ const socket = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' }).stdout.trim();
293
+ assert.notEqual(socket, '');
294
+ const priorTmux = process.env['TMUX'];
295
+ process.env['TMUX'] = `${socket},1,0`;
296
+ const userBinding = ['bind-key', '-T', 'root', 'C-k', 'copy-mode', '-e'];
297
+ const bindings = resolveKeybindings({ 'crtr.attach.scroll.up': ['ctrl+k'] });
298
+ const passthrough = ['crtr.attach.scroll.up'];
299
+ try {
300
+ isolatedTmux(socket, userBinding);
301
+ const original = tmuxBinding(socket, 'root', 'C-k');
302
+ assert.match(original ?? '', /copy-mode/);
303
+ const wrapped = installTmuxBindings(bindings, passthrough);
304
+ assert.equal(wrapped.ok, true, JSON.stringify(wrapped.diagnostics));
305
+ assert.match(tmuxBinding(socket, 'root', 'C-k') ?? '', /@crtr_attach/, 'occupied key should be wrapped');
306
+ // The user reloads their tmux config: their own line lands over the wrapper.
307
+ isolatedTmux(socket, userBinding);
308
+ assert.equal(tmuxBinding(socket, 'root', 'C-k'), original);
309
+ const resourced = installTmuxBindings(bindings, passthrough);
310
+ assert.equal(resourced.ok, true, JSON.stringify(resourced.diagnostics));
311
+ assert.match(tmuxBinding(socket, 'root', 'C-k') ?? '', /@crtr_attach/, 're-sourced config should be re-wrapped, not relinquished');
312
+ // A line matching neither the wrapper nor the recorded original is a real
313
+ // rebind and stays the user's, here and on every later reconciliation.
314
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'C-k', 'display-message', 'user-owned']);
315
+ for (const pass of ['first', 'second']) {
316
+ const rebound = installTmuxBindings(bindings, passthrough);
317
+ assert.equal(rebound.ok, true, JSON.stringify(rebound.diagnostics));
318
+ const live = tmuxBinding(socket, 'root', 'C-k') ?? '';
319
+ assert.match(live, /user-owned/, `${pass} reconciliation should leave a real rebind alone`);
320
+ assert.doesNotMatch(live, /@crtr_attach/);
321
+ }
322
+ }
323
+ finally {
324
+ if (priorTmux === undefined)
325
+ delete process.env['TMUX'];
326
+ else
327
+ process.env['TMUX'] = priorTmux;
328
+ isolatedTmux(socket, ['kill-server']);
329
+ }
330
+ });
@@ -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);