@north-light/crouter 0.3.282 → 0.3.284

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 (160) hide show
  1. package/dist/api/client.d.ts +22 -4
  2. package/dist/api/client.js +34 -0
  3. package/dist/api/dto/broker-ops.d.ts +11 -0
  4. package/dist/api/dto/broker.d.ts +3 -8
  5. package/dist/api/dto/common.d.ts +1 -1
  6. package/dist/api/dto/messages.d.ts +2 -2
  7. package/dist/api/dto/node-outcomes.d.ts +80 -0
  8. package/dist/api/dto/node-outcomes.js +2 -0
  9. package/dist/api/dto/nodes.d.ts +9 -0
  10. package/dist/api/dto/profiles.d.ts +5 -0
  11. package/dist/api/dto/reports.d.ts +45 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/routes.d.ts +7 -0
  15. package/dist/api/routes.js +7 -0
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
  18. package/dist/clients/attach/overlays/graph.d.ts +5 -0
  19. package/dist/clients/attach/overlays/graph.js +65 -47
  20. package/dist/clients/attach/overlays/pickers.js +8 -9
  21. package/dist/clients/attach/render/card-presentation.js +1 -1
  22. package/dist/clients/attach/viewer.js +1286 -1260
  23. package/dist/commands/api-client.js +22 -23
  24. package/dist/commands/node/create.js +38 -3
  25. package/dist/commands/node/inspect.js +3 -7
  26. package/dist/commands/node/message.js +3 -3
  27. package/dist/commands/node/outcome.d.ts +2 -0
  28. package/dist/commands/node/outcome.js +134 -0
  29. package/dist/commands/node.js +2 -1
  30. package/dist/commands/profile/pause.js +17 -14
  31. package/dist/commands/push.js +102 -1
  32. package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
  33. package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
  34. package/dist/commands/sys/panels/models-panel.js +2 -2
  35. package/dist/commands/sys/settings-shell.d.ts +1 -1
  36. package/dist/commands/sys/settings-shell.js +6 -0
  37. package/dist/commands/sys/settings.js +3 -3
  38. package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
  39. package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
  40. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  41. package/dist/core/__tests__/helpers/harness.js +9 -0
  42. package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
  43. package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
  44. package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
  45. package/dist/core/__tests__/node-outcome.test.js +184 -0
  46. package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
  47. package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
  48. package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
  49. package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
  50. package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
  51. package/dist/core/bash-jobs.d.ts +6 -0
  52. package/dist/core/bash-jobs.js +22 -0
  53. package/dist/core/canvas/canvas.d.ts +41 -3
  54. package/dist/core/canvas/canvas.js +153 -39
  55. package/dist/core/canvas/crons.js +2 -1
  56. package/dist/core/canvas/index.d.ts +1 -0
  57. package/dist/core/canvas/index.js +1 -0
  58. package/dist/core/canvas/migrations.js +47 -0
  59. package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
  60. package/dist/core/canvas/node-outcome-deliveries.js +163 -0
  61. package/dist/core/canvas/pid.d.ts +7 -0
  62. package/dist/core/canvas/pid.js +17 -0
  63. package/dist/core/canvas/render-source.js +2 -2
  64. package/dist/core/canvas/types.d.ts +42 -1
  65. package/dist/core/canvas/types.js +2 -1
  66. package/dist/core/config.d.ts +4 -1
  67. package/dist/core/config.js +25 -1
  68. package/dist/core/feed/feed.d.ts +8 -0
  69. package/dist/core/feed/feed.js +25 -4
  70. package/dist/core/feed/inbox.d.ts +5 -0
  71. package/dist/core/feed/inbox.js +13 -0
  72. package/dist/core/keybindings/catalog.d.ts +1 -1
  73. package/dist/core/keybindings/catalog.js +3 -0
  74. package/dist/core/profiles/manifest.d.ts +3 -1
  75. package/dist/core/profiles/manifest.js +6 -1
  76. package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
  77. package/dist/core/runtime/broker/daemon-ops.js +6 -0
  78. package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
  79. package/dist/core/runtime/broker/engine-drive.js +74 -27
  80. package/dist/core/runtime/broker/frame-dispatch.js +1 -0
  81. package/dist/core/runtime/broker/read-ops.js +14 -0
  82. package/dist/core/runtime/broker/rebind.js +0 -7
  83. package/dist/core/runtime/broker-protocol.d.ts +1 -6
  84. package/dist/core/runtime/fleet.d.ts +7 -0
  85. package/dist/core/runtime/headless-pi.d.ts +13 -0
  86. package/dist/core/runtime/headless-pi.js +10 -2
  87. package/dist/core/runtime/lifecycle.d.ts +6 -1
  88. package/dist/core/runtime/lifecycle.js +50 -1
  89. package/dist/core/runtime/nodes.d.ts +5 -1
  90. package/dist/core/runtime/nodes.js +2 -1
  91. package/dist/core/runtime/outcome-document.d.ts +70 -0
  92. package/dist/core/runtime/outcome-document.js +97 -0
  93. package/dist/core/runtime/revive.js +16 -21
  94. package/dist/core/runtime/spawn.d.ts +5 -1
  95. package/dist/core/runtime/spawn.js +18 -7
  96. package/dist/core/runtime/stamp/channel.d.ts +3 -5
  97. package/dist/core/runtime/stamp/channel.js +3 -10
  98. package/dist/core/runtime/stop-guard.js +2 -2
  99. package/dist/core/runtime/structured-output.d.ts +24 -3
  100. package/dist/core/runtime/structured-output.js +42 -3
  101. package/dist/core/runtime/tmux-bindings.js +4 -0
  102. package/dist/core/runtime/warm-pool.d.ts +5 -2
  103. package/dist/core/runtime/warm-pool.js +5 -5
  104. package/dist/core/subscription-state.js +7 -5
  105. package/dist/core/substrate/render.js +10 -10
  106. package/dist/core/user-settings.d.ts +18 -1
  107. package/dist/core/user-settings.js +37 -0
  108. package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
  109. package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
  110. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
  111. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
  112. package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
  113. package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
  114. package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
  115. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
  116. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
  117. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
  118. package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
  119. package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
  120. package/dist/daemon/api/handlers/broker-ops.js +53 -24
  121. package/dist/daemon/api/handlers/canvas.js +2 -2
  122. package/dist/daemon/api/handlers/messages.js +12 -2
  123. package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
  124. package/dist/daemon/api/handlers/node-outcomes.js +136 -0
  125. package/dist/daemon/api/handlers/nodes.js +67 -1
  126. package/dist/daemon/api/handlers/profiles.js +53 -1
  127. package/dist/daemon/api/handlers/reports.js +197 -33
  128. package/dist/daemon/api/map.d.ts +7 -2
  129. package/dist/daemon/api/map.js +78 -4
  130. package/dist/daemon/api/server.js +74 -7
  131. package/dist/daemon/crtrd.js +12 -0
  132. package/dist/daemon/fleet.d.ts +4 -8
  133. package/dist/daemon/fleet.js +48 -9
  134. package/dist/daemon/messaging/node-message.js +2 -1
  135. package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
  136. package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
  137. package/dist/daemon/park-activity.d.ts +9 -0
  138. package/dist/daemon/park-activity.js +30 -0
  139. package/dist/daemon/park-pending.d.ts +10 -5
  140. package/dist/daemon/park-pending.js +18 -8
  141. package/dist/daemon/reconcilers/bash-deadline.js +6 -0
  142. package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
  143. package/dist/daemon/reconcilers/broker-supervision.js +29 -33
  144. package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
  145. package/dist/daemon/reconcilers/node-deadline.js +82 -0
  146. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
  147. package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
  148. package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
  149. package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
  150. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
  151. package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
  152. package/dist/pi-extensions/canvas-structured-output.js +23 -297
  153. package/dist/shared/env.d.ts +8 -0
  154. package/dist/shared/env.js +14 -0
  155. package/dist/shared/generated-context.d.ts +3 -9
  156. package/dist/shared/generated-context.js +8 -16
  157. package/dist/types.d.ts +12 -0
  158. package/dist/types.js +1 -0
  159. package/package.json +9 -8
  160. package/runtime.lock.json +227 -383
@@ -0,0 +1,153 @@
1
+ // Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/node-outcome-delivery.test.js
2
+ //
3
+ // Pins the terminal-outcome delivery boundary: a real node publishes a canonical
4
+ // final through the CLI, then the compiled daemon lane gives the registered
5
+ // command its immutable outcome document on stdin and honors its exit code.
6
+ import { after, before, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { closeDb } from '../../canvas/db.js';
11
+ import { readOutcomeDelivery } from '../../canvas/node-outcome-deliveries.js';
12
+ import { createHeadlessHarness } from '../helpers/harness.js';
13
+ let h;
14
+ let root;
15
+ let project;
16
+ function assertKeys(value, expected, label) {
17
+ assert.deepEqual(Object.keys(value).sort(), [...expected].sort(), `${label} has the pinned document fields`);
18
+ }
19
+ function readCapture(action, attempt) {
20
+ const path = join(project, `${action}-${attempt}.json`);
21
+ assert.ok(existsSync(path), `${action} attempt ${attempt} captured stdin`);
22
+ return JSON.parse(readFileSync(path, 'utf8'));
23
+ }
24
+ function assertResultDocument(document, nodeId, action, payloadJson, attempt) {
25
+ assertKeys(document, ['schema', 'outcomeId', 'nodeId', 'revision', 'kind', 'reason', 'settledAt', 'node', 'result', 'action', 'delivery'], 'node outcome document');
26
+ assert.equal(document.schema, 'crtr.node-outcome/v1');
27
+ assert.equal(document.outcomeId, `${nodeId}:1`);
28
+ assert.equal(document.nodeId, nodeId);
29
+ assert.equal(document.revision, 1);
30
+ assert.equal(document.kind, 'result');
31
+ assert.equal(document.reason, 'finalized');
32
+ assert.match(document.settledAt, /^\d{4}-\d{2}-\d{2}T/);
33
+ assert.equal(document.failure, undefined, 'result documents do not carry a failure discriminator');
34
+ assertKeys(document.node, ['name', 'kind', 'mode', 'lifecycle', 'status', 'terminalReason', 'cwd', 'createdAt', 'parent', 'spawnedBy'], 'document node');
35
+ assert.equal(document.node['kind'], 'general');
36
+ assert.equal(document.node['mode'], 'base');
37
+ assert.equal(document.node['lifecycle'], 'terminal');
38
+ assert.equal(document.node['status'], 'done');
39
+ assert.equal(document.node['terminalReason'], 'finalized');
40
+ assert.equal(document.node['cwd'], project);
41
+ assert.equal(document.node['parent'], root);
42
+ assert.equal(document.node['spawnedBy'], root);
43
+ assertKeys(document.result, ['finalReport'], 'result document');
44
+ const finalReport = document.result['finalReport'];
45
+ assertKeys(finalReport, ['basename', 'path'], 'result final-report pointer');
46
+ assert.equal(typeof finalReport['basename'], 'string');
47
+ assert.equal(typeof finalReport['path'], 'string');
48
+ assertKeys(document.action, ['name', 'payload'], 'document action');
49
+ assert.equal(document.action.name, action);
50
+ assert.equal(JSON.stringify(document.action.payload), payloadJson, 'action payload reaches stdin byte-identically');
51
+ assertKeys(document.delivery, ['attempt'], 'document delivery');
52
+ assert.equal(document.delivery.attempt, attempt);
53
+ }
54
+ function deliveryWhen(nodeId, predicate, label) {
55
+ return h.waitFor(() => {
56
+ closeDb();
57
+ const delivery = readOutcomeDelivery(nodeId);
58
+ return delivery !== null && predicate(delivery) ? delivery : null;
59
+ }, { label, timeoutMs: 20_000 });
60
+ }
61
+ async function createAndFinalize(action, payload) {
62
+ const payloadJson = JSON.stringify(payload);
63
+ const created = h.cli(root, [
64
+ '--json', 'node', 'new', '--kind', 'general', '--cwd', project,
65
+ '--outcome-action', action, '--outcome-payload', payloadJson,
66
+ `outcome delivery ${action}`,
67
+ ]);
68
+ assert.equal(created.code, 0, `node new for ${action}: ${created.stderr}`);
69
+ const createdBody = created.json;
70
+ assert.equal(typeof createdBody?.node_id, 'string', `node new returns its node id for ${action}`);
71
+ const nodeId = createdBody.node_id;
72
+ await h.awaitBoot(nodeId);
73
+ closeDb();
74
+ const armed = readOutcomeDelivery(nodeId);
75
+ assert.notEqual(armed, null, `node birth arms ${action} delivery`);
76
+ assert.equal(armed.state, 'armed');
77
+ assert.equal(armed.actionName, action);
78
+ assert.equal(armed.argv[0], join(project, `${action}.sh`));
79
+ assert.equal(armed.cwd, project);
80
+ assert.equal(armed.configOrigin, join(project, '.crouter', 'config.json'));
81
+ assert.equal(JSON.stringify(armed.payload), payloadJson, 'registered payload is frozen before launch');
82
+ assert.equal(armed.attempt, 0);
83
+ const finalized = h.cli(nodeId, ['push', 'final', `canonical final for ${action}`]);
84
+ assert.equal(finalized.code, 0, `push final for ${action}: ${finalized.stderr}`);
85
+ const pending = await deliveryWhen(nodeId, (delivery) => delivery.state === 'pending', `${action} delivery pending after canonical final`);
86
+ assert.notEqual(pending.document, null, 'canonical final composes the immutable delivery document');
87
+ return { nodeId, payloadJson };
88
+ }
89
+ function writeCaptureAction(action, exitPolicy) {
90
+ const script = join(project, `${action}.sh`);
91
+ writeFileSync(script, `#!/bin/sh
92
+ attempt_file=.${action}.attempt
93
+ attempt=$(cat "$attempt_file" 2>/dev/null || printf 0)
94
+ attempt=$((attempt + 1))
95
+ printf '%s' "$attempt" > "$attempt_file"
96
+ cat > "${action}-$attempt.json"
97
+ ${exitPolicy}
98
+ `);
99
+ chmodSync(script, 0o755);
100
+ }
101
+ before(async () => {
102
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-node-outcome-delivery' });
103
+ root = h.spawnRoot('node outcome delivery seam root');
104
+ project = join(h.agentHome, 'outcome-delivery-project');
105
+ mkdirSync(join(project, '.crouter'), { recursive: true });
106
+ writeCaptureAction('accepted', 'exit 0');
107
+ writeCaptureAction('permanent', 'exit 78');
108
+ writeCaptureAction('retry', 'if [ "$attempt" -eq 1 ]; then exit 1; fi\nexit 0');
109
+ writeFileSync(join(project, '.crouter', 'config.json'), JSON.stringify({
110
+ humanActions: {
111
+ accepted: { argv: ['accepted.sh'], cwd: '.' },
112
+ permanent: { argv: ['permanent.sh'], cwd: '.' },
113
+ retry: { argv: ['retry.sh'], cwd: '.' },
114
+ },
115
+ }));
116
+ });
117
+ after(async () => {
118
+ if (h !== undefined)
119
+ await h.dispose();
120
+ });
121
+ test('canonical outcomes deliver the exact document and honor accepted, permanent, and retry exits', { timeout: 60_000 }, async () => {
122
+ const acceptedPayload = JSON.parse('{"callbackToken":"accepted-token","callbackId":"accepted-id","nested":{"emoji":"✓"}}');
123
+ const accepted = await createAndFinalize('accepted', acceptedPayload);
124
+ await h.tick();
125
+ const acceptedDelivery = await deliveryWhen(accepted.nodeId, (delivery) => delivery.state === 'accepted' && delivery.attempt === 1, 'accepted outcome delivery');
126
+ assert.equal(acceptedDelivery.acceptedAt === null, false, 'exit 0 settles delivery as accepted');
127
+ assertResultDocument(readCapture('accepted', 1), accepted.nodeId, 'accepted', accepted.payloadJson, 1);
128
+ const permanentPayload = JSON.parse('{"callbackToken":"permanent-token","callbackId":"permanent-id","nested":{"emoji":"✓"}}');
129
+ const permanent = await createAndFinalize('permanent', permanentPayload);
130
+ await h.tick();
131
+ const permanentDelivery = await deliveryWhen(permanent.nodeId, (delivery) => delivery.state === 'permanent_failed' && delivery.attempt === 1, 'permanent outcome delivery');
132
+ assert.equal(permanentDelivery.permanentFailedAt === null, false, 'exit 78 permanently fails delivery');
133
+ assert.equal(permanentDelivery.lastFailure?.exitCode, 78);
134
+ assertResultDocument(readCapture('permanent', 1), permanent.nodeId, 'permanent', permanent.payloadJson, 1);
135
+ const retryPayload = JSON.parse('{"callbackToken":"retry-token","callbackId":"retry-id","nested":{"emoji":"✓"}}');
136
+ const retry = await createAndFinalize('retry', retryPayload);
137
+ await h.tick();
138
+ const first = await deliveryWhen(retry.nodeId, (delivery) => delivery.state === 'pending' && delivery.attempt === 1 && delivery.lastFailure?.kind === 'exit', 'retryable first outcome delivery');
139
+ assert.ok(first.nextAttemptAt > Date.now(), 'exit 1 schedules a later retry');
140
+ assert.equal(first.lastFailure?.exitCode, 1);
141
+ const firstDocument = readCapture('retry', 1);
142
+ assertResultDocument(firstDocument, retry.nodeId, 'retry', retry.payloadJson, 1);
143
+ await h.tick(first.nextAttemptAt + 1);
144
+ const retried = await deliveryWhen(retry.nodeId, (delivery) => delivery.state === 'accepted' && delivery.attempt === 2, 'second outcome delivery accepted');
145
+ assert.equal(retried.acceptedAt === null, false, 'the second attempt accepts delivery');
146
+ const secondDocument = readCapture('retry', 2);
147
+ assertResultDocument(secondDocument, retry.nodeId, 'retry', retry.payloadJson, 2);
148
+ const { delivery: firstDelivery, ...firstImmutable } = firstDocument;
149
+ const { delivery: secondDelivery, ...secondImmutable } = secondDocument;
150
+ assert.deepEqual(secondImmutable, firstImmutable, 'only delivery.attempt changes across retries');
151
+ assert.deepEqual(firstDelivery, { attempt: 1 });
152
+ assert.deepEqual(secondDelivery, { attempt: 2 });
153
+ });
@@ -117,6 +117,12 @@ export declare function activeBackgroundBashJobs(contextDir: string): BashJobSta
117
117
  * passed at `now`. The daemon's deadline lane stops exactly these. A job with
118
118
  * no deadline (deliberate handoff, or one the node claimed) never appears. */
119
119
  export declare function expiredAutoBackgroundBashJobs(contextDir: string, now: number): BashJobStatus[];
120
+ /** Credit every armed auto-cancel deadline for an interval the node was not
121
+ * permitted to run. A profile pause stops the owning node, so without this the
122
+ * claim window burns down while the only agent that could claim it is stopped,
123
+ * and resume greets it with a job already canceled for not being claimed. A
124
+ * deliberate handoff carries no deadline and is untouched. */
125
+ export declare function shiftAutoBackgroundBashDeadlines(contextDir: string, byMs: number): number;
120
126
  export type BackgroundBashJobResult = 'backgrounded' | 'already-backgrounded' | 'finished' | 'missing';
121
127
  /** Atomically hand one foreground job to its detached supervisor. The rename
122
128
  * is the ownership decision: exactly one caller can move job.run to job.bg,
@@ -268,6 +268,28 @@ export function activeBackgroundBashJobs(contextDir) {
268
268
  export function expiredAutoBackgroundBashJobs(contextDir, now) {
269
269
  return activeBackgroundBashJobs(contextDir).filter((job) => job.deadlineAtMs !== null && now >= job.deadlineAtMs);
270
270
  }
271
+ /** Credit every armed auto-cancel deadline for an interval the node was not
272
+ * permitted to run. A profile pause stops the owning node, so without this the
273
+ * claim window burns down while the only agent that could claim it is stopped,
274
+ * and resume greets it with a job already canceled for not being claimed. A
275
+ * deliberate handoff carries no deadline and is untouched. */
276
+ export function shiftAutoBackgroundBashDeadlines(contextDir, byMs) {
277
+ if (!Number.isFinite(byMs) || byMs <= 0)
278
+ return 0;
279
+ let shifted = 0;
280
+ for (const job of activeBackgroundBashJobs(contextDir)) {
281
+ if (job.deadlineAtMs === null)
282
+ continue;
283
+ try {
284
+ writeJobDeadlineMs(bashJobPaths(contextDir, job.jobId), job.deadlineAtMs + byMs);
285
+ shifted += 1;
286
+ }
287
+ catch {
288
+ /* best-effort — a job that vanished mid-scan needs no credit */
289
+ }
290
+ }
291
+ return shifted;
292
+ }
271
293
  /** Atomically hand one foreground job to its detached supervisor. The rename
272
294
  * is the ownership decision: exactly one caller can move job.run to job.bg,
273
295
  * while the supervisor races to move the same source to job.done. */
@@ -1,7 +1,20 @@
1
- import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle, TerminalReason } from './types.js';
1
+ import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle, TerminalReason, NodeOutcomeKind } from './types.js';
2
+ /** Seed a node's row at BIRTH: identity columns + runtime columns taken from the
3
+ * incoming meta (defaults: status='active', the rest null). The only writer
4
+ * that sets runtime columns alongside identity in one statement — afterwards
5
+ * the atomic setters are the sole runtime writers. */
6
+ export interface OutcomeDeliveryBinding {
7
+ actionName: string;
8
+ argv: string[];
9
+ cwd: string;
10
+ configOrigin: string;
11
+ payload?: unknown;
12
+ }
2
13
  /** Create a node: scaffold its dirs, persist identity to meta.json, and seed the
3
14
  * row (identity + runtime from the incoming meta). Returns the hydrated view. */
4
- export declare function createNode(meta: NodeMeta): NodeMeta;
15
+ export declare function createNode(meta: NodeMeta, opts?: {
16
+ outcomeDelivery?: OutcomeDeliveryBinding;
17
+ }): NodeMeta;
5
18
  /** The canonical node record: durable identity (meta.json) ∪ authoritative
6
19
  * runtime (the row). Null if unknown. */
7
20
  export declare function getNode(nodeId: string): NodeMeta | null;
@@ -15,6 +28,12 @@ export declare function getRow(nodeId: string): NodeRow | null;
15
28
  * to adopt a caller's pane as a focus). pane is not UNIQUE in the schema, but a
16
29
  * live pane backs at most one node, so this returns the single match. */
17
30
  export declare function getRowByPane(pane: string): NodeRow | null;
31
+ /** Nodes whose persisted spawn deadline has elapsed and that remain live work. */
32
+ export declare function dueNodeDeadlines(nowIso: string): NodeRow[];
33
+ /** Write a claimed warm spare's deadline before it is handed to its caller. */
34
+ export declare function setNodeDeadline(nodeId: string, deadlineAt: string | null): boolean;
35
+ /** Shift active/idle profile deadlines by a completed profile pause duration. */
36
+ export declare function shiftProfileNodeDeadlines(profileId: string, pauseDurationMs: number): number;
18
37
  /** Merge an IDENTITY patch into a node's meta.json and re-index its identity
19
38
  * columns. Identity has a single writer per node, so this read-modify-write is
20
39
  * safe (the contended runtime fields were moved out — see the atomic setters
@@ -39,6 +58,17 @@ export declare function transitionStatusIntent(nodeId: string, fields: {
39
58
  }, opts?: {
40
59
  fromStatuses?: readonly NodeStatus[];
41
60
  requireNoFinalReport?: boolean;
61
+ /** Present ⇒ latch an outcome and make an armed delivery pending atomically. */
62
+ outcome?: {
63
+ kind: NodeOutcomeKind;
64
+ reason: TerminalReason;
65
+ detailJson: string | null;
66
+ at: string;
67
+ /** Canonical-final only: replace a prior failure outcome. */
68
+ supersede?: boolean;
69
+ /** The document composed at outcome time for an armed delivery. */
70
+ documentJson?: string | null;
71
+ };
42
72
  }): number;
43
73
  /** Read a node's committed-final latch pointer. Pure read, no side effect. */
44
74
  export declare function getFinalReport(nodeId: string): string | null;
@@ -135,6 +165,11 @@ export declare function listNodes(filter?: {
135
165
  withRecordedPid?: boolean;
136
166
  withRecordedPidOrFrozen?: boolean;
137
167
  }): NodeRow[];
168
+ /** Identity-only projection of every user-visible row: the broker's report
169
+ * resolver needs only "does this sender exist, and what is it called", and a
170
+ * three-column read stays cheap against a canvas of thousands of rows where a
171
+ * full `listNodes()` hydration does not. */
172
+ export declare function listNodeIdentities(): Array<Pick<NodeRow, 'node_id' | 'name' | 'created'>>;
138
173
  /** Mark an already-spawned node as an unclaimed spare for `recipeKey`. Hides it
139
174
  * from every listing until it is claimed. */
140
175
  export declare function registerWarmSpare(nodeId: string, recipeKey: string): void;
@@ -178,7 +213,10 @@ export declare function isWarmSpare(nodeId: string): boolean;
178
213
  * spare must not sort as an hour-old conversation. This is the one sanctioned
179
214
  * write of `created` after birth (`upsertRow` deliberately never touches it);
180
215
  * the caller mirrors the same stamp into meta.json. */
181
- export declare function claimWarmSpare(recipeKey: string, created: string): string | null;
216
+ export declare function claimWarmSpare(recipeKey: string, created: string, opts?: {
217
+ deadlineAt?: string;
218
+ outcomeDelivery?: OutcomeDeliveryBinding;
219
+ }): string | null;
182
220
  /** How many CLAIMABLE spares are parked for `recipeKey` (the depth check a
183
221
  * refill reads before minting). Uses the same liveness test as the claim, so a
184
222
  * spare the claim would skip can never hold the pool at depth 1 and starve
@@ -93,16 +93,21 @@ function upsertRow(meta) {
93
93
  pi_session_file=excluded.pi_session_file`)
94
94
  .run(meta.node_id, meta.name, meta.description ?? null, meta.cycles ?? null, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.profile_id ?? null, meta.parent ?? null, meta.created, typeof meta.pi_session_file === 'string' ? canonicalSessionFile(meta.pi_session_file) : null);
95
95
  }
96
- /** Seed a node's row at BIRTH: identity columns + runtime columns taken from the
97
- * incoming meta (defaults: status='active', the rest null). The only writer
98
- * that sets runtime columns alongside identity in one statement — afterwards
99
- * the atomic setters are the sole runtime writers. */
100
- function seedRow(meta) {
101
- openDb()
96
+ function insertArmedOutcomeDelivery(db, nodeId, delivery) {
97
+ const payloadJson = delivery.payload === undefined ? null : JSON.stringify(delivery.payload);
98
+ if (payloadJson === undefined)
99
+ throw new TypeError('outcome delivery payload must be JSON-serializable');
100
+ const now = Date.now();
101
+ db.prepare(`INSERT INTO node_outcome_deliveries
102
+ (node_id, state, action_name, argv_json, cwd, config_origin, payload_json, next_attempt_at, created_at, updated_at)
103
+ VALUES (?, 'armed', ?, ?, ?, ?, ?, 0, ?, ?)`).run(nodeId, delivery.actionName, JSON.stringify(delivery.argv), delivery.cwd, delivery.configOrigin, payloadJson, now, now);
104
+ }
105
+ function seedRow(meta, db = openDb()) {
106
+ db
102
107
  .prepare(`INSERT INTO nodes
103
108
  (node_id, name, description, cycles, kind, mode, lifecycle, cwd, host_kind, profile_id, parent, created, pi_session_file,
104
- status, intent, waiting_for, final_report, finalized_at, pi_pid, "window", tmux_session, pane)
105
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ status, intent, waiting_for, final_report, finalized_at, deadline_at, pi_pid, "window", tmux_session, pane)
110
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
106
111
  ON CONFLICT(node_id) DO UPDATE SET
107
112
  name=excluded.name, description=excluded.description, cycles=excluded.cycles,
108
113
  kind=excluded.kind, mode=excluded.mode,
@@ -113,7 +118,7 @@ function seedRow(meta) {
113
118
  final_report=excluded.final_report, finalized_at=excluded.finalized_at,
114
119
  pi_pid=excluded.pi_pid, "window"=excluded."window", tmux_session=excluded.tmux_session,
115
120
  pane=excluded.pane`)
116
- .run(meta.node_id, meta.name, meta.description ?? null, meta.cycles ?? null, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.profile_id ?? null, meta.parent ?? null, meta.created, typeof meta.pi_session_file === 'string' ? canonicalSessionFile(meta.pi_session_file) : null, meta.status ?? 'active', meta.intent ?? null, meta.waiting_for ?? null, meta.final_report ?? null, meta.finalized_at ?? null, meta.pi_pid ?? null, meta.window ?? null, meta.tmux_session ?? null, meta.pane ?? null);
121
+ .run(meta.node_id, meta.name, meta.description ?? null, meta.cycles ?? null, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.profile_id ?? null, meta.parent ?? null, meta.created, typeof meta.pi_session_file === 'string' ? canonicalSessionFile(meta.pi_session_file) : null, meta.status ?? 'active', meta.intent ?? null, meta.waiting_for ?? null, meta.final_report ?? null, meta.finalized_at ?? null, meta.deadline_at ?? null, meta.pi_pid ?? null, meta.window ?? null, meta.tmux_session ?? null, meta.pane ?? null);
117
122
  }
118
123
  function rowFrom(r) {
119
124
  return {
@@ -140,6 +145,12 @@ function rowFrom(r) {
140
145
  terminal_reason: r['terminal_reason'] ?? null,
141
146
  final_report: r['final_report'] ?? null,
142
147
  finalized_at: r['finalized_at'] ?? null,
148
+ outcome_kind: r['outcome_kind'] ?? null,
149
+ outcome_reason: r['outcome_reason'] ?? null,
150
+ outcome_at: r['outcome_at'] ?? null,
151
+ outcome_revision: r['outcome_revision'] ?? 0,
152
+ outcome_detail_json: r['outcome_detail_json'] ?? null,
153
+ deadline_at: r['deadline_at'] ?? null,
143
154
  pi_pid: r['pi_pid'] ?? null,
144
155
  pi_pid_identity: r['pi_pid_identity'] ?? null,
145
156
  window: r['window'] ?? null,
@@ -151,7 +162,7 @@ function rowFrom(r) {
151
162
  * no row exists yet (the hydration then falls back to whatever the meta held). */
152
163
  function runtimeFromRow(nodeId) {
153
164
  const r = openDb()
154
- .prepare('SELECT status, intent, waiting_for, frozen_at, respawn_failures, respawn_not_before, launched_at, last_settled_at, terminal_reason, final_report, finalized_at, pi_pid, pi_pid_identity, "window", tmux_session, pane FROM nodes WHERE node_id = ?')
165
+ .prepare('SELECT status, intent, waiting_for, frozen_at, respawn_failures, respawn_not_before, launched_at, last_settled_at, terminal_reason, final_report, finalized_at, outcome_kind, outcome_reason, outcome_at, outcome_revision, outcome_detail_json, deadline_at, pi_pid, pi_pid_identity, "window", tmux_session, pane FROM nodes WHERE node_id = ?')
155
166
  .get(nodeId);
156
167
  if (r === undefined)
157
168
  return null;
@@ -167,6 +178,12 @@ function runtimeFromRow(nodeId) {
167
178
  terminal_reason: r['terminal_reason'] ?? null,
168
179
  final_report: r['final_report'] ?? null,
169
180
  finalized_at: r['finalized_at'] ?? null,
181
+ outcome_kind: r['outcome_kind'] ?? null,
182
+ outcome_reason: r['outcome_reason'] ?? null,
183
+ outcome_at: r['outcome_at'] ?? null,
184
+ outcome_revision: r['outcome_revision'] ?? 0,
185
+ outcome_detail_json: r['outcome_detail_json'] ?? null,
186
+ deadline_at: r['deadline_at'] ?? null,
170
187
  pi_pid: r['pi_pid'] ?? null,
171
188
  pi_pid_identity: r['pi_pid_identity'] ?? null,
172
189
  window: r['window'] ?? null,
@@ -177,7 +194,7 @@ function runtimeFromRow(nodeId) {
177
194
  // Nodes
178
195
  /** Create a node: scaffold its dirs, persist identity to meta.json, and seed the
179
196
  * row (identity + runtime from the incoming meta). Returns the hydrated view. */
180
- export function createNode(meta) {
197
+ export function createNode(meta, opts = {}) {
181
198
  ensureHome();
182
199
  const scaffolded = !existsSync(nodeDir(meta.node_id));
183
200
  ensureNodeDirs(meta.node_id);
@@ -191,7 +208,11 @@ export function createNode(meta) {
191
208
  rmSync(nodeDir(meta.node_id), { recursive: true, force: true });
192
209
  throw error;
193
210
  }
194
- seedRow(meta);
211
+ withCanvasWrite((db) => {
212
+ seedRow(meta, db);
213
+ if (opts.outcomeDelivery !== undefined)
214
+ insertArmedOutcomeDelivery(db, meta.node_id, opts.outcomeDelivery);
215
+ });
195
216
  return getNode(meta.node_id);
196
217
  }
197
218
  /** The canonical node record: durable identity (meta.json) ∪ authoritative
@@ -237,6 +258,35 @@ export function getRowByPane(pane) {
237
258
  .get(pane);
238
259
  return r ? rowFrom(r) : null;
239
260
  }
261
+ /** Nodes whose persisted spawn deadline has elapsed and that remain live work. */
262
+ export function dueNodeDeadlines(nowIso) {
263
+ return openDb().prepare(`SELECT * FROM nodes
264
+ WHERE deadline_at IS NOT NULL AND outcome_at IS NULL
265
+ AND deadline_at <= ? AND status IN ('active','idle') AND kind != 'human'
266
+ ORDER BY deadline_at, created, node_id`).all(nowIso).map(rowFrom);
267
+ }
268
+ /** Write a claimed warm spare's deadline before it is handed to its caller. */
269
+ export function setNodeDeadline(nodeId, deadlineAt) {
270
+ return Number(openDb().prepare('UPDATE nodes SET deadline_at = ? WHERE node_id = ?').run(deadlineAt, nodeId).changes) === 1;
271
+ }
272
+ /** Shift active/idle profile deadlines by a completed profile pause duration. */
273
+ export function shiftProfileNodeDeadlines(profileId, pauseDurationMs) {
274
+ if (!Number.isFinite(pauseDurationMs) || pauseDurationMs <= 0)
275
+ return 0;
276
+ return withCanvasWrite((db) => {
277
+ const rows = db.prepare(`SELECT node_id, deadline_at FROM nodes
278
+ WHERE profile_id = ? AND deadline_at IS NOT NULL AND status IN ('active', 'idle')`).all(profileId);
279
+ const update = db.prepare('UPDATE nodes SET deadline_at = ? WHERE node_id = ? AND deadline_at = ?');
280
+ let shifted = 0;
281
+ for (const row of rows) {
282
+ const at = Date.parse(row.deadline_at);
283
+ if (Number.isNaN(at))
284
+ continue;
285
+ shifted += Number(update.run(new Date(at + pauseDurationMs).toISOString(), row.node_id, row.deadline_at).changes);
286
+ }
287
+ return shifted;
288
+ });
289
+ }
240
290
  /** Merge an IDENTITY patch into a node's meta.json and re-index its identity
241
291
  * columns. Identity has a single writer per node, so this read-modify-write is
242
292
  * safe (the contended runtime fields were moved out — see the atomic setters
@@ -279,32 +329,84 @@ export function setIntent(nodeId, intent) {
279
329
  * unchanged. Returns the row-changed count so the caller can distinguish "no
280
330
  * such node", "gate failed", and "already applied" without a second query. */
281
331
  export function transitionStatusIntent(nodeId, fields, opts = {}) {
282
- const assignments = [];
283
- const values = [];
284
- if (Object.prototype.hasOwnProperty.call(fields, 'status')) {
285
- assignments.push('status = ?');
286
- values.push(fields.status ?? null);
287
- }
288
- if (Object.prototype.hasOwnProperty.call(fields, 'intent')) {
289
- assignments.push('intent = ?');
290
- values.push(fields.intent ?? null);
291
- }
292
- if (Object.prototype.hasOwnProperty.call(fields, 'terminal_reason')) {
293
- assignments.push('terminal_reason = ?');
294
- values.push(fields.terminal_reason ?? null);
295
- }
296
- const predicates = ['node_id = ?'];
297
- values.push(nodeId);
298
- if (opts.fromStatuses !== undefined) {
299
- predicates.push(`status IN (${opts.fromStatuses.map(() => '?').join(', ')})`);
300
- values.push(...opts.fromStatuses);
332
+ // Keep the ordinary lifecycle write exactly as its pre-outcome form: one
333
+ // conditional UPDATE and no transaction boundary.
334
+ if (opts.outcome === undefined) {
335
+ const assignments = [];
336
+ const values = [];
337
+ if (Object.prototype.hasOwnProperty.call(fields, 'status')) {
338
+ assignments.push('status = ?');
339
+ values.push(fields.status ?? null);
340
+ }
341
+ if (Object.prototype.hasOwnProperty.call(fields, 'intent')) {
342
+ assignments.push('intent = ?');
343
+ values.push(fields.intent ?? null);
344
+ }
345
+ if (Object.prototype.hasOwnProperty.call(fields, 'terminal_reason')) {
346
+ assignments.push('terminal_reason = ?');
347
+ values.push(fields.terminal_reason ?? null);
348
+ }
349
+ const predicates = ['node_id = ?'];
350
+ values.push(nodeId);
351
+ if (opts.fromStatuses !== undefined) {
352
+ predicates.push(`status IN (${opts.fromStatuses.map(() => '?').join(', ')})`);
353
+ values.push(...opts.fromStatuses);
354
+ }
355
+ if (opts.requireNoFinalReport === true)
356
+ predicates.push('final_report IS NULL');
357
+ const result = openDb()
358
+ .prepare(`UPDATE nodes SET ${assignments.join(', ')} WHERE ${predicates.join(' AND ')}`)
359
+ .run(...values);
360
+ return Number(result.changes);
301
361
  }
302
- if (opts.requireNoFinalReport === true)
303
- predicates.push('final_report IS NULL');
304
- const result = openDb()
305
- .prepare(`UPDATE nodes SET ${assignments.join(', ')} WHERE ${predicates.join(' AND ')}`)
306
- .run(...values);
307
- return Number(result.changes);
362
+ const outcome = opts.outcome;
363
+ return withCanvasWrite((db) => {
364
+ const assignments = [];
365
+ const values = [];
366
+ if (Object.prototype.hasOwnProperty.call(fields, 'status')) {
367
+ assignments.push('status = ?');
368
+ values.push(fields.status ?? null);
369
+ }
370
+ if (Object.prototype.hasOwnProperty.call(fields, 'intent')) {
371
+ assignments.push('intent = ?');
372
+ values.push(fields.intent ?? null);
373
+ }
374
+ if (Object.prototype.hasOwnProperty.call(fields, 'terminal_reason')) {
375
+ assignments.push('terminal_reason = ?');
376
+ values.push(fields.terminal_reason ?? null);
377
+ }
378
+ const outcomeValue = (value, column) => outcome.supersede === true ? '?' : `CASE WHEN outcome_at IS NULL THEN ? ELSE ${column} END`;
379
+ assignments.push(`outcome_kind = ${outcomeValue(outcome.kind, 'outcome_kind')}`, `outcome_reason = ${outcomeValue(outcome.reason, 'outcome_reason')}`, `outcome_detail_json = ${outcomeValue(outcome.detailJson, 'outcome_detail_json')}`, `outcome_revision = ${outcome.supersede === true ? 'outcome_revision + 1' : 'CASE WHEN outcome_at IS NULL THEN outcome_revision + 1 ELSE outcome_revision END'}`, `outcome_at = ${outcomeValue(outcome.at, 'outcome_at')}`);
380
+ values.push(outcome.kind, outcome.reason, outcome.detailJson, outcome.at);
381
+ const predicates = ['node_id = ?'];
382
+ values.push(nodeId);
383
+ if (opts.fromStatuses !== undefined) {
384
+ predicates.push(`status IN (${opts.fromStatuses.map(() => '?').join(', ')})`);
385
+ values.push(...opts.fromStatuses);
386
+ }
387
+ if (opts.requireNoFinalReport === true)
388
+ predicates.push('final_report IS NULL');
389
+ const result = db.prepare(`UPDATE nodes SET ${assignments.join(', ')} WHERE ${predicates.join(' AND ')}`).run(...values);
390
+ if (Number(result.changes) === 0)
391
+ return 0;
392
+ const latched = db.prepare('SELECT outcome_at FROM nodes WHERE node_id = ?').get(nodeId);
393
+ if (outcome.supersede === true || latched.outcome_at === outcome.at) {
394
+ const now = Date.now();
395
+ if (outcome.supersede === true) {
396
+ db.prepare(`UPDATE node_outcome_deliveries
397
+ SET state = 'pending', attempt = 0, document_json = ?, next_attempt_at = ?,
398
+ claimed_at = NULL, claim_owner = NULL, accepted_at = NULL, permanent_failed_at = NULL,
399
+ last_failure_json = NULL, updated_at = ?
400
+ WHERE node_id = ?`).run(outcome.documentJson ?? null, now, now, nodeId);
401
+ }
402
+ else {
403
+ db.prepare(`UPDATE node_outcome_deliveries
404
+ SET state = 'pending', next_attempt_at = ?, document_json = ?, updated_at = ?
405
+ WHERE node_id = ? AND state = 'armed' AND ? IS NOT NULL`).run(now, outcome.documentJson ?? null, now, nodeId, outcome.documentJson ?? null);
406
+ }
407
+ }
408
+ return Number(result.changes);
409
+ });
308
410
  }
309
411
  /** Read a node's committed-final latch pointer. Pure read, no side effect. */
310
412
  export function getFinalReport(nodeId) {
@@ -479,6 +581,15 @@ export function listNodes(filter) {
479
581
  .all(...params);
480
582
  return rows.map(rowFrom);
481
583
  }
584
+ /** Identity-only projection of every user-visible row: the broker's report
585
+ * resolver needs only "does this sender exist, and what is it called", and a
586
+ * three-column read stays cheap against a canvas of thousands of rows where a
587
+ * full `listNodes()` hydration does not. */
588
+ export function listNodeIdentities() {
589
+ return openDb()
590
+ .prepare(`SELECT node_id, name, created FROM nodes WHERE ${NOT_A_SPARE} ORDER BY created`)
591
+ .all();
592
+ }
482
593
  // Warm pool — spare booted roots awaiting a `prefer_warm` claim (see
483
594
  // `runtime/warm-pool.ts` for the recipe key and the mint/refill policy).
484
595
  /** Mark an already-spawned node as an unclaimed spare for `recipeKey`. Hides it
@@ -553,7 +664,7 @@ function spareIsClaimable(row) {
553
664
  * spare must not sort as an hour-old conversation. This is the one sanctioned
554
665
  * write of `created` after birth (`upsertRow` deliberately never touches it);
555
666
  * the caller mirrors the same stamp into meta.json. */
556
- export function claimWarmSpare(recipeKey, created) {
667
+ export function claimWarmSpare(recipeKey, created, opts = {}) {
557
668
  return withCanvasWrite((db) => {
558
669
  const row = warmSpareRowsFor(db, recipeKey).find(spareIsClaimable);
559
670
  if (row === undefined)
@@ -561,7 +672,10 @@ export function claimWarmSpare(recipeKey, created) {
561
672
  const res = db.prepare('DELETE FROM warm_pool WHERE node_id = ?').run(row.node_id);
562
673
  if (res.changes === 0)
563
674
  return null;
564
- db.prepare('UPDATE nodes SET created = ? WHERE node_id = ?').run(created, row.node_id);
675
+ db.prepare('UPDATE nodes SET created = ?, deadline_at = ? WHERE node_id = ?')
676
+ .run(created, opts.deadlineAt ?? null, row.node_id);
677
+ if (opts.outcomeDelivery !== undefined)
678
+ insertArmedOutcomeDelivery(db, row.node_id, opts.outcomeDelivery);
565
679
  return row.node_id;
566
680
  });
567
681
  }
@@ -21,6 +21,7 @@
21
21
  // serve the daemon-owned run executor in src/daemon/cron-run.ts.
22
22
  import { openDb, withCanvasWrite } from './db.js';
23
23
  import { capturePidIdentities, identitiesMatch, killProcessGroup } from './pid.js';
24
+ import { isProfilePaused } from '../profiles/manifest.js';
24
25
  /** Ring bound for `cron_runs`: recordCronRun prunes each cron's history to
25
26
  * its most recent this-many rows. */
26
27
  export const CRON_RUN_RING = 20;
@@ -305,7 +306,7 @@ export function dueClockCrons(nowIso) {
305
306
  .prepare(`SELECT * FROM crons
306
307
  WHERE state = 'active' AND run_state = 'idle' AND fire_at <= ?
307
308
  ORDER BY fire_at, created, cron_id`)
308
- .all(nowIso).map(cronFrom);
309
+ .all(nowIso).map(cronFrom).filter((cron) => !isProfilePaused(cron.profile));
309
310
  }
310
311
  /** Atomically acquire the idle run lease: flips `run_state` to 'running' and
311
312
  * records lease/pid metadata, but ONLY if the row is still idle (the
@@ -6,5 +6,6 @@ export * from './source.js';
6
6
  export * from './history.js';
7
7
  export * from './focuses.js';
8
8
  export * from './crons.js';
9
+ export * from './node-outcome-deliveries.js';
9
10
  export * from './telemetry.js';
10
11
  export { openDb, closeDb } from './db.js';
@@ -8,5 +8,6 @@ export * from './source.js';
8
8
  export * from './history.js';
9
9
  export * from './focuses.js';
10
10
  export * from './crons.js';
11
+ export * from './node-outcome-deliveries.js';
11
12
  export * from './telemetry.js';
12
13
  export { openDb, closeDb } from './db.js';
@@ -1360,6 +1360,52 @@ function addNodeLabelColumns(db) {
1360
1360
  upd.run(description, cycles, id);
1361
1361
  }
1362
1362
  }
1363
+ /** v39 — durable terminal outcomes, spawn deadlines, and externally-owned
1364
+ * outcome-delivery attempts. The outcome columns live on `nodes` so the same
1365
+ * conditional terminal-status UPDATE can latch them atomically. */
1366
+ function addNodeOutcomeAndDeadline(db) {
1367
+ const columns = nodeColumns(db);
1368
+ addNodeColumnIfMissing(db, columns, 'outcome_kind', 'ALTER TABLE nodes ADD COLUMN outcome_kind TEXT;');
1369
+ addNodeColumnIfMissing(db, columns, 'outcome_reason', 'ALTER TABLE nodes ADD COLUMN outcome_reason TEXT;');
1370
+ addNodeColumnIfMissing(db, columns, 'outcome_at', 'ALTER TABLE nodes ADD COLUMN outcome_at TEXT;');
1371
+ addNodeColumnIfMissing(db, columns, 'outcome_revision', 'ALTER TABLE nodes ADD COLUMN outcome_revision INTEGER NOT NULL DEFAULT 0;');
1372
+ addNodeColumnIfMissing(db, columns, 'outcome_detail_json', 'ALTER TABLE nodes ADD COLUMN outcome_detail_json TEXT;');
1373
+ addNodeColumnIfMissing(db, columns, 'deadline_at', 'ALTER TABLE nodes ADD COLUMN deadline_at TEXT;');
1374
+ db.exec(`
1375
+ CREATE TABLE IF NOT EXISTS node_outcome_deliveries (
1376
+ node_id TEXT PRIMARY KEY,
1377
+ state TEXT NOT NULL,
1378
+ attempt INTEGER NOT NULL DEFAULT 0,
1379
+ action_name TEXT NOT NULL,
1380
+ argv_json TEXT NOT NULL,
1381
+ cwd TEXT NOT NULL,
1382
+ config_origin TEXT NOT NULL,
1383
+ payload_json TEXT,
1384
+ document_json TEXT,
1385
+ next_attempt_at INTEGER NOT NULL DEFAULT 0,
1386
+ claimed_at INTEGER,
1387
+ claim_owner TEXT,
1388
+ accepted_at INTEGER,
1389
+ permanent_failed_at INTEGER,
1390
+ last_failure_json TEXT,
1391
+ created_at INTEGER NOT NULL,
1392
+ updated_at INTEGER NOT NULL,
1393
+ FOREIGN KEY(node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
1394
+ );
1395
+ CREATE INDEX IF NOT EXISTS idx_node_outcome_deliveries_due
1396
+ ON node_outcome_deliveries(state, next_attempt_at);
1397
+ CREATE INDEX IF NOT EXISTS idx_nodes_deadline ON nodes(deadline_at) WHERE deadline_at IS NOT NULL;
1398
+
1399
+ UPDATE nodes SET outcome_kind='result', outcome_reason='finalized',
1400
+ outcome_at=finalized_at, outcome_revision=1
1401
+ WHERE final_report IS NOT NULL AND outcome_at IS NULL;
1402
+
1403
+ UPDATE nodes SET outcome_kind='failure',
1404
+ outcome_reason=COALESCE(terminal_reason,'crashed'),
1405
+ outcome_at=COALESCE(finalized_at, created), outcome_revision=1
1406
+ WHERE final_report IS NULL AND status IN ('done','dead','canceled') AND outcome_at IS NULL;
1407
+ `);
1408
+ }
1363
1409
  /** The journaled lane of the state-migration registry, adapted to this chain's
1364
1410
  * step shape. Registry order is preserved, so these versions stay stable as
1365
1411
  * long as the registry is append-only (same contract as the local list). */
@@ -1411,6 +1457,7 @@ export const MIGRATIONS = [
1411
1457
  /* v36 */ makeNodeSessionFileUnique,
1412
1458
  /* v37 */ repairNodeSessionFileUniqueIndex,
1413
1459
  /* v38 */ addNodeLabelColumns,
1460
+ /* v39 */ addNodeOutcomeAndDeadline,
1414
1461
  ...JOURNALED_STATE_MIGRATIONS,
1415
1462
  ];
1416
1463
  /** Migration indexes that manage their OWN transaction and therefore must not