@north-light/crouter 0.3.310 → 0.3.312

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 (68) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
  2. package/dist/clients/attach/viewer.js +389 -389
  3. package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
  4. package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
  5. package/dist/commands/api-client.d.ts +1 -0
  6. package/dist/commands/api-client.js +4 -2
  7. package/dist/commands/canvas-roster.js +24 -17
  8. package/dist/commands/dashboard.js +23 -16
  9. package/dist/core/__tests__/context-intro.test.js +4 -4
  10. package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
  11. package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
  12. package/dist/core/__tests__/human-deliver.test.js +18 -18
  13. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
  14. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
  15. package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
  16. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
  17. package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
  18. package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
  19. package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
  20. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
  21. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
  22. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
  23. package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
  24. package/dist/core/canvas/remote-canvas-source.js +58 -6
  25. package/dist/core/canvas/render-source.d.ts +4 -4
  26. package/dist/core/canvas/render-source.js +49 -48
  27. package/dist/core/canvas/source.d.ts +2 -0
  28. package/dist/core/canvas/source.js +4 -1
  29. package/dist/core/command-hooks/artifact.d.ts +6 -0
  30. package/dist/core/command-hooks/artifact.js +22 -6
  31. package/dist/core/command-hooks/discovery.d.ts +2 -0
  32. package/dist/core/command-hooks/discovery.js +2 -0
  33. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  34. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  35. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  36. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  37. package/dist/core/command-hooks/schema.d.ts +2 -2
  38. package/dist/core/command-hooks/schema.js +5 -4
  39. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  40. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  41. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  42. package/dist/core/errors.d.ts +1 -0
  43. package/dist/core/errors.js +4 -0
  44. package/dist/core/human/feedback-companion.js +1 -1
  45. package/dist/core/review/companion.d.ts +1 -1
  46. package/dist/core/review/companion.js +2 -2
  47. package/dist/core/review/realize.js +6 -6
  48. package/dist/core/runtime/nodes.d.ts +5 -1
  49. package/dist/core/runtime/nodes.js +26 -11
  50. package/dist/core/runtime/recycle.js +16 -12
  51. package/dist/core/runtime/reset.js +4 -1
  52. package/dist/core/runtime/revive.js +13 -12
  53. package/dist/core/runtime/spawn.d.ts +4 -1
  54. package/dist/core/runtime/spawn.js +30 -3
  55. package/dist/core/runtime/warm-pool.d.ts +2 -2
  56. package/dist/core/runtime/warm-pool.js +23 -3
  57. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
  58. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  59. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
  60. package/dist/daemon/api/handlers/human.js +5 -2
  61. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  62. package/dist/daemon/api/handlers/nodes.js +34 -29
  63. package/dist/daemon/cron-run.js +49 -15
  64. package/dist/hook-authoring.d.ts +28 -2
  65. package/dist/hook-authoring.js +53 -1
  66. package/dist/index.d.ts +1 -1
  67. package/package.json +1 -1
  68. package/runtime.lock.json +5 -5
@@ -0,0 +1,212 @@
1
+ import assert from 'node:assert/strict';
2
+ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ import { createServer } from 'node:http';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import test from 'node:test';
8
+ import { createNode, subscribe } from '../../../core/canvas/canvas.js';
9
+ import { closeDb } from '../../../core/canvas/db.js';
10
+ import { apiSocketPath } from '../../../core/canvas/paths.js';
11
+ import { Router } from '../../../daemon/api/router.js';
12
+ import { createApiServer } from '../../../daemon/api/server.js';
13
+ function node(id, parent) {
14
+ return {
15
+ node_id: id,
16
+ name: `${id}-handle`,
17
+ description: `${id} task`,
18
+ created: new Date().toISOString(),
19
+ cwd: '/tmp/dashboard-test',
20
+ kind: 'developer',
21
+ mode: 'base',
22
+ lifecycle: 'terminal',
23
+ status: 'active',
24
+ parent,
25
+ };
26
+ }
27
+ function runCanvas(home, command, extraEnv = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ const args = ['--conditions=crtr-src', '--import', 'tsx/esm', 'src/cli.ts', '--json', ...command];
30
+ const child = spawn(process.execPath, args, {
31
+ cwd: process.cwd(),
32
+ env: {
33
+ ...process.env,
34
+ ...extraEnv,
35
+ CRTR_HOME: home,
36
+ CRTR_NO_DAEMON_AUTOSTART: '1',
37
+ CRTR_NO_AUTO_INIT: '1',
38
+ CRTR_NO_AUTO_UPDATE: '1',
39
+ CRTR_NO_EXPORTS: '1',
40
+ },
41
+ stdio: ['ignore', 'pipe', 'pipe'],
42
+ });
43
+ let stdout = '';
44
+ let stderr = '';
45
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
46
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
47
+ child.on('error', reject);
48
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
49
+ });
50
+ }
51
+ function runDashboard(home, root, extraEnv) {
52
+ return runCanvas(home, ['canvas', 'dashboard', ...(root === undefined ? [] : ['--root', root])], extraEnv);
53
+ }
54
+ /** The capability this file exists for: a dashboard render issues a fixed
55
+ * number of requests whatever the graph's size, and never reads one node at
56
+ * a time. Which order those requests go out in is an implementation detail —
57
+ * the scoped and full renders already differ there. */
58
+ function assertSnapshotReads(requests, label) {
59
+ assert.equal(requests.length, 3, `${label} issued ${requests.length} requests: ${requests.join(' ')}`);
60
+ assert.ok(!requests.some((url) => /^\/v1\/nodes\/./.test(url)), `${label} made a per-node read: ${requests.join(' ')}`);
61
+ }
62
+ function startMockRemoteCanvas(route) {
63
+ return new Promise((resolve) => {
64
+ const requests = [];
65
+ const server = createServer((req, res) => {
66
+ const chunks = [];
67
+ req.on('data', (chunk) => chunks.push(chunk));
68
+ req.on('end', () => {
69
+ const { args } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
70
+ requests.push(args);
71
+ res.writeHead(200, { 'content-type': 'application/json' });
72
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify(route(args)), stderr: '' }));
73
+ });
74
+ });
75
+ server.listen(0, '127.0.0.1', () => {
76
+ const address = server.address();
77
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
78
+ resolve({
79
+ url: `http://127.0.0.1:${port}`,
80
+ requests,
81
+ close: () => new Promise((done) => server.close(() => done())),
82
+ });
83
+ });
84
+ });
85
+ }
86
+ test('canvas dashboard renders scoped and full cycles from one node snapshot without per-node API reads', async (t) => {
87
+ const root = mkdtempSync(join(tmpdir(), 'crtr-dashboard-'));
88
+ const home = join(root, 'canvas');
89
+ mkdirSync(home, { recursive: true });
90
+ const previousHome = process.env['CRTR_HOME'];
91
+ const originalHandle = Router.prototype.handle;
92
+ const requests = [];
93
+ try {
94
+ process.env['CRTR_HOME'] = home;
95
+ closeDb();
96
+ createNode(node('root', null));
97
+ createNode(node('left', 'root'));
98
+ createNode(node('right', 'root'));
99
+ createNode(node('shared', 'left'));
100
+ subscribe('root', 'left');
101
+ subscribe('root', 'right');
102
+ subscribe('left', 'shared');
103
+ subscribe('right', 'shared');
104
+ subscribe('shared', 'left');
105
+ Router.prototype.handle = async function (req, res) {
106
+ requests.push(req.url ?? '');
107
+ await originalHandle.call(this, req, res);
108
+ };
109
+ const server = createApiServer();
110
+ await server.ready;
111
+ assert.ok(existsSync(apiSocketPath()), 'the isolated API socket is bound before the CLI starts');
112
+ t.after(async () => {
113
+ Router.prototype.handle = originalHandle;
114
+ await server.close();
115
+ });
116
+ for (let i = 0; i < 32; i++) {
117
+ const id = `worker-${i}`;
118
+ createNode(node(id, i === 0 ? 'shared' : `worker-${i - 1}`));
119
+ subscribe(i === 0 ? 'shared' : `worker-${i - 1}`, id);
120
+ }
121
+ requests.length = 0;
122
+ const large = await runDashboard(home, 'root');
123
+ assert.equal(large.code, 0, large.stderr);
124
+ const largeOutput = JSON.parse(large.stdout);
125
+ assert.equal(largeOutput.nodes, 36);
126
+ assert.equal(largeOutput.rows.length, 36);
127
+ assert.equal(new Set(largeOutput.rows.map((row) => row.node_id)).size, 36, 'shared descendants remain one row each');
128
+ assert.ok(largeOutput.rows.every((row) => row.ctx_tokens === 0 && row.asks === 0), 'the emitted row fields remain populated from the snapshot');
129
+ assert.match(largeOutput.tree, /worker-31-handle worker-31 task \[developer\/base\] ctx 0k/);
130
+ assertSnapshotReads(requests, 'the scoped dashboard');
131
+ requests.length = 0;
132
+ const full = await runDashboard(home);
133
+ assert.equal(full.code, 0, full.stderr);
134
+ const fullOutput = JSON.parse(full.stdout);
135
+ assert.equal(fullOutput.nodes, 36);
136
+ assert.equal(fullOutput.rows.length, 36);
137
+ assert.match(fullOutput.tree, /root-handle root task \[developer\/base\] ctx 0k/);
138
+ assert.match(fullOutput.tree, /↺ <cycle: left>/);
139
+ assert.match(fullOutput.tree, /↺ <cycle: shared>/);
140
+ assertSnapshotReads(requests, 'the full dashboard');
141
+ Router.prototype.handle = async function (req, res) {
142
+ requests.push(req.url ?? '');
143
+ if (req.url === '/v1/canvas/attention/counts') {
144
+ res.writeHead(503, { 'content-type': 'application/json' });
145
+ res.end(JSON.stringify({ error: 'unavailable' }));
146
+ return;
147
+ }
148
+ await originalHandle.call(this, req, res);
149
+ };
150
+ requests.length = 0;
151
+ const degradedDashboard = await runDashboard(home);
152
+ assert.equal(degradedDashboard.code, 0, degradedDashboard.stderr);
153
+ const degradedOutput = JSON.parse(degradedDashboard.stdout);
154
+ assert.ok(degradedOutput.rows.every((row) => row.asks === 0), 'unavailable ticket counts render as default badges');
155
+ assert.match(degradedOutput.tree, /worker-31-handle worker-31 task/, 'the dashboard keeps its topology when ticket counts fail');
156
+ const roster = await runCanvas(home, ['canvas', 'roster']);
157
+ assert.equal(roster.code, 0, roster.stderr);
158
+ const rosterOutput = JSON.parse(roster.stdout);
159
+ assert.ok(rosterOutput.edges.some((edge) => edge.from_id === 'root' && edge.to_id === 'left'), 'the roster retains authoritative edges when ticket counts fail');
160
+ assert.deepEqual(rosterOutput.subscriptions['root'], ['left', 'right']);
161
+ }
162
+ finally {
163
+ Router.prototype.handle = originalHandle;
164
+ closeDb();
165
+ if (previousHome === undefined)
166
+ delete process.env['CRTR_HOME'];
167
+ else
168
+ process.env['CRTR_HOME'] = previousHome;
169
+ rmSync(root, { recursive: true, force: true });
170
+ }
171
+ });
172
+ test('canvas dashboard reads full-forest ticket counts from the selected remote canvas', async (t) => {
173
+ const root = mkdtempSync(join(tmpdir(), 'crtr-dashboard-remote-'));
174
+ const home = join(root, 'canvas');
175
+ const userHome = join(root, 'user');
176
+ const remote = await startMockRemoteCanvas((args) => {
177
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
178
+ return { nodes: [node('root', null), node('child', 'root')] };
179
+ }
180
+ if (args[1] === 'canvas' && args[2] === 'attention') {
181
+ return { counts: { root: 2, child: 3 } };
182
+ }
183
+ if (args[1] === 'canvas' && args[2] === 'roster') {
184
+ return { edges: [{ from_id: 'root', to_id: 'child', active: true, created: '' }] };
185
+ }
186
+ throw new Error(`unexpected remote command: ${args.join(' ')}`);
187
+ });
188
+ t.after(() => remote.close());
189
+ try {
190
+ mkdirSync(join(userHome, '.crouter'), { recursive: true });
191
+ writeFileSync(join(userHome, '.crouter', 'config.json'), JSON.stringify({
192
+ remoteCanvas: { targets: { remote: { previewEndpoint: remote.url, relayTokenRef: 'remote' } } },
193
+ }));
194
+ writeFileSync(join(userHome, '.crouter', 'state.json'), JSON.stringify({ activeCanvas: 'remote' }));
195
+ writeFileSync(join(userHome, '.crouter', 'secrets.json'), JSON.stringify({ relayTokens: { remote: 'test-token' } }));
196
+ const result = await runDashboard(home, undefined, { HOME: userHome });
197
+ assert.equal(result.code, 0, result.stderr);
198
+ const output = JSON.parse(result.stdout);
199
+ assert.deepEqual(output.rows.map((row) => ({ node_id: row.node_id, asks: row.asks })), [
200
+ { node_id: 'root', asks: 2 },
201
+ { node_id: 'child', asks: 3 },
202
+ ]);
203
+ assert.match(output.tree, /⚑2/);
204
+ assert.match(output.tree, /⚑3/);
205
+ // The mock route throws on any command it does not recognise, so this only
206
+ // has to pin that topology comes from one roster call rather than one per edge.
207
+ assert.equal(remote.requests.length, 3, `remote issued ${remote.requests.length} commands: ${remote.requests.map((args) => args.join(' ')).join(' | ')}`);
208
+ }
209
+ finally {
210
+ rmSync(root, { recursive: true, force: true });
211
+ }
212
+ });
@@ -76,6 +76,7 @@ export declare class ApiCanvasSource implements CanvasSource {
76
76
  * be per-node. Request only this view's ids: using the
77
77
  * enriched full-canvas snapshot for a five-second viewer poll blocked crtrd
78
78
  * behind metadata and telemetry reads for every historical node. */
79
+ ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
79
80
  ticketCountsForView(root: string): Promise<Record<string, number>>;
80
81
  /** True when `nodeId` has at least one active (`active` edge flag, not just a
81
82
  * live subscriber) subscription to a node that is currently live
@@ -247,9 +247,11 @@ export class ApiCanvasSource {
247
247
  * be per-node. Request only this view's ids: using the
248
248
  * enriched full-canvas snapshot for a five-second viewer poll blocked crtrd
249
249
  * behind metadata and telemetry reads for every historical node. */
250
+ async ticketCountsForNodes(nodeIds) {
251
+ return (await this.client.attentionCounts([...nodeIds])).counts;
252
+ }
250
253
  async ticketCountsForView(root) {
251
- const ids = [root, ...await this.view(root)];
252
- return (await this.client.attentionCounts(ids)).counts;
254
+ return this.ticketCountsForNodes([root, ...await this.view(root)]);
253
255
  }
254
256
  /** True when `nodeId` has at least one active (`active` edge flag, not just a
255
257
  * live subscriber) subscription to a node that is currently live
@@ -23,6 +23,12 @@ export const canvasRosterLeaf = defineLeaf({
23
23
  required: true,
24
24
  constraint: 'Authoritative subscribes_to adjacency map: each node id maps to its publisher/child node ids in edge order.',
25
25
  },
26
+ {
27
+ name: 'edges',
28
+ type: 'object[]',
29
+ required: true,
30
+ constraint: 'Subscription edges with from_id, to_id, active, and created fields.',
31
+ },
26
32
  ],
27
33
  outputKind: 'object',
28
34
  effects: [
@@ -38,25 +44,26 @@ export const canvasRosterLeaf = defineLeaf({
38
44
  // (`GET /v1/canvas/roster`). Attention counts stay a SEPARATE bounded read
39
45
  // (`POST /v1/canvas/attention/counts`) — never folded into the roster query
40
46
  // itself, which must stay indexed-scan-only.
47
+ const dto = await cliClient().canvasRoster().catch((err) => rethrowAsCliError(err));
48
+ const ids = dto.nodes.map((n) => n.node_id);
49
+ let counts = {};
41
50
  try {
42
- const dto = await cliClient().canvasRoster();
43
- const ids = dto.nodes.map((n) => n.node_id);
44
- const counts = ids.length > 0 ? (await cliClient().attentionCounts(ids)).counts : {};
45
- const nodes = dto.nodes.map((n) => ({ ...n, attention_count: counts[n.node_id] ?? 0 }));
46
- const subscriptions = {};
47
- for (const n of dto.nodes)
48
- subscriptions[n.node_id] = [];
49
- for (const e of dto.edges) {
50
- const list = subscriptions[e.from_id];
51
- if (list !== undefined)
52
- list.push(e.to_id);
53
- else
54
- subscriptions[e.from_id] = [e.to_id];
55
- }
56
- return { generated_at: dto.generated_at, nodes, subscriptions };
51
+ counts = ids.length > 0 ? (await cliClient().attentionCounts(ids)).counts : {};
52
+ }
53
+ catch {
54
+ // Ticket counts decorate the authoritative roster topology.
57
55
  }
58
- catch (err) {
59
- rethrowAsCliError(err);
56
+ const nodes = dto.nodes.map((n) => ({ ...n, attention_count: counts[n.node_id] ?? 0 }));
57
+ const subscriptions = {};
58
+ for (const n of dto.nodes)
59
+ subscriptions[n.node_id] = [];
60
+ for (const e of dto.edges) {
61
+ const list = subscriptions[e.from_id];
62
+ if (list !== undefined)
63
+ list.push(e.to_id);
64
+ else
65
+ subscriptions[e.from_id] = [e.to_id];
60
66
  }
67
+ return { generated_at: dto.generated_at, nodes, subscriptions, edges: dto.edges };
61
68
  },
62
69
  });
@@ -7,7 +7,7 @@
7
7
  // Exported as a leaf; `crtr canvas` (canvas.ts) mounts it.
8
8
  import { defineLeaf } from '../core/command.js';
9
9
  import { InputError } from '../core/io.js';
10
- import { cliClient, cliCanvasSource } from './api-client.js';
10
+ import { cliCanvasSource } from './api-client.js';
11
11
  import { renderTreeFromSource, renderForestFromSource, dashboardRowsFromSource, dashboardRowsAllFromSource, enrichRowsFromSource, } from '../core/canvas/render-source.js';
12
12
  // dashboard show — the main leaf
13
13
  export const dashboardLeaf = defineLeaf({
@@ -37,40 +37,47 @@ export const dashboardLeaf = defineLeaf({
37
37
  run: async (input) => {
38
38
  const rootId = input['root'];
39
39
  const source = cliCanvasSource();
40
+ const allNodes = await source.listNodes();
40
41
  if (rootId !== undefined) {
41
- // Scoped to a single root — validate the node exists (via the daemon API).
42
- const node = await source.getNode(rootId);
43
- if (node === null) {
42
+ if (!allNodes.some((node) => node.node_id === rootId)) {
44
43
  throw new InputError({
45
44
  error: 'not_found',
46
45
  message: `no node: ${rootId}`,
47
46
  next: 'List nodes with `crtr node inspect list` to find a valid id.',
48
47
  });
49
48
  }
50
- const rows = await dashboardRowsFromSource(source, rootId);
49
+ let asks = {};
50
+ let ticketsUnavailable;
51
+ try {
52
+ asks = await source.ticketCountsForView(rootId);
53
+ }
54
+ catch (err) {
55
+ ticketsUnavailable = err instanceof Error ? err.message : String(err);
56
+ }
57
+ const rows = await dashboardRowsFromSource(source, rootId, allNodes, asks);
51
58
  return {
52
- tree: await renderTreeFromSource(source, rootId),
59
+ tree: await renderTreeFromSource(source, rootId, allNodes, asks, ticketsUnavailable),
53
60
  nodes: rows.length,
54
61
  rows,
55
62
  };
56
63
  }
57
64
  // Full forest: all nodes on the canvas.
58
- const allNodes = await source.listNodes();
59
65
  // dashboardRowsAllFromSource is the cheap-boot builder: ctx_tokens/asks are 0
60
66
  // and `name` is the handle only until enriched. The JSON consumer wants the
61
67
  // real values, so fold the deferred fields in before emitting (the rendered
62
68
  // TREE is unaffected). Ask counts are per-node: attention follows each human
63
- // bridge's parent relation. The daemon's canvas snapshot carries that count
64
- // (`attention_count`, server-side `enrichRows`), so read it straight off the
65
- // roster.
66
- const rows = await dashboardRowsAllFromSource(source);
67
- const snapshot = await cliClient().canvasSnapshot();
68
- const asks = {};
69
- for (const n of snapshot.nodes)
70
- asks[n.node_id] = n.attention_count;
69
+ // bridge's parent relation, so fetch all displayed counts in one batch.
70
+ const rows = await dashboardRowsAllFromSource(source, new Set(), false, allNodes);
71
+ let asks = {};
72
+ try {
73
+ asks = await source.ticketCountsForNodes(allNodes.map((node) => node.node_id));
74
+ }
75
+ catch {
76
+ // Ticket counts decorate the dashboard; the source snapshot remains authoritative.
77
+ }
71
78
  await enrichRowsFromSource(source, rows, asks);
72
79
  return {
73
- tree: await renderForestFromSource(source),
80
+ tree: await renderForestFromSource(source, allNodes, asks),
74
81
  nodes: allNodes.length,
75
82
  rows,
76
83
  };
@@ -48,8 +48,8 @@ after(() => {
48
48
  delete process.env['CRTR_HOME'];
49
49
  delete process.env['CRTR_NODE_ID'];
50
50
  });
51
- test('the stored situational card rides the bearings body; absent when unset', () => {
52
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
51
+ test('the stored situational card rides the bearings body; absent when unset', async () => {
52
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
53
53
  assert.equal(situationalContextEnvelope(meta.node_id), null, 'no card until something is set');
54
54
  assert.doesNotMatch(buildContextIntro(meta.node_id), /kind="situational"/, 'bearings carry no card when unset');
55
55
  appendSituationalContext(meta.node_id, formatSituationalProse('user is looking at the billing applet'));
@@ -73,7 +73,7 @@ function fakeCtx(pi) {
73
73
  return { sessionManager: { getBranch: () => pi.entries } };
74
74
  }
75
75
  test('session_start injects the block as the first message of an empty session', async () => {
76
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
76
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
77
77
  process.env['CRTR_NODE_ID'] = meta.node_id;
78
78
  const pi = makeFakePi();
79
79
  registerCanvasContextIntro(pi);
@@ -87,7 +87,7 @@ test('session_start injects the block as the first message of an empty session',
87
87
  assert.match(pi.sent[0].content, /<crtr-bearings>/);
88
88
  });
89
89
  test('session_start is idempotent across resume (skips if already in history)', async () => {
90
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
90
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
91
91
  process.env['CRTR_NODE_ID'] = meta.node_id;
92
92
  // Restored session already carries OUR block (which names this node id) → no
93
93
  // duplicate injection. The content matters: the guard is fork-aware and only
@@ -0,0 +1,102 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/cron-timeout-descendants.test.ts
2
+ //
3
+ // #1037: a cron descendant living outside the shell's own process group held
4
+ // the run's output pipes open, and settlement waited on those pipes. Teardown
5
+ // signalled the group and moved on, so the timeout was recorded only when the
6
+ // descendant finally exited by itself — 732 seconds for a 240-second timeout
7
+ // in the field. Teardown must reap the tree it captured by ancestry, and the
8
+ // record must wait for those processes to be dead rather than merely quiet.
9
+ //
10
+ // GNU `timeout` is the wrapper that produced the field report, but nothing
11
+ // here is specific to it: a detached child reproduces the same shape on any
12
+ // platform, so this never skips.
13
+ import { after, before, test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { closeDb } from '../canvas/db.js';
19
+ import { armCron, getCron } from '../canvas/crons.js';
20
+ import { isPidAlive } from '../canvas/pid.js';
21
+ import { executeCron } from '../../daemon/cron-run.js';
22
+ // The shell starts a live intermediate that keeps a detached grandchild in a
23
+ // process group of its own. The grandchild inherits the cron's stdout and
24
+ // stderr and ignores TERM, so the pipes stay open until something kills it.
25
+ const INTERMEDIATE = `
26
+ const { spawn } = require('node:child_process');
27
+ spawn(process.execPath, [process.argv[2], process.argv[3]], { detached: true, stdio: 'inherit' }).unref();
28
+ setInterval(() => {}, 1000);
29
+ `;
30
+ const DESCENDANT = `
31
+ process.on('SIGTERM', () => {});
32
+ require('node:fs').writeFileSync(process.argv[2], String(process.pid));
33
+ setInterval(() => {}, 1000);
34
+ `;
35
+ let home;
36
+ let realHome;
37
+ let marker;
38
+ let command;
39
+ function arm(overrides) {
40
+ armCron({
41
+ name: overrides.cron_id,
42
+ created_by: null,
43
+ fire_at: new Date(Date.now() - 1_000).toISOString(),
44
+ recur: null,
45
+ tz: null,
46
+ expires_at: null,
47
+ anchor_node: null,
48
+ cancel_on_wake: false,
49
+ cwd: home,
50
+ env_json: null,
51
+ profile: null,
52
+ scope: 'profile',
53
+ run_timeout_s: 1,
54
+ overlap: 'skip',
55
+ on_output: 'silent',
56
+ sink: '{}',
57
+ tier: 'normal',
58
+ ...overrides,
59
+ });
60
+ }
61
+ before(() => {
62
+ home = mkdtempSync(join(tmpdir(), 'crtr-cron-timeout-descendants-'));
63
+ realHome = process.env['CRTR_HOME'];
64
+ process.env['CRTR_HOME'] = home;
65
+ marker = join(home, 'descendant.pid');
66
+ const intermediate = join(home, 'intermediate.cjs');
67
+ const descendant = join(home, 'descendant.cjs');
68
+ writeFileSync(intermediate, INTERMEDIATE);
69
+ writeFileSync(descendant, DESCENDANT);
70
+ // Two statements, so the shell stays alive as the tree's root rather than
71
+ // exec-replacing itself with the intermediate.
72
+ command = `${JSON.stringify(process.execPath)} ${JSON.stringify(intermediate)} ${JSON.stringify(descendant)} ${JSON.stringify(marker)}; printf 'shell survived\\n'`;
73
+ });
74
+ after(() => {
75
+ if (existsSync(marker)) {
76
+ const stranded = Number.parseInt(readFileSync(marker, 'utf8').trim(), 10);
77
+ if (Number.isInteger(stranded) && stranded > 0 && isPidAlive(stranded)) {
78
+ try {
79
+ process.kill(stranded, 'SIGKILL');
80
+ }
81
+ catch {
82
+ // It exited between the liveness probe and the signal.
83
+ }
84
+ }
85
+ }
86
+ closeDb();
87
+ rmSync(home, { recursive: true, force: true });
88
+ if (realHome === undefined)
89
+ delete process.env['CRTR_HOME'];
90
+ else
91
+ process.env['CRTR_HOME'] = realHome;
92
+ });
93
+ test('a TERM-ignoring descendant outside the shell process group is reaped before the timeout is recorded', async () => {
94
+ arm({ cron_id: 'timeout-descendants', command });
95
+ const record = await executeCron(getCron('timeout-descendants'), { outOfBand: true });
96
+ assert.ok(record !== null, 'the run settles instead of hanging on held pipes');
97
+ assert.equal(record.exit_code, -1, 'the configured deadline is recorded as a timeout');
98
+ assert.ok(record.duration_ms < 5_000, `settlement is bounded by the deadline and teardown, got ${record.duration_ms}ms`);
99
+ const descendant = Number.parseInt(readFileSync(marker, 'utf8').trim(), 10);
100
+ assert.ok(Number.isInteger(descendant) && descendant > 0, 'the scenario actually started an out-of-group descendant');
101
+ assert.equal(isPidAlive(descendant), false, 'the descendant is dead, not merely detached from the pipes it was holding');
102
+ });
@@ -45,9 +45,9 @@ async function withMutedStdout(fn) {
45
45
  process.stdout.write = originalWrite;
46
46
  }
47
47
  }
48
- function bridgeUnder() {
49
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
50
- const bridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
48
+ async function bridgeUnder() {
49
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
50
+ const bridge = (await spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
51
51
  return { parent, bridge };
52
52
  }
53
53
  function submitBridgePage(bridge) {
@@ -97,7 +97,7 @@ after(() => {
97
97
  });
98
98
  describe('enqueue is producer-only', () => {
99
99
  test('send preserves title and subtitle from the authored page', async () => {
100
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
100
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
101
101
  process.env['CRTR_NODE_ID'] = parent;
102
102
  const page = join(home, 'release-decision.tsx');
103
103
  writeFileSync(page, 'export default () => (\n <Page title="Release decision" subtitle="Decide whether to release now so the launch can proceed.">\n <p>This first line stays page content.</p>\n <UserText id="question" initialText="" />\n </Page>\n);\n');
@@ -117,7 +117,7 @@ describe('enqueue is producer-only', () => {
117
117
  assert.equal(manifest.slots.filter((slot) => slot.kind === 'text').length, 1);
118
118
  });
119
119
  test('send materializes a piped page before validation and publication', async () => {
120
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
120
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
121
121
  process.env['CRTR_NODE_ID'] = parent;
122
122
  const document = 'export default () => (\n <Page title="Piped question" subtitle="Answer the question to unblock this task.">\n <UserText id="question" initialText="" />\n </Page>\n);\n';
123
123
  const result = (await humanSend.run({ document }));
@@ -128,7 +128,7 @@ describe('enqueue is producer-only', () => {
128
128
  });
129
129
  describe('terminal ticket delivery', () => {
130
130
  test("resolvePageTicket pushFinals the person's words once and wakes the subscribed parent", async () => {
131
- const { parent, bridge } = bridgeUnder();
131
+ const { parent, bridge } = await bridgeUnder();
132
132
  const dir = submitBridgePage(bridge);
133
133
  const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
134
134
  assert.equal(result.kind, 'page');
@@ -142,7 +142,7 @@ describe('terminal ticket delivery', () => {
142
142
  assert.ok(!body.includes(join(dir, 'response.json')));
143
143
  });
144
144
  test('settlement delivers feedback outside the human-answer entry', async () => {
145
- const { parent, bridge } = bridgeUnder();
145
+ const { parent, bridge } = await bridgeUnder();
146
146
  const dir = submitBridgePage(bridge);
147
147
  writeFileSync(pageFeedbackPath(dir), JSON.stringify({
148
148
  schema: 'crtr.page-feedback/v1',
@@ -167,7 +167,7 @@ describe('terminal ticket delivery', () => {
167
167
  assert.deepEqual(answer, { kind: 'final', disposition: 'human-answer', body: `yes, ship\n\nRead this report with \`crtr canvas history read ${final.ref}\`.`, ref: final.ref });
168
168
  });
169
169
  test('a replay against a terminal bridge is a no-op', async () => {
170
- const { parent, bridge } = bridgeUnder();
170
+ const { parent, bridge } = await bridgeUnder();
171
171
  submitBridgePage(bridge);
172
172
  await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
173
173
  assert.equal(await deliverTerminalResult(bridge), true);
@@ -176,9 +176,9 @@ describe('terminal ticket delivery', () => {
176
176
  assert.equal(finals.length, 1, 'replay after a terminal bridge pushes nothing further');
177
177
  });
178
178
  test('approved companions receive one canonical final while dismissed companions remain canceled', async () => {
179
- const { bridge } = bridgeUnder();
179
+ const { bridge } = await bridgeUnder();
180
180
  const dir = submitBridgePage(bridge);
181
- const companion = spawnNode({ kind: 'general', parent: bridge, creator: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
181
+ const companion = (await spawnNode({ kind: 'general', parent: bridge, creator: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' })).node_id;
182
182
  createPageFeedbackComment(dir, { quote: 'Ship it?', note: 'Confirm the release.', companionNodeId: companion });
183
183
  transition(companion, 'finish');
184
184
  await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
@@ -193,9 +193,9 @@ describe('terminal ticket delivery', () => {
193
193
  assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
194
194
  assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
195
195
  await assert.rejects(reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
196
- const { bridge: dismissedBridge } = bridgeUnder();
196
+ const { bridge: dismissedBridge } = await bridgeUnder();
197
197
  const dismissedDir = submitBridgePage(dismissedBridge);
198
- const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, creator: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
198
+ const dismissedCompanion = (await spawnNode({ kind: 'general', parent: dismissedBridge, creator: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' })).node_id;
199
199
  createPageFeedbackComment(dismissedDir, { quote: 'Ship it?', note: 'Do not ship.', companionNodeId: dismissedCompanion });
200
200
  await cancelHumanTicket(dismissedBridge, { reason: 'dismissed', actor: 'human' });
201
201
  assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
@@ -206,9 +206,9 @@ describe('terminal ticket delivery', () => {
206
206
  assert.equal(getNode(dismissedCompanion)?.final_report, null);
207
207
  });
208
208
  test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
209
- const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
210
- const bridge = spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
211
- const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
209
+ const actor = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' })).node_id;
210
+ const bridge = (await spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
211
+ const other = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' })).node_id;
212
212
  const { subscribe } = await import('../canvas/canvas.js');
213
213
  subscribe(other, bridge, true);
214
214
  submitBridgePage(bridge);
@@ -222,9 +222,9 @@ describe('terminal ticket delivery', () => {
222
222
  assert.match(notes[0].label, /canceled/);
223
223
  });
224
224
  test('cancellation reaches subscribers when the bridge is already done, and retries conflict rather than fail', async () => {
225
- const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
226
- const bridge = spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
227
- const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
225
+ const actor = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' })).node_id;
226
+ const bridge = (await spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
227
+ const other = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' })).node_id;
228
228
  const { subscribe } = await import('../canvas/canvas.js');
229
229
  subscribe(other, bridge, true);
230
230
  submitBridgePage(bridge);
@@ -88,8 +88,8 @@ function reportBody(ref) {
88
88
  return resolved.body;
89
89
  }
90
90
  test('malformed resolves leave the mounted ticket claim in place', async () => {
91
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
92
- const pageBridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
91
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
92
+ const pageBridge = (await spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
93
93
  const pageDir = submitBridgePage(pageBridge);
94
94
  const pageClaim = claimTicket(pageDir);
95
95
  assert.ok(pageClaim !== null);
@@ -101,7 +101,7 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
101
101
  await expectReviewSubmitGuidance(pageBridge, body);
102
102
  assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'feedback-shaped input must not take over the live claim');
103
103
  }
104
- const reviewBridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-review', lifecycle: 'terminal' }).node_id;
104
+ const reviewBridge = (await spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-review', lifecycle: 'terminal' })).node_id;
105
105
  const reviewDir = ticketDir(reviewBridge);
106
106
  const file = join(home, 'review.md');
107
107
  writeFileSync(file, '# Review\n');
@@ -113,8 +113,8 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
113
113
  assert.equal(readTicketClaim(reviewDir)?.token, reviewClaim.token, 'review ticket resolve must not take over the live claim');
114
114
  });
115
115
  test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
116
- const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
117
- const bridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
116
+ const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
117
+ const bridge = (await spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
118
118
  const dir = submitBridgePage(bridge);
119
119
  const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true } } });
120
120
  assert.deepEqual(result, { completed: true, reply_delivered: true, kind: 'page' });