@north-light/crouter 0.3.236 → 0.3.237
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 +2 -1
- package/dist/api/client.js +3 -0
- package/dist/api/dto/broker-ops.d.ts +1 -1
- package/dist/api/dto/common.d.ts +2 -0
- package/dist/api/dto/lifecycle.d.ts +9 -2
- package/dist/api/dto/messages.d.ts +5 -0
- package/dist/api/dto/nodes.d.ts +10 -2
- package/dist/api/dto/worktree.d.ts +10 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
- package/dist/builtin-memory/internal/agent-shaping.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
- package/dist/clients/attach/render/tool-calls.js +18 -3
- package/dist/clients/attach/viewer.js +426 -426
- package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
- package/dist/clients/inbox/review/roundtrip.js +1 -1
- package/dist/commands/memory/lint.js +22 -13
- package/dist/commands/memory/shared.js +1 -1
- package/dist/commands/node/create.d.ts +1 -1
- package/dist/commands/node/create.js +1 -1
- package/dist/commands/node/lifecycle.js +1 -1
- package/dist/commands/node-worktree.js +39 -8
- package/dist/commands/revive.js +11 -2
- package/dist/commands/sys/context/admin/actions.d.ts +48 -0
- package/dist/commands/sys/context/admin/actions.js +246 -0
- package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
- package/dist/commands/sys/context/admin/axes-panel.js +117 -0
- package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
- package/dist/commands/sys/context/admin/detail-panel.js +178 -0
- package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
- package/dist/commands/sys/context/admin/docs-panel.js +186 -0
- package/dist/commands/sys/context/admin/model.d.ts +75 -0
- package/dist/commands/sys/context/admin/model.js +209 -0
- package/dist/commands/sys/context/admin/shell.d.ts +82 -0
- package/dist/commands/sys/context/admin/shell.js +669 -0
- package/dist/commands/sys/context/admin.d.ts +1 -0
- package/dist/commands/sys/context/admin.js +124 -0
- package/dist/commands/sys/context/doc.d.ts +1 -0
- package/dist/commands/sys/context/doc.js +196 -0
- package/dist/commands/sys/context/prompt-review.d.ts +1 -0
- package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
- package/dist/commands/sys/context/resolve.d.ts +55 -0
- package/dist/commands/sys/context/resolve.js +428 -0
- package/dist/commands/sys/context/sysprompt.d.ts +1 -0
- package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
- package/dist/commands/sys/context.d.ts +2 -0
- package/dist/commands/sys/context.js +20 -0
- package/dist/commands/sys.js +3 -4
- package/dist/core/__tests__/child-death-wake.test.js +38 -22
- package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
- package/dist/core/__tests__/daemon-boot.test.js +40 -28
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
- package/dist/core/__tests__/integration/revive.test.js +12 -12
- package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
- package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
- package/dist/core/__tests__/migration.test.js +38 -1
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
- package/dist/core/__tests__/revive-capacity.test.js +127 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
- package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
- package/dist/core/canvas/browse/render.js +11 -3
- package/dist/core/canvas/canvas.d.ts +15 -3
- package/dist/core/canvas/canvas.js +52 -16
- package/dist/core/canvas/migrations.js +21 -0
- package/dist/core/canvas/nav-render.js +3 -1
- package/dist/core/canvas/remote-canvas-source.js +2 -0
- package/dist/core/canvas/render-source.d.ts +3 -1
- package/dist/core/canvas/render-source.js +7 -3
- package/dist/core/canvas/status-glyph.d.ts +10 -1
- package/dist/core/canvas/status-glyph.js +19 -3
- package/dist/core/canvas/types.d.ts +20 -1
- package/dist/core/feed/feed.js +1 -1
- package/dist/core/human/feedback-companion.js +5 -2
- package/dist/core/memory/lint.d.ts +19 -0
- package/dist/core/memory/lint.js +97 -0
- package/dist/core/memory-resolver.d.ts +4 -0
- package/dist/core/memory-resolver.js +7 -7
- package/dist/core/preview-registry.js +4 -4
- package/dist/core/review/realize.js +4 -2
- package/dist/core/runtime/close.js +1 -1
- package/dist/core/runtime/fleet.d.ts +29 -1
- package/dist/core/runtime/fleet.js +23 -0
- package/dist/core/runtime/host.js +32 -11
- package/dist/core/runtime/launch-prompt.d.ts +1 -1
- package/dist/core/runtime/launch-prompt.js +2 -2
- package/dist/core/runtime/lifecycle.d.ts +4 -2
- package/dist/core/runtime/lifecycle.js +12 -1
- package/dist/core/runtime/memory.js +1 -1
- package/dist/core/runtime/reopen.js +10 -1
- package/dist/core/runtime/reset.js +1 -1
- package/dist/core/runtime/revive-all.d.ts +3 -0
- package/dist/core/runtime/revive-all.js +10 -4
- package/dist/core/runtime/revive.d.ts +12 -0
- package/dist/core/runtime/revive.js +62 -3
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +36 -7
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/core/substrate/index.d.ts +6 -3
- package/dist/core/substrate/index.js +4 -3
- package/dist/core/substrate/on-read.d.ts +0 -8
- package/dist/core/substrate/on-read.js +45 -124
- package/dist/core/substrate/plan.d.ts +94 -0
- package/dist/core/substrate/plan.js +266 -0
- package/dist/core/substrate/render.d.ts +5 -6
- package/dist/core/substrate/render.js +76 -129
- package/dist/core/substrate/schema.d.ts +1 -6
- package/dist/core/substrate/schema.js +1 -15
- package/dist/core/substrate/session-cache.d.ts +2 -10
- package/dist/core/substrate/session-cache.js +9 -47
- package/dist/core/substrate/subject-fields.d.ts +1 -4
- package/dist/core/substrate/subject-fields.js +2 -5
- package/dist/core/substrate/surface-match.d.ts +37 -17
- package/dist/core/substrate/surface-match.js +44 -53
- package/dist/core/tui/page-host.d.ts +6 -0
- package/dist/core/tui/page-host.js +12 -3
- package/dist/core/worktree.d.ts +8 -0
- package/dist/core/worktree.js +65 -0
- package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
- package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
- package/dist/daemon/__tests__/thaw-order.test.js +55 -0
- package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
- package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
- package/dist/daemon/api/bridge.js +5 -1
- package/dist/daemon/api/handlers/attach.js +5 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +10 -2
- package/dist/daemon/api/handlers/nodes.js +8 -1
- package/dist/daemon/api/handlers/worktree.js +10 -1
- package/dist/daemon/api/map.js +7 -0
- package/dist/daemon/companion-retire.js +1 -1
- package/dist/daemon/cron/passes.d.ts +0 -5
- package/dist/daemon/cron/passes.js +0 -20
- package/dist/daemon/cron/sinks.js +1 -1
- package/dist/daemon/cron-run.d.ts +5 -6
- package/dist/daemon/cron-run.js +7 -16
- package/dist/daemon/crtrd.d.ts +0 -10
- package/dist/daemon/crtrd.js +23 -61
- package/dist/daemon/fleet.d.ts +10 -7
- package/dist/daemon/fleet.js +71 -12
- package/dist/daemon/messaging/node-message.js +3 -1
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
- package/dist/daemon/reconcilers/broker-supervision.js +39 -52
- package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
- package/dist/daemon/reconcilers/controller-death.js +26 -0
- package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
- package/dist/daemon/reconcilers/cron-lane.js +0 -3
- package/dist/daemon/reconcilers/live-obligation.js +3 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/commands/sys/prompt-review.d.ts +0 -1
- package/dist/commands/sys/sysprompt.d.ts +0 -1
- package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
- package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
- package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
- /package/dist/core/__tests__/{cron-broker-capacity.test.d.ts → cron-self-cancel-delivery.test.d.ts} +0 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/revive-capacity.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The broker cap is a real admission gate, and `reviveNode` is where every
|
|
4
|
+
// launcher meets it. A caller declares what it wants when the ledger is full —
|
|
5
|
+
// `freeze` (its reason to run survives waiting) or `refuse` (it needs an engine
|
|
6
|
+
// now) — and this file pins the whole disposition matrix: the refusal names the
|
|
7
|
+
// cap and the live count instead of failing silently, the freeze marks the row
|
|
8
|
+
// once per episode without touching its session identity, an already-live node
|
|
9
|
+
// is admitted at the cap because it consumes no new slot, and the launch that
|
|
10
|
+
// finally happens retires the mark.
|
|
11
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { createNode, getNode } from '../canvas/canvas.js';
|
|
17
|
+
import { closeDb } from '../canvas/db.js';
|
|
18
|
+
import { bindDaemonEventSource, resetEventSourceForTests } from '../events/source.js';
|
|
19
|
+
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
20
|
+
import { headlessBrokerHost } from '../runtime/host.js';
|
|
21
|
+
import { reviveNode } from '../runtime/revive.js';
|
|
22
|
+
import { bindTestFleet } from './helpers/fleet.js';
|
|
23
|
+
let home;
|
|
24
|
+
let tf;
|
|
25
|
+
let seq = 0;
|
|
26
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
27
|
+
/** The real host contract, minus the process: register at the launch seam, so
|
|
28
|
+
* the ledger consumes the reservation exactly as production does. */
|
|
29
|
+
function stubLaunch() {
|
|
30
|
+
headlessBrokerHost.launch = (nodeId) => {
|
|
31
|
+
const handle = { pid: 500_000 + seq++, exited: new Promise(() => { }) };
|
|
32
|
+
tf.fleet.register(nodeId, handle);
|
|
33
|
+
return handle;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function node(id, over = {}) {
|
|
37
|
+
createNode({
|
|
38
|
+
node_id: id,
|
|
39
|
+
name: id,
|
|
40
|
+
created: new Date().toISOString(),
|
|
41
|
+
cwd: home,
|
|
42
|
+
kind: 'developer',
|
|
43
|
+
mode: 'base',
|
|
44
|
+
lifecycle: 'terminal',
|
|
45
|
+
status: 'active',
|
|
46
|
+
host_kind: 'broker',
|
|
47
|
+
...over,
|
|
48
|
+
});
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
/** Fill the ledger to the cap with live brokers. */
|
|
52
|
+
function saturate() {
|
|
53
|
+
reviveNode(node(`filler-a-${seq}`), { resume: false, capacity: 'refuse' });
|
|
54
|
+
reviveNode(node(`filler-b-${seq}`), { resume: false, capacity: 'refuse' });
|
|
55
|
+
assert.equal(tf.fleet.size(), 2, 'precondition: the ledger is at its cap of 2');
|
|
56
|
+
}
|
|
57
|
+
before(() => {
|
|
58
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-revive-capacity-'));
|
|
59
|
+
process.env['CRTR_HOME'] = home;
|
|
60
|
+
process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
|
|
61
|
+
resetEventSourceForTests();
|
|
62
|
+
bindDaemonEventSource();
|
|
63
|
+
});
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
tf = bindTestFleet();
|
|
66
|
+
stubLaunch();
|
|
67
|
+
});
|
|
68
|
+
after(() => {
|
|
69
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
70
|
+
tf.fleet.dispose();
|
|
71
|
+
resetFleetForTests();
|
|
72
|
+
resetEventSourceForTests();
|
|
73
|
+
closeDb();
|
|
74
|
+
delete process.env['CRTR_MAX_LIVE_BROKERS'];
|
|
75
|
+
delete process.env['CRTR_HOME'];
|
|
76
|
+
rmSync(home, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
test('a refusing caller gets an error naming the cap and the live count, never a silent no-op', () => {
|
|
79
|
+
saturate();
|
|
80
|
+
const turned = node('refused', { pi_session_id: 'sess-refused' });
|
|
81
|
+
assert.throws(() => reviveNode(turned, { resume: true, capacity: 'refuse' }), (err) => {
|
|
82
|
+
const details = err.details ?? {};
|
|
83
|
+
assert.equal(details['error'], 'broker_cap_reached', 'the refusal is a typed InputError, so it survives the /v1 error body');
|
|
84
|
+
assert.match(String(details['message']), /2 live brokers at the cap of 2/, 'the message names both the live count and the cap');
|
|
85
|
+
return true;
|
|
86
|
+
});
|
|
87
|
+
const meta = getNode(turned);
|
|
88
|
+
assert.equal(meta.frozen_at ?? null, null, 'a refusal is not a freeze — nothing waits for this row');
|
|
89
|
+
assert.equal(meta.pi_session_id, 'sess-refused', 'the refusal happens before any mutation of the session identity');
|
|
90
|
+
assert.equal(meta.cycles ?? 0, 0, 'a node turned away never spent a cycle');
|
|
91
|
+
});
|
|
92
|
+
test('a freezing caller gets a frozen row, marked once and left otherwise untouched', () => {
|
|
93
|
+
saturate();
|
|
94
|
+
const waiting = node('frozen', { pi_session_id: 'sess-frozen' });
|
|
95
|
+
const first = reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
96
|
+
assert.equal(first.outcome, 'frozen', 'the caller learns it got no engine');
|
|
97
|
+
const marked = getNode(waiting);
|
|
98
|
+
assert.ok(marked.frozen_at != null, 'the row carries the durable wait mark the thaw order reads');
|
|
99
|
+
assert.equal(marked.pi_pid ?? null, null, 'a frozen row holds no pid — every status surface reads it as frozen, not stale-active');
|
|
100
|
+
assert.equal(marked.cycles ?? 0, 0, 'a frozen node never spent a cycle');
|
|
101
|
+
const second = reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
102
|
+
assert.equal(second.outcome, 'frozen');
|
|
103
|
+
assert.equal(getNode(waiting).frozen_at, marked.frozen_at, 'freezing is once-per-episode: a second saturated tick keeps the original wait timestamp');
|
|
104
|
+
});
|
|
105
|
+
test('an already-live node is admitted at the cap — it consumes no new slot', () => {
|
|
106
|
+
const live = node('already-live');
|
|
107
|
+
reviveNode(live, { resume: false, capacity: 'refuse' });
|
|
108
|
+
reviveNode(node('already-live-filler'), { resume: false, capacity: 'refuse' });
|
|
109
|
+
assert.equal(tf.fleet.size(), 2, 'precondition: this node holds one of the two slots, and the ledger is full');
|
|
110
|
+
// Both dispositions agree: liveness is answered before capacity ever is.
|
|
111
|
+
assert.equal(reviveNode(live, { resume: true, capacity: 'refuse' }).outcome, 'already-live');
|
|
112
|
+
assert.equal(reviveNode(live, { resume: true, capacity: 'freeze' }).outcome, 'already-live');
|
|
113
|
+
assert.equal(getNode(live).frozen_at ?? null, null, 'a live node is never marked as waiting for a slot');
|
|
114
|
+
});
|
|
115
|
+
test('a freed slot lets the frozen row launch, and the launch retires the mark', async () => {
|
|
116
|
+
const filler = node('filler-exits');
|
|
117
|
+
reviveNode(filler, { resume: false, capacity: 'refuse' });
|
|
118
|
+
reviveNode(node('filler-stays'), { resume: false, capacity: 'refuse' });
|
|
119
|
+
const waiting = node('thaws', { pi_session_id: 'sess-thaws' });
|
|
120
|
+
assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
|
|
121
|
+
// A real exit frees the slot; its policy job runs on the lane like production.
|
|
122
|
+
tf.fleet.deliverExit(filler, { code: 0, signal: null });
|
|
123
|
+
await tf.drain();
|
|
124
|
+
assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'launched');
|
|
125
|
+
assert.equal(getNode(waiting).frozen_at ?? null, null, 'a launch is the definition of not-frozen');
|
|
126
|
+
assert.equal(tf.fleet.has(waiting), true, 'the thawed node owns the freed slot');
|
|
127
|
+
});
|
|
@@ -106,7 +106,7 @@ after(() => {
|
|
|
106
106
|
test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', () => {
|
|
107
107
|
parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
108
108
|
deliverMail('parked');
|
|
109
|
-
const result = reviveNode('parked', { resume: true });
|
|
109
|
+
const result = reviveNode('parked', { resume: true, capacity: 'freeze' });
|
|
110
110
|
assert.equal(result.resumed, false, 'the concluded transcript is not replayed');
|
|
111
111
|
assert.deepEqual(placements(), [{ nodeId: 'parked', resuming: false }]);
|
|
112
112
|
assert.equal(kickedOff(launches[0]), true, 'the fresh window is handed its bearings');
|
|
@@ -118,12 +118,12 @@ test('a parked node with mail waiting re-enters fresh even when the caller asked
|
|
|
118
118
|
test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', () => {
|
|
119
119
|
parkResident('goal-only', { goal: 'Watch the deploy lane.' });
|
|
120
120
|
deliverMail('goal-only');
|
|
121
|
-
assert.equal(reviveNode('goal-only', { resume: true }).resumed, false);
|
|
121
|
+
assert.equal(reviveNode('goal-only', { resume: true, capacity: 'freeze' }).resumed, false);
|
|
122
122
|
assert.equal(kickedOff(launches[0]), true);
|
|
123
123
|
});
|
|
124
124
|
test('an input-less revival of a parked node quietly resumes and injects no kickoff', () => {
|
|
125
125
|
parkResident('quiet', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
126
|
-
const result = reviveNode('quiet', { resume: true });
|
|
126
|
+
const result = reviveNode('quiet', { resume: true, capacity: 'freeze' });
|
|
127
127
|
assert.equal(result.resumed, true, 'nothing arrived, so there is nothing a fresh window would answer');
|
|
128
128
|
assert.deepEqual(placements(), [{ nodeId: 'quiet', resuming: true }]);
|
|
129
129
|
assert.equal(kickedOff(launches[0]), false, 'a quiet resume generates nothing');
|
|
@@ -135,20 +135,20 @@ test('an input-less revival of a parked node quietly resumes and injects no kick
|
|
|
135
135
|
test('deferred mail alone is not input, so the revival still resumes quietly', () => {
|
|
136
136
|
parkResident('deferred', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
137
137
|
deliverMail('deferred', 'deferred');
|
|
138
|
-
assert.equal(reviveNode('deferred', { resume: true }).resumed, true);
|
|
138
|
+
assert.equal(reviveNode('deferred', { resume: true, capacity: 'freeze' }).resumed, true);
|
|
139
139
|
assert.equal(kickedOff(launches[0]), false);
|
|
140
140
|
assert.equal(readCursor('deferred'), undefined, 'the deferred entry rides the next natural cycle, unconsumed');
|
|
141
141
|
});
|
|
142
142
|
test('mail already covered by the cursor is not input, so the revival still resumes quietly', () => {
|
|
143
143
|
parkResident('stale', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
144
144
|
writeCursor('stale', deliverMail('stale'));
|
|
145
|
-
assert.equal(reviveNode('stale', { resume: true }).resumed, true, 'only the UNSEEN suffix counts as input');
|
|
145
|
+
assert.equal(reviveNode('stale', { resume: true, capacity: 'freeze' }).resumed, true, 'only the UNSEEN suffix counts as input');
|
|
146
146
|
assert.equal(kickedOff(launches[0]), false);
|
|
147
147
|
});
|
|
148
148
|
test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', () => {
|
|
149
149
|
parkResident('bare');
|
|
150
150
|
deliverMail('bare');
|
|
151
|
-
const result = reviveNode('bare', { resume: true });
|
|
151
|
+
const result = reviveNode('bare', { resume: true, capacity: 'freeze' });
|
|
152
152
|
assert.equal(result.resumed, true, 'the degraded park has nothing on disk to ground a fresh window');
|
|
153
153
|
assert.deepEqual(placements(), [{ nodeId: 'bare', resuming: true }]);
|
|
154
154
|
});
|
|
@@ -156,5 +156,5 @@ test('an ordinary done node with a roadmap still resumes — only the park marke
|
|
|
156
156
|
parkResident('finished', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
157
157
|
transition('finished', 'revive');
|
|
158
158
|
transition('finished', 'finish');
|
|
159
|
-
assert.equal(reviveNode('finished', { resume: true }).resumed, true);
|
|
159
|
+
assert.equal(reviveNode('finished', { resume: true, capacity: 'freeze' }).resumed, true);
|
|
160
160
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Run with: npm run build && node --test dist/core/__tests__/seam/broker-cap-freeze.test.js
|
|
2
|
+
//
|
|
3
|
+
// The cap as the host actually meets it: real broker processes, the real
|
|
4
|
+
// ledger, and the real supervision tick. With CRTR_MAX_LIVE_BROKERS=2 a third
|
|
5
|
+
// birth gets no engine at all — the row exists, carries the durable wait mark,
|
|
6
|
+
// and holds no pid — and it survives repeated saturated ticks rather than being
|
|
7
|
+
// written off as a boot failure. When a live node finishes and gives its slot
|
|
8
|
+
// back, the next tick spends that slot on the waiting row and retires the mark.
|
|
9
|
+
//
|
|
10
|
+
// This is the whole point of P3, and none of it is observable without real
|
|
11
|
+
// processes: the freeze is decided at the launch seam and the thaw is decided
|
|
12
|
+
// by the daemon pass, so it belongs in the compiled seam tier.
|
|
13
|
+
import { after, before, test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { closeDb } from '../../canvas/db.js';
|
|
16
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
17
|
+
import { closeNode } from '../../runtime/close.js';
|
|
18
|
+
import { spawnChild } from '../../runtime/spawn.js';
|
|
19
|
+
import { createHarness } from '../helpers/harness.js';
|
|
20
|
+
let h;
|
|
21
|
+
let root;
|
|
22
|
+
before(async () => {
|
|
23
|
+
// Read per launch by brokerThresholdsForDaemon(), so setting it before the
|
|
24
|
+
// harness boots covers every launch this file drives.
|
|
25
|
+
process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
|
|
26
|
+
h = await createHarness({ headless: true, sessionPrefix: 'crtr-broker-cap' });
|
|
27
|
+
root = h.spawnRoot('broker cap suite root');
|
|
28
|
+
});
|
|
29
|
+
after(async () => {
|
|
30
|
+
if (h !== undefined)
|
|
31
|
+
await h.dispose();
|
|
32
|
+
delete process.env['CRTR_MAX_LIVE_BROKERS'];
|
|
33
|
+
});
|
|
34
|
+
test('a birth at the cap freezes instead of booting, survives saturated ticks, and thaws when a slot frees', { timeout: 120_000 }, async () => {
|
|
35
|
+
const first = await h.spawnHeadlessChild(root, 'first live child');
|
|
36
|
+
const second = await h.spawnHeadlessChild(root, 'second live child');
|
|
37
|
+
assert.equal(h.fleet.size(), 2, 'precondition: both slots are held by real brokers');
|
|
38
|
+
// The third birth meets a full ledger. spawnChild returns rather than
|
|
39
|
+
// crashing the row: a node that only has to wait is not a failed launch.
|
|
40
|
+
const born = await spawnChild({
|
|
41
|
+
kind: 'general',
|
|
42
|
+
cwd: h.node(first).cwd,
|
|
43
|
+
prompt: 'third child, born at the cap',
|
|
44
|
+
parent: root,
|
|
45
|
+
});
|
|
46
|
+
closeDb();
|
|
47
|
+
const third = born.node.node_id;
|
|
48
|
+
assert.equal(born.frozen, true, 'the birth reports that it got no engine');
|
|
49
|
+
const frozenRow = h.node(third);
|
|
50
|
+
assert.ok(frozenRow.frozen_at != null, 'the row carries the durable wait mark');
|
|
51
|
+
assert.equal(frozenRow.pi_pid ?? null, null, 'no pid was ever recorded — status surfaces read frozen, not stale-active');
|
|
52
|
+
assert.equal(h.fleet.has(third), false, 'no broker process exists for the frozen row');
|
|
53
|
+
assert.equal(h.bootCount(third), 0, 'nothing booted');
|
|
54
|
+
// Six saturated ticks: the cap is not a failure, so nothing is charged
|
|
55
|
+
// against the row and nothing terminalizes it.
|
|
56
|
+
for (let i = 0; i < 6; i++)
|
|
57
|
+
await h.tick();
|
|
58
|
+
closeDb();
|
|
59
|
+
const survived = h.node(third);
|
|
60
|
+
assert.equal(survived.status, 'active', 'six saturated ticks leave the row alive and still owed an engine');
|
|
61
|
+
assert.equal(survived.respawn_failures ?? 0, 0, 'waiting for a slot is not a respawn failure');
|
|
62
|
+
assert.equal(survived.frozen_at, frozenRow.frozen_at, 'the original wait timestamp survives — the thaw order reads it');
|
|
63
|
+
assert.equal(h.fleet.size(), 2, 'the two live brokers were never disturbed');
|
|
64
|
+
// Finish one node: its broker exits and gives the slot back.
|
|
65
|
+
const firstPid = h.node(first).pi_pid;
|
|
66
|
+
closeNode(first, { rootEvent: 'finish' });
|
|
67
|
+
closeDb();
|
|
68
|
+
await h.awaitFleetExit(first);
|
|
69
|
+
assert.equal(h.fleet.size(), 1, 'the finished node released its slot');
|
|
70
|
+
// The next tick spends the freed slot on the waiting row.
|
|
71
|
+
await h.tick();
|
|
72
|
+
closeDb();
|
|
73
|
+
await h.awaitBoot(third);
|
|
74
|
+
closeDb();
|
|
75
|
+
const thawed = h.node(third);
|
|
76
|
+
assert.equal(thawed.frozen_at ?? null, null, 'the launch retired the wait mark');
|
|
77
|
+
assert.ok(thawed.pi_pid != null && isPidAlive(thawed.pi_pid), 'the thawed node holds a live broker');
|
|
78
|
+
assert.equal(h.fleet.has(third), true, 'the freed slot is now the thawed node\u2019s');
|
|
79
|
+
assert.equal(h.fleet.has(second), true, 'the untouched live node kept its slot throughout');
|
|
80
|
+
assert.notEqual(thawed.pi_pid, firstPid, 'the thaw is a real new broker, not the finished node\u2019s pid lingering');
|
|
81
|
+
});
|
|
@@ -78,7 +78,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
|
|
|
78
78
|
},
|
|
79
79
|
},
|
|
80
80
|
});
|
|
81
|
-
reviveNode(manager, { resume: true });
|
|
81
|
+
reviveNode(manager, { resume: true, capacity: 'freeze' });
|
|
82
82
|
await h.awaitBoot(manager);
|
|
83
83
|
// A live managed child makes this unfocused terminal manager legitimately
|
|
84
84
|
// awaiting, so its real stophook takes the idle-release branch.
|
|
@@ -171,7 +171,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
171
171
|
},
|
|
172
172
|
});
|
|
173
173
|
subscribe(root, nodeId, true);
|
|
174
|
-
reviveNode(nodeId, { resume: true });
|
|
174
|
+
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
175
175
|
const boot = await h.awaitBoot(nodeId);
|
|
176
176
|
assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
|
|
177
177
|
const seenAt = 10_000;
|
|
@@ -223,7 +223,7 @@ test('a non-attending observer does not prevent unattended resident parking', {
|
|
|
223
223
|
intent: 'idle-release',
|
|
224
224
|
});
|
|
225
225
|
updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
226
|
-
reviveNode(nodeId, { resume: true });
|
|
226
|
+
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
227
227
|
const boot = await h.awaitBoot(nodeId);
|
|
228
228
|
await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
|
|
229
229
|
assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
|
|
@@ -268,7 +268,7 @@ test('a connected broker viewer keeps an obligation-free resident live', { timeo
|
|
|
268
268
|
env: {},
|
|
269
269
|
},
|
|
270
270
|
});
|
|
271
|
-
reviveNode(nodeId, { resume: true });
|
|
271
|
+
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
272
272
|
const boot = await h.awaitBoot(nodeId);
|
|
273
273
|
// awaitBoot returns on the boot proof, which is written BEFORE the broker's
|
|
274
274
|
// own `persistAttachState()` lays down the truthful zero state. Writing our
|
|
@@ -311,7 +311,7 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
|
|
|
311
311
|
try {
|
|
312
312
|
const boot = async (id) => {
|
|
313
313
|
updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
314
|
-
reviveNode(id, { resume: true });
|
|
314
|
+
reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
315
315
|
return (await h.awaitBoot(id)).pid;
|
|
316
316
|
};
|
|
317
317
|
const settled = h.fabricateBrokerNode({ id: 'terminal-no-work', parent: root, status: 'idle', intent: 'idle-release' });
|
|
@@ -378,7 +378,7 @@ test('a resident managed child parks without finalizing while a terminal child i
|
|
|
378
378
|
subscribe(terminalManager, terminal, true);
|
|
379
379
|
const boot = async (id) => {
|
|
380
380
|
updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
381
|
-
reviveNode(id, { resume: true });
|
|
381
|
+
reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
382
382
|
return (await h.awaitBoot(id)).pid;
|
|
383
383
|
};
|
|
384
384
|
const residentPid = await boot(resident);
|
|
@@ -439,7 +439,7 @@ test('an unattended resident awaiting a live child remains wakeable', { timeout:
|
|
|
439
439
|
env: {},
|
|
440
440
|
},
|
|
441
441
|
});
|
|
442
|
-
reviveNode(nodeId, { resume: true });
|
|
442
|
+
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
443
443
|
const boot = await h.awaitBoot(nodeId);
|
|
444
444
|
const seenAt = 20_000;
|
|
445
445
|
await h.tick(seenAt);
|
|
@@ -65,7 +65,7 @@ test('yield-message survives one pre-session_start retry and clears on session_s
|
|
|
65
65
|
// the retry still roots a fresh cycle carrying the note, never reopening the
|
|
66
66
|
// old session. Driving reviveNode directly is exactly what focus's revive path
|
|
67
67
|
// calls, minus a tmux pane this contract does not involve.
|
|
68
|
-
const focusRevive = reviveNode(id, { resume: true });
|
|
68
|
+
const focusRevive = reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
69
69
|
const focusPid = focusRevive.launch?.pid;
|
|
70
70
|
assert.ok(focusPid != null, 'the competing focus invocation launches the replacement broker');
|
|
71
71
|
assert.equal(focusRevive.resumed, false, 'refresh intent overrides this invocation\'s strict-resume request');
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Every hue (fg / bg color) is gated on `caps.color`; structural SGR (bold/dim/
|
|
12
12
|
// reverse) is allowed always. See detectColorCaps() for the gate, and the
|
|
13
13
|
// canvas-browse color spec for the rationale + palette.
|
|
14
|
-
import { STATUS_GLYPH, STATUS_COLOR, resolveNodeVisual, faultSummary } from '../status-glyph.js';
|
|
14
|
+
import { STATUS_GLYPH, STATUS_COLOR, isFrozenRow, resolveNodeVisual, faultSummary } from '../status-glyph.js';
|
|
15
15
|
import { TABS, matchIndices, promptText, previewSnippet } from './model.js';
|
|
16
16
|
// Span/color primitives live in core/tui/draw.ts (one copy, shared with the
|
|
17
17
|
// `crtr view` host). Re-export the color caps so browse's importers + tests keep
|
|
@@ -185,7 +185,11 @@ function spansWidth(spans) {
|
|
|
185
185
|
* node is genuinely mid-turn; otherwise the lifecycle word. The word is what makes
|
|
186
186
|
* state legible at a glance and survives NO_COLOR (the glyph is the second cue). */
|
|
187
187
|
function statusRail(r) {
|
|
188
|
-
const v = resolveNodeVisual(r.status, {
|
|
188
|
+
const v = resolveNodeVisual(r.status, {
|
|
189
|
+
streaming: r.streaming === true,
|
|
190
|
+
frozen: isFrozenRow(r),
|
|
191
|
+
hanging: r.hanging ?? null,
|
|
192
|
+
});
|
|
189
193
|
// Plain status keeps browse's own status WORD; overlays (hanging/⟳)
|
|
190
194
|
// carry their own word from resolveNodeVisual. A hanging kind label can exceed
|
|
191
195
|
// STATUS_W — acceptable: the rare, attention-demanding row gets a slightly
|
|
@@ -366,7 +370,11 @@ function previewPanel(r, width, caps, now, query) {
|
|
|
366
370
|
out.push('');
|
|
367
371
|
return out;
|
|
368
372
|
}
|
|
369
|
-
const v = resolveNodeVisual(r.status, {
|
|
373
|
+
const v = resolveNodeVisual(r.status, {
|
|
374
|
+
streaming: r.streaming === true,
|
|
375
|
+
frozen: isFrozenRow(r),
|
|
376
|
+
hanging: r.hanging ?? null,
|
|
377
|
+
});
|
|
370
378
|
const glyph = caps.color
|
|
371
379
|
? `${ESC}${v.color}m${v.glyph}${RESET}`
|
|
372
380
|
: v.glyph;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle } from './types.js';
|
|
1
|
+
import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle, TerminalReason } from './types.js';
|
|
2
2
|
/** Create a node: scaffold its dirs, persist identity to meta.json, and seed the
|
|
3
3
|
* row (identity + runtime from the incoming meta). Returns the hydrated view. */
|
|
4
4
|
export declare function createNode(meta: NodeMeta): NodeMeta;
|
|
@@ -35,6 +35,7 @@ export declare function setIntent(nodeId: string, intent: ExitIntent): void;
|
|
|
35
35
|
export declare function transitionStatusIntent(nodeId: string, fields: {
|
|
36
36
|
status?: NodeStatus;
|
|
37
37
|
intent?: ExitIntent;
|
|
38
|
+
terminal_reason?: TerminalReason | null;
|
|
38
39
|
}, opts?: {
|
|
39
40
|
fromStatuses?: readonly NodeStatus[];
|
|
40
41
|
requireNoFinalReport?: boolean;
|
|
@@ -74,7 +75,16 @@ export declare function recordPid(nodeId: string, pid: number): void;
|
|
|
74
75
|
* `recordPid` and must be cleared together, or a subsequent relaunch that
|
|
75
76
|
* reuses the pid namespace could compare its own fresh pid against a STALE
|
|
76
77
|
* identity left over from the pid this row held before. */
|
|
77
|
-
export declare function
|
|
78
|
+
export declare function clearRecordedPid(nodeId: string): void;
|
|
79
|
+
export declare const clearPid: typeof clearRecordedPid;
|
|
80
|
+
export declare function recordLaunch(nodeId: string, at: string): void;
|
|
81
|
+
export declare function recordSettledTurn(nodeId: string, at: string): void;
|
|
82
|
+
export declare function setFrozen(nodeId: string, at: string): void;
|
|
83
|
+
export declare function clearFrozen(nodeId: string): void;
|
|
84
|
+
export declare function recordRespawnAttempt(nodeId: string, attempt: {
|
|
85
|
+
failures: number;
|
|
86
|
+
notBefore: string | null;
|
|
87
|
+
}): void;
|
|
78
88
|
/** ONE-TIME completion of the boot-scoped-identity format migration, run once
|
|
79
89
|
* at daemon startup (the natural boot-reconciliation chokepoint, right after
|
|
80
90
|
* `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy
|
|
@@ -119,9 +129,11 @@ export declare function migrateLegacyPidIdentities(captureIdentity?: (pid: numbe
|
|
|
119
129
|
* Unlike `listNodes`, this includes unclaimed warm-pool spares: profile
|
|
120
130
|
* deletion owns every matching row, whether or not it is user-visible. */
|
|
121
131
|
export declare function listNodesByProfile(profileId: string): NodeMeta[];
|
|
122
|
-
/** All rows, optionally filtered by status
|
|
132
|
+
/** All rows, optionally filtered by status and by carrying a recorded pid (the
|
|
133
|
+
* supervision tick's terminal-row query). Unclaimed warm spares are excluded. */
|
|
123
134
|
export declare function listNodes(filter?: {
|
|
124
135
|
status?: NodeStatus | NodeStatus[];
|
|
136
|
+
withRecordedPid?: boolean;
|
|
125
137
|
}): NodeRow[];
|
|
126
138
|
/** Mark an already-spawned node as an unclaimed spare for `recipeKey`. Hides it
|
|
127
139
|
* from every listing until it is claimed. */
|
|
@@ -13,10 +13,9 @@ import { isPidAlive, recordedPidLiveness, capturePidIdentities, identitiesMatch,
|
|
|
13
13
|
import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, } from './paths.js';
|
|
14
14
|
// meta.json (durable identity — the source of truth for what PERSISTS)
|
|
15
15
|
//
|
|
16
|
-
// One authoritative store per fact: meta.json holds NodeIdentity only;
|
|
17
|
-
// runtime fields
|
|
18
|
-
//
|
|
19
|
-
// below. getNode() hydrates the two back into the historical NodeMeta view.
|
|
16
|
+
// One authoritative store per fact: meta.json holds NodeIdentity only; live
|
|
17
|
+
// runtime fields are authoritative in the WAL'd `nodes` row. getNode() hydrates
|
|
18
|
+
// the two back into the historical NodeMeta view.
|
|
20
19
|
/** The identity keys meta.json persists. Listed explicitly so no runtime field
|
|
21
20
|
* can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
|
|
22
21
|
const IDENTITY_KEYS = [
|
|
@@ -129,6 +128,12 @@ function rowFrom(r) {
|
|
|
129
128
|
created: r['created'],
|
|
130
129
|
intent: r['intent'] ?? null,
|
|
131
130
|
waiting_for: r['waiting_for'] ?? null,
|
|
131
|
+
frozen_at: r['frozen_at'] ?? null,
|
|
132
|
+
respawn_failures: r['respawn_failures'] ?? 0,
|
|
133
|
+
respawn_not_before: r['respawn_not_before'] ?? null,
|
|
134
|
+
launched_at: r['launched_at'] ?? null,
|
|
135
|
+
last_settled_at: r['last_settled_at'] ?? null,
|
|
136
|
+
terminal_reason: r['terminal_reason'] ?? null,
|
|
132
137
|
final_report: r['final_report'] ?? null,
|
|
133
138
|
finalized_at: r['finalized_at'] ?? null,
|
|
134
139
|
pi_pid: r['pi_pid'] ?? null,
|
|
@@ -142,7 +147,7 @@ function rowFrom(r) {
|
|
|
142
147
|
* no row exists yet (the hydration then falls back to whatever the meta held). */
|
|
143
148
|
function runtimeFromRow(nodeId) {
|
|
144
149
|
const r = openDb()
|
|
145
|
-
.prepare('SELECT status, intent, waiting_for, final_report, finalized_at, pi_pid, pi_pid_identity, "window", tmux_session, pane FROM nodes WHERE node_id = ?')
|
|
150
|
+
.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 = ?')
|
|
146
151
|
.get(nodeId);
|
|
147
152
|
if (r === undefined)
|
|
148
153
|
return null;
|
|
@@ -150,6 +155,12 @@ function runtimeFromRow(nodeId) {
|
|
|
150
155
|
status: r['status'] ?? 'active',
|
|
151
156
|
intent: r['intent'] ?? null,
|
|
152
157
|
waiting_for: r['waiting_for'] ?? null,
|
|
158
|
+
frozen_at: r['frozen_at'] ?? null,
|
|
159
|
+
respawn_failures: r['respawn_failures'] ?? 0,
|
|
160
|
+
respawn_not_before: r['respawn_not_before'] ?? null,
|
|
161
|
+
launched_at: r['launched_at'] ?? null,
|
|
162
|
+
last_settled_at: r['last_settled_at'] ?? null,
|
|
163
|
+
terminal_reason: r['terminal_reason'] ?? null,
|
|
153
164
|
final_report: r['final_report'] ?? null,
|
|
154
165
|
finalized_at: r['finalized_at'] ?? null,
|
|
155
166
|
pi_pid: r['pi_pid'] ?? null,
|
|
@@ -273,6 +284,10 @@ export function transitionStatusIntent(nodeId, fields, opts = {}) {
|
|
|
273
284
|
assignments.push('intent = ?');
|
|
274
285
|
values.push(fields.intent ?? null);
|
|
275
286
|
}
|
|
287
|
+
if (Object.prototype.hasOwnProperty.call(fields, 'terminal_reason')) {
|
|
288
|
+
assignments.push('terminal_reason = ?');
|
|
289
|
+
values.push(fields.terminal_reason ?? null);
|
|
290
|
+
}
|
|
276
291
|
const predicates = ['node_id = ?'];
|
|
277
292
|
values.push(nodeId);
|
|
278
293
|
if (opts.fromStatuses !== undefined) {
|
|
@@ -334,9 +349,29 @@ export function recordPid(nodeId, pid) {
|
|
|
334
349
|
* `recordPid` and must be cleared together, or a subsequent relaunch that
|
|
335
350
|
* reuses the pid namespace could compare its own fresh pid against a STALE
|
|
336
351
|
* identity left over from the pid this row held before. */
|
|
337
|
-
export function
|
|
352
|
+
export function clearRecordedPid(nodeId) {
|
|
338
353
|
openDb().prepare('UPDATE nodes SET pi_pid = NULL, pi_pid_identity = NULL WHERE node_id = ?').run(nodeId);
|
|
339
354
|
}
|
|
355
|
+
export const clearPid = clearRecordedPid;
|
|
356
|
+
export function recordLaunch(nodeId, at) {
|
|
357
|
+
openDb()
|
|
358
|
+
.prepare('UPDATE nodes SET launched_at = ?, frozen_at = NULL, respawn_not_before = NULL WHERE node_id = ?')
|
|
359
|
+
.run(at, nodeId);
|
|
360
|
+
}
|
|
361
|
+
export function recordSettledTurn(nodeId, at) {
|
|
362
|
+
openDb().prepare('UPDATE nodes SET last_settled_at = ? WHERE node_id = ?').run(at, nodeId);
|
|
363
|
+
}
|
|
364
|
+
export function setFrozen(nodeId, at) {
|
|
365
|
+
openDb().prepare('UPDATE nodes SET frozen_at = ? WHERE node_id = ?').run(at, nodeId);
|
|
366
|
+
}
|
|
367
|
+
export function clearFrozen(nodeId) {
|
|
368
|
+
openDb().prepare('UPDATE nodes SET frozen_at = NULL WHERE node_id = ?').run(nodeId);
|
|
369
|
+
}
|
|
370
|
+
export function recordRespawnAttempt(nodeId, attempt) {
|
|
371
|
+
openDb()
|
|
372
|
+
.prepare('UPDATE nodes SET respawn_failures = ?, respawn_not_before = ? WHERE node_id = ?')
|
|
373
|
+
.run(attempt.failures, attempt.notBefore, nodeId);
|
|
374
|
+
}
|
|
340
375
|
/** ONE-TIME completion of the boot-scoped-identity format migration, run once
|
|
341
376
|
* at daemon startup (the natural boot-reconciliation chokepoint, right after
|
|
342
377
|
* `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy
|
|
@@ -421,20 +456,21 @@ export function listNodesByProfile(profileId) {
|
|
|
421
456
|
return meta;
|
|
422
457
|
});
|
|
423
458
|
}
|
|
424
|
-
/** All rows, optionally filtered by status
|
|
459
|
+
/** All rows, optionally filtered by status and by carrying a recorded pid (the
|
|
460
|
+
* supervision tick's terminal-row query). Unclaimed warm spares are excluded. */
|
|
425
461
|
export function listNodes(filter) {
|
|
426
|
-
const
|
|
427
|
-
|
|
462
|
+
const clauses = [NOT_A_SPARE];
|
|
463
|
+
const params = [];
|
|
428
464
|
if (filter?.status !== undefined) {
|
|
429
465
|
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
.prepare(`SELECT * FROM nodes WHERE status IN (${placeholders}) AND ${NOT_A_SPARE} ORDER BY created`)
|
|
433
|
-
.all(...statuses);
|
|
434
|
-
}
|
|
435
|
-
else {
|
|
436
|
-
rows = db.prepare(`SELECT * FROM nodes WHERE ${NOT_A_SPARE} ORDER BY created`).all();
|
|
466
|
+
clauses.push(`status IN (${statuses.map(() => '?').join(',')})`);
|
|
467
|
+
params.push(...statuses);
|
|
437
468
|
}
|
|
469
|
+
if (filter?.withRecordedPid === true)
|
|
470
|
+
clauses.push('pi_pid IS NOT NULL');
|
|
471
|
+
const rows = openDb()
|
|
472
|
+
.prepare(`SELECT * FROM nodes WHERE ${clauses.join(' AND ')} ORDER BY created`)
|
|
473
|
+
.all(...params);
|
|
438
474
|
return rows.map(rowFrom);
|
|
439
475
|
}
|
|
440
476
|
// Warm pool — spare booted roots awaiting a `prefer_warm` claim (see
|
|
@@ -1215,6 +1215,26 @@ function addNodeSessionFileIndex(db) {
|
|
|
1215
1215
|
}
|
|
1216
1216
|
db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_pi_session_file ON nodes(pi_session_file);');
|
|
1217
1217
|
}
|
|
1218
|
+
/** v35 — durable node lifecycle execution state and terminal cause. */
|
|
1219
|
+
function addNodeLifecycleColumns(db) {
|
|
1220
|
+
const columns = nodeColumns(db);
|
|
1221
|
+
addNodeColumnIfMissing(db, columns, 'frozen_at', 'ALTER TABLE nodes ADD COLUMN frozen_at TEXT;');
|
|
1222
|
+
addNodeColumnIfMissing(db, columns, 'respawn_failures', 'ALTER TABLE nodes ADD COLUMN respawn_failures INTEGER NOT NULL DEFAULT 0;');
|
|
1223
|
+
addNodeColumnIfMissing(db, columns, 'respawn_not_before', 'ALTER TABLE nodes ADD COLUMN respawn_not_before TEXT;');
|
|
1224
|
+
addNodeColumnIfMissing(db, columns, 'launched_at', 'ALTER TABLE nodes ADD COLUMN launched_at TEXT;');
|
|
1225
|
+
addNodeColumnIfMissing(db, columns, 'last_settled_at', 'ALTER TABLE nodes ADD COLUMN last_settled_at TEXT;');
|
|
1226
|
+
addNodeColumnIfMissing(db, columns, 'terminal_reason', 'ALTER TABLE nodes ADD COLUMN terminal_reason TEXT;');
|
|
1227
|
+
db.exec(`
|
|
1228
|
+
UPDATE nodes SET terminal_reason = CASE
|
|
1229
|
+
WHEN status = 'done' AND final_report IS NOT NULL THEN 'finalized'
|
|
1230
|
+
WHEN status = 'done' AND intent = 'parked' THEN 'parked'
|
|
1231
|
+
WHEN status = 'done' THEN 'finished'
|
|
1232
|
+
WHEN status = 'canceled' THEN 'stranded'
|
|
1233
|
+
WHEN status = 'dead' THEN 'crashed'
|
|
1234
|
+
END
|
|
1235
|
+
WHERE status IN ('done','canceled','dead') AND terminal_reason IS NULL;
|
|
1236
|
+
`);
|
|
1237
|
+
}
|
|
1218
1238
|
/** The journaled lane of the state-migration registry, adapted to this chain's
|
|
1219
1239
|
* step shape. Registry order is preserved, so these versions stay stable as
|
|
1220
1240
|
* long as the registry is append-only (same contract as the local list). */
|
|
@@ -1262,6 +1282,7 @@ export const MIGRATIONS = [
|
|
|
1262
1282
|
/* v32 */ addCronHeldColumns,
|
|
1263
1283
|
/* v33 */ addHumanActionDeliveries,
|
|
1264
1284
|
/* v34 */ addNodeSessionFileIndex,
|
|
1285
|
+
/* v35 */ addNodeLifecycleColumns,
|
|
1265
1286
|
...JOURNALED_STATE_MIGRATIONS,
|
|
1266
1287
|
];
|
|
1267
1288
|
/** Migration indexes that manage their OWN transaction and therefore must not
|
|
@@ -14,7 +14,7 @@ import { isPidAlive } from './pid.js';
|
|
|
14
14
|
import { isBusy } from '../runtime/busy.js';
|
|
15
15
|
import { readFault } from '../runtime/fault.js';
|
|
16
16
|
import { activeFaultForDisplay } from './render-source.js';
|
|
17
|
-
import { resolveNodeVisual } from './status-glyph.js';
|
|
17
|
+
import { isFrozenRow, resolveNodeVisual } from './status-glyph.js';
|
|
18
18
|
// Tuning constants
|
|
19
19
|
/** pi's InteractiveMode.MAX_WIDGET_LINES — the hard cap on lines in a string
|
|
20
20
|
* array widget; anything beyond it pi truncates with its own "... (widget
|
|
@@ -62,6 +62,8 @@ export function nodeGlyph(node, remote = false) {
|
|
|
62
62
|
return '?';
|
|
63
63
|
const v = resolveNodeVisual(node.status, {
|
|
64
64
|
streaming: remote ? false : streamingFor(node),
|
|
65
|
+
// Durable row state, so it holds for a remote node too.
|
|
66
|
+
frozen: isFrozenRow(node),
|
|
65
67
|
hanging: remote ? null : hangingFor(node),
|
|
66
68
|
});
|
|
67
69
|
return `${ESC}${v.color}m${v.glyph}${RESET}`;
|
|
@@ -161,6 +161,8 @@ export class RemoteCanvasSource {
|
|
|
161
161
|
created: n.created,
|
|
162
162
|
intent: n.intent ?? null,
|
|
163
163
|
waiting_for: n.waiting_for ?? null,
|
|
164
|
+
frozen_at: n.frozen_at ?? null,
|
|
165
|
+
terminal_reason: n.terminal_reason ?? null,
|
|
164
166
|
pi_pid: n.pi_pid ?? null,
|
|
165
167
|
pi_pid_identity: n.pi_pid_identity ?? null,
|
|
166
168
|
window: n.window ?? null,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CanvasSource } from './source.js';
|
|
2
2
|
import { type Fault } from '../runtime/fault.js';
|
|
3
|
-
import type { NodeStatus, Lifecycle, NodeMeta, NodeRow } from './types.js';
|
|
3
|
+
import type { NodeStatus, Lifecycle, NodeMeta, NodeRow, TerminalReason } from './types.js';
|
|
4
4
|
export declare function activeFaultForDisplay<T extends {
|
|
5
5
|
status: NodeStatus;
|
|
6
6
|
pi_pid?: number | null;
|
|
@@ -24,6 +24,8 @@ export interface DashboardRow {
|
|
|
24
24
|
/** Generated first-task description. Searchable below user-authored prompts. */
|
|
25
25
|
description?: string;
|
|
26
26
|
status: NodeStatus;
|
|
27
|
+
frozen_at?: string | null;
|
|
28
|
+
terminal_reason?: TerminalReason | null;
|
|
27
29
|
kind: string;
|
|
28
30
|
mode: string;
|
|
29
31
|
ctx_tokens: number;
|