@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.
- package/dist/api/client.d.ts +22 -4
- package/dist/api/client.js +34 -0
- package/dist/api/dto/broker-ops.d.ts +11 -0
- package/dist/api/dto/broker.d.ts +3 -8
- package/dist/api/dto/common.d.ts +1 -1
- package/dist/api/dto/messages.d.ts +2 -2
- package/dist/api/dto/node-outcomes.d.ts +80 -0
- package/dist/api/dto/node-outcomes.js +2 -0
- package/dist/api/dto/nodes.d.ts +9 -0
- package/dist/api/dto/profiles.d.ts +5 -0
- package/dist/api/dto/reports.d.ts +45 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
- package/dist/clients/attach/overlays/graph.d.ts +5 -0
- package/dist/clients/attach/overlays/graph.js +65 -47
- package/dist/clients/attach/overlays/pickers.js +8 -9
- package/dist/clients/attach/render/card-presentation.js +1 -1
- package/dist/clients/attach/viewer.js +1286 -1260
- package/dist/commands/api-client.js +22 -23
- package/dist/commands/node/create.js +38 -3
- package/dist/commands/node/inspect.js +3 -7
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/node/outcome.d.ts +2 -0
- package/dist/commands/node/outcome.js +134 -0
- package/dist/commands/node.js +2 -1
- package/dist/commands/profile/pause.js +17 -14
- package/dist/commands/push.js +102 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
- package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
- package/dist/commands/sys/panels/models-panel.js +2 -2
- package/dist/commands/sys/settings-shell.d.ts +1 -1
- package/dist/commands/sys/settings-shell.js +6 -0
- package/dist/commands/sys/settings.js +3 -3
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
- package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
- package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
- package/dist/core/__tests__/node-outcome.test.js +184 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
- package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
- package/dist/core/bash-jobs.d.ts +6 -0
- package/dist/core/bash-jobs.js +22 -0
- package/dist/core/canvas/canvas.d.ts +41 -3
- package/dist/core/canvas/canvas.js +153 -39
- package/dist/core/canvas/crons.js +2 -1
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/migrations.js +47 -0
- package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
- package/dist/core/canvas/node-outcome-deliveries.js +163 -0
- package/dist/core/canvas/pid.d.ts +7 -0
- package/dist/core/canvas/pid.js +17 -0
- package/dist/core/canvas/render-source.js +2 -2
- package/dist/core/canvas/types.d.ts +42 -1
- package/dist/core/canvas/types.js +2 -1
- package/dist/core/config.d.ts +4 -1
- package/dist/core/config.js +25 -1
- package/dist/core/feed/feed.d.ts +8 -0
- package/dist/core/feed/feed.js +25 -4
- package/dist/core/feed/inbox.d.ts +5 -0
- package/dist/core/feed/inbox.js +13 -0
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +3 -0
- package/dist/core/profiles/manifest.d.ts +3 -1
- package/dist/core/profiles/manifest.js +6 -1
- package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
- package/dist/core/runtime/broker/daemon-ops.js +6 -0
- package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
- package/dist/core/runtime/broker/engine-drive.js +74 -27
- package/dist/core/runtime/broker/frame-dispatch.js +1 -0
- package/dist/core/runtime/broker/read-ops.js +14 -0
- package/dist/core/runtime/broker/rebind.js +0 -7
- package/dist/core/runtime/broker-protocol.d.ts +1 -6
- package/dist/core/runtime/fleet.d.ts +7 -0
- package/dist/core/runtime/headless-pi.d.ts +13 -0
- package/dist/core/runtime/headless-pi.js +10 -2
- package/dist/core/runtime/lifecycle.d.ts +6 -1
- package/dist/core/runtime/lifecycle.js +50 -1
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +2 -1
- package/dist/core/runtime/outcome-document.d.ts +70 -0
- package/dist/core/runtime/outcome-document.js +97 -0
- package/dist/core/runtime/revive.js +16 -21
- package/dist/core/runtime/spawn.d.ts +5 -1
- package/dist/core/runtime/spawn.js +18 -7
- package/dist/core/runtime/stamp/channel.d.ts +3 -5
- package/dist/core/runtime/stamp/channel.js +3 -10
- package/dist/core/runtime/stop-guard.js +2 -2
- package/dist/core/runtime/structured-output.d.ts +24 -3
- package/dist/core/runtime/structured-output.js +42 -3
- package/dist/core/runtime/tmux-bindings.js +4 -0
- package/dist/core/runtime/warm-pool.d.ts +5 -2
- package/dist/core/runtime/warm-pool.js +5 -5
- package/dist/core/subscription-state.js +7 -5
- package/dist/core/substrate/render.js +10 -10
- package/dist/core/user-settings.d.ts +18 -1
- package/dist/core/user-settings.js +37 -0
- package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
- package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
- package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
- package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
- package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
- package/dist/daemon/api/handlers/broker-ops.js +53 -24
- package/dist/daemon/api/handlers/canvas.js +2 -2
- package/dist/daemon/api/handlers/messages.js +12 -2
- package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
- package/dist/daemon/api/handlers/node-outcomes.js +136 -0
- package/dist/daemon/api/handlers/nodes.js +67 -1
- package/dist/daemon/api/handlers/profiles.js +53 -1
- package/dist/daemon/api/handlers/reports.js +197 -33
- package/dist/daemon/api/map.d.ts +7 -2
- package/dist/daemon/api/map.js +78 -4
- package/dist/daemon/api/server.js +74 -7
- package/dist/daemon/crtrd.js +12 -0
- package/dist/daemon/fleet.d.ts +4 -8
- package/dist/daemon/fleet.js +48 -9
- package/dist/daemon/messaging/node-message.js +2 -1
- package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
- package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
- package/dist/daemon/park-activity.d.ts +9 -0
- package/dist/daemon/park-activity.js +30 -0
- package/dist/daemon/park-pending.d.ts +10 -5
- package/dist/daemon/park-pending.js +18 -8
- package/dist/daemon/reconcilers/bash-deadline.js +6 -0
- package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
- package/dist/daemon/reconcilers/broker-supervision.js +29 -33
- package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
- package/dist/daemon/reconcilers/node-deadline.js +82 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
- package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
- package/dist/pi-extensions/canvas-structured-output.js +23 -297
- package/dist/shared/env.d.ts +8 -0
- package/dist/shared/env.js +14 -0
- package/dist/shared/generated-context.d.ts +3 -9
- package/dist/shared/generated-context.js +8 -16
- package/dist/types.d.ts +12 -0
- package/dist/types.js +1 -0
- package/package.json +9 -8
- 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
|
+
});
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -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,
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
assignments
|
|
286
|
-
values
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
predicates
|
|
300
|
-
values.push(
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
.
|
|
307
|
-
|
|
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 = ?')
|
|
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
|
|
@@ -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
|