@north-light/crouter 0.3.263 → 0.3.270
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 +5 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/broker.d.ts +11 -2
- package/dist/api/dto/broker.js +2 -2
- package/dist/api/dto/canvas.d.ts +53 -2
- package/dist/api/dto/nodes.d.ts +10 -2
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
- package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
- package/dist/clients/attach/session/whip-achievements.js +145 -0
- package/dist/clients/attach/slash/dispatch.js +5 -3
- package/dist/clients/attach/viewer.js +607 -606
- package/dist/commands/canvas-history/grep.js +3 -22
- package/dist/commands/canvas-history/read.js +5 -3
- package/dist/commands/canvas-history/search.js +3 -22
- package/dist/commands/canvas-history/shared.d.ts +8 -0
- package/dist/commands/canvas-history/shared.js +64 -1
- package/dist/commands/canvas-history/stats.d.ts +1 -0
- package/dist/commands/canvas-history/stats.js +68 -0
- package/dist/commands/canvas-history.js +4 -3
- package/dist/commands/node/create.js +2 -2
- package/dist/commands/pkg/plugin-inspect.js +2 -2
- package/dist/commands/pkg/plugin-manage.js +3 -3
- package/dist/commands/sys/config.js +19 -2
- package/dist/commands/sys/doctor.js +4 -3
- package/dist/commands/sys/panels/models-panel.js +3 -1
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
- package/dist/core/__tests__/integration/revive.test.js +19 -17
- package/dist/core/__tests__/model-pin-durability.test.js +25 -1
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/relaunch-root.test.js +17 -0
- package/dist/core/__tests__/revive-capacity.test.js +20 -20
- package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
- package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
- package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
- package/dist/core/broker-client/transport-relay.js +5 -1
- package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +13 -10
- package/dist/core/canvas/browse/model.js +16 -1
- package/dist/core/canvas/browse/render.d.ts +23 -3
- package/dist/core/canvas/browse/render.js +429 -64
- package/dist/core/canvas/canvas.js +12 -8
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/canvas/history.d.ts +36 -1
- package/dist/core/canvas/history.js +328 -7
- package/dist/core/canvas/labels.d.ts +2 -2
- package/dist/core/canvas/migrations.js +32 -0
- package/dist/core/canvas/node-recap.d.ts +23 -0
- package/dist/core/canvas/node-recap.js +67 -0
- package/dist/core/canvas/render-source.d.ts +33 -1
- package/dist/core/canvas/render-source.js +80 -22
- package/dist/core/canvas/types.d.ts +9 -0
- package/dist/core/command-hooks/discovery.d.ts +27 -1
- package/dist/core/command-hooks/discovery.js +52 -0
- package/dist/core/command-hooks/index.d.ts +4 -3
- package/dist/core/command-hooks/index.js +2 -1
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
- package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
- package/dist/core/command-hooks/report.d.ts +11 -2
- package/dist/core/command-hooks/report.js +10 -1
- package/dist/core/command-hooks/schema.d.ts +16 -1
- package/dist/core/command-hooks/schema.js +128 -40
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
- package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
- package/dist/core/config.js +13 -5
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/model-routes.js +1 -1
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/auth-reload.js +4 -1
- package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
- package/dist/core/runtime/broker/engine-drive.js +29 -1
- package/dist/core/runtime/broker/fault-retry.js +7 -2
- package/dist/core/runtime/broker/frame-client.js +3 -0
- package/dist/core/runtime/broker/frame-dispatch.js +7 -0
- package/dist/core/runtime/broker/inbox.d.ts +12 -2
- package/dist/core/runtime/broker/inbox.js +68 -38
- package/dist/core/runtime/broker/rebind.js +7 -0
- package/dist/core/runtime/broker-protocol.d.ts +16 -1
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +27 -3
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/interactive-deliver.d.ts +8 -2
- package/dist/core/runtime/interactive-deliver.js +12 -3
- package/dist/core/runtime/launch-target.js +1 -1
- package/dist/core/runtime/launch.d.ts +12 -6
- package/dist/core/runtime/launch.js +19 -17
- package/dist/core/runtime/model-swap.d.ts +5 -4
- package/dist/core/runtime/model-swap.js +7 -5
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +18 -9
- package/dist/core/runtime/promote.js +2 -1
- package/dist/core/runtime/reset.js +4 -2
- package/dist/core/runtime/revive-all.d.ts +1 -1
- package/dist/core/runtime/revive-all.js +2 -2
- package/dist/core/runtime/revive.d.ts +2 -2
- package/dist/core/runtime/revive.js +71 -13
- package/dist/core/runtime/session-visibility.d.ts +10 -14
- package/dist/core/runtime/session-visibility.js +43 -30
- package/dist/core/runtime/spawn.d.ts +3 -3
- package/dist/core/runtime/spawn.js +20 -3
- package/dist/core/runtime/stamp/channel.d.ts +22 -0
- package/dist/core/runtime/stamp/channel.js +83 -0
- package/dist/core/runtime/stamp/protocol.d.ts +90 -0
- package/dist/core/runtime/stamp/protocol.js +113 -0
- package/dist/core/runtime/tmux-driver.d.ts +11 -1
- package/dist/core/runtime/tmux-driver.js +27 -11
- package/dist/core/runtime/turn-visibility.d.ts +10 -0
- package/dist/core/runtime/turn-visibility.js +31 -0
- package/dist/core/self-update.js +2 -5
- package/dist/core/tui/draw.d.ts +1 -0
- package/dist/core/tui/draw.js +5 -1
- package/dist/core/tui/markdown.d.ts +17 -0
- package/dist/core/tui/markdown.js +407 -0
- package/dist/core/user-settings.d.ts +4 -0
- package/dist/core/user-settings.js +1 -0
- package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/bridge.d.ts +3 -2
- package/dist/daemon/api/bridge.js +34 -9
- package/dist/daemon/api/handlers/attach.js +4 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/canvas.js +62 -16
- package/dist/daemon/api/handlers/messages.js +14 -3
- package/dist/daemon/api/handlers/nodes.js +17 -7
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/cron/sinks.js +3 -3
- package/dist/daemon/fleet.js +1 -1
- package/dist/daemon/messaging/node-message.js +16 -2
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/broker-supervision.js +26 -9
- package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
- package/dist/daemon/reconcilers/live-obligation.js +17 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
- package/dist/hook-authoring.d.ts +31 -1
- package/dist/hook-authoring.js +42 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
- package/dist/pi-extensions/canvas-recap.js +4 -0
- package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
- package/dist/pi-extensions/canvas-stamp.js +77 -0
- package/dist/pi-extensions/canvas-stophook.js +3 -0
- package/dist/shared/__tests__/env-boundary.test.js +4 -2
- package/dist/shared/crtr-version.d.ts +2 -0
- package/dist/shared/crtr-version.js +18 -0
- package/dist/shared/env.d.ts +10 -0
- package/dist/shared/env.js +19 -4
- package/dist/shared/generated-context.d.ts +1 -2
- package/dist/shared/generated-context.js +3 -4
- package/dist/types.d.ts +10 -0
- package/dist/types.js +4 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import { contextDir, jobDir, nodeDir, reportsDir } from '../../canvas/paths.js';
|
|
6
|
+
import { validateHookManifest } from '../../command-hooks/schema.js';
|
|
7
|
+
import { headlessBrokerHost } from '../../runtime/host.js';
|
|
8
|
+
import { reviveNode } from '../../runtime/revive.js';
|
|
9
|
+
import { createHarness } from '../helpers/harness.js';
|
|
10
|
+
function installLifecyclePlugin(harness, name, op) {
|
|
11
|
+
const ops = typeof op === 'string' ? [op] : op;
|
|
12
|
+
const root = join(harness.agentHome, '.crouter', 'plugins', name);
|
|
13
|
+
mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
|
|
14
|
+
writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({
|
|
15
|
+
name,
|
|
16
|
+
version: '0.1.0',
|
|
17
|
+
description: 'lifecycle hook fixture',
|
|
18
|
+
hooks: 'hooks.json',
|
|
19
|
+
hookExecutable: 'bin/hook.js',
|
|
20
|
+
}));
|
|
21
|
+
writeFileSync(join(root, 'hooks.json'), JSON.stringify({
|
|
22
|
+
schemaVersion: 2,
|
|
23
|
+
lifecycle: ops.map((entry) => ({ event: 'node:start', phase: 'on', op: entry, description: `${entry} fixture`, effects: ['writes fixture state'] })),
|
|
24
|
+
}));
|
|
25
|
+
const executable = join(root, 'bin', 'hook.js');
|
|
26
|
+
mkdirSync(join(root, 'bin'), { recursive: true });
|
|
27
|
+
writeFileSync(executable, `#!/usr/bin/env node
|
|
28
|
+
const { existsSync, writeFileSync } = require('node:fs');
|
|
29
|
+
const { join } = require('node:path');
|
|
30
|
+
let input = '';
|
|
31
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
32
|
+
process.stdin.on('end', () => {
|
|
33
|
+
const request = JSON.parse(input);
|
|
34
|
+
writeFileSync(join(request.node.contextDir, 'lifecycle-order'), request.op + '\\n', { flag: 'a' });
|
|
35
|
+
if (request.op === 'record') writeFileSync(join(request.node.contextDir, 'lifecycle-request.json'), JSON.stringify(request));
|
|
36
|
+
if (request.op === 'hang') {
|
|
37
|
+
writeFileSync(join(request.node.contextDir, 'lifecycle-hang-started'), 'started');
|
|
38
|
+
setInterval(() => {}, 1000);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (request.op === 'bad') {
|
|
42
|
+
process.stdout.write('not a JSON envelope');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (request.op === 'nonzero') {
|
|
46
|
+
writeFileSync(join(request.node.contextDir, 'lifecycle-nonzero-started'), 'started');
|
|
47
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true }));
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true }));
|
|
52
|
+
});
|
|
53
|
+
`);
|
|
54
|
+
chmodSync(executable, 0o755);
|
|
55
|
+
}
|
|
56
|
+
function installV1CommandHook(harness) {
|
|
57
|
+
const root = join(harness.agentHome, '.crouter', 'plugins', 'z-v1-command');
|
|
58
|
+
mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
|
|
59
|
+
writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({
|
|
60
|
+
name: 'z-v1-command', version: '0.1.0', description: 'v1 command hook fixture', hooks: 'hooks.json', hookExecutable: 'bin/hook.js',
|
|
61
|
+
}));
|
|
62
|
+
writeFileSync(join(root, 'hooks.json'), JSON.stringify({
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
hooks: [{ target: 'sys version', phase: 'before', op: 'v1_check', description: 'v1 command hook', effects: ['writes fixture state'] }],
|
|
65
|
+
}));
|
|
66
|
+
const executable = join(root, 'bin', 'hook.js');
|
|
67
|
+
mkdirSync(join(root, 'bin'), { recursive: true });
|
|
68
|
+
writeFileSync(executable, `#!/usr/bin/env node
|
|
69
|
+
const { writeFileSync } = require('node:fs');
|
|
70
|
+
const { join } = require('node:path');
|
|
71
|
+
let input = '';
|
|
72
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
73
|
+
process.stdin.on('end', () => {
|
|
74
|
+
JSON.parse(input);
|
|
75
|
+
writeFileSync(join(__dirname, '..', 'v1-ran'), 'ran');
|
|
76
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true }));
|
|
77
|
+
});
|
|
78
|
+
`);
|
|
79
|
+
chmodSync(executable, 0o755);
|
|
80
|
+
return join(root, 'v1-ran');
|
|
81
|
+
}
|
|
82
|
+
test('node:start hooks receive resolved paths before broker launch; invalid and hanging hooks do not block launch', async () => {
|
|
83
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-lifecycle-hooks' });
|
|
84
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
85
|
+
try {
|
|
86
|
+
installLifecyclePlugin(harness, 'aa-lifecycle-order', ['first', 'second']);
|
|
87
|
+
installLifecyclePlugin(harness, 'a-lifecycle-record', 'record');
|
|
88
|
+
installLifecyclePlugin(harness, 'b-lifecycle-bad', 'bad');
|
|
89
|
+
installLifecyclePlugin(harness, 'c-lifecycle-nonzero', 'nonzero');
|
|
90
|
+
installLifecyclePlugin(harness, 'd-lifecycle-hang', 'hang');
|
|
91
|
+
const v1Marker = installV1CommandHook(harness);
|
|
92
|
+
const show = harness.cli(null, ['--json', 'pkg', 'plugin', 'show', 'a-lifecycle-record', '--scope', 'user']);
|
|
93
|
+
assert.equal(show.code, 0, show.stderr);
|
|
94
|
+
assert.deepEqual(show.json.hooks.lifecycle, [{
|
|
95
|
+
event: 'node:start', phase: 'on', op: 'record', description: 'record fixture', effects: ['writes fixture state'],
|
|
96
|
+
}]);
|
|
97
|
+
const doctor = harness.cli(null, ['--json', 'sys', 'doctor', '--scope', 'user']);
|
|
98
|
+
assert.equal(doctor.code, 0, doctor.stderr);
|
|
99
|
+
const hookCheck = doctor.json.checks.find((check) => check.name === 'plugin:a-lifecycle-record:hooks');
|
|
100
|
+
assert.match(hookCheck?.message ?? '', /node:start \[on record; effects: writes fixture state\]/);
|
|
101
|
+
const nodeId = harness.fabricateBrokerNode({ id: 'lifecycle-hook-node', pi_pid: null, pi_session_id: null, pi_session_file: null });
|
|
102
|
+
const requestPath = join(contextDir(nodeId), 'lifecycle-request.json');
|
|
103
|
+
let requestExistedBeforeLaunch = false;
|
|
104
|
+
headlessBrokerHost.launch = ((id, ...args) => {
|
|
105
|
+
if (id === nodeId)
|
|
106
|
+
requestExistedBeforeLaunch = existsSync(requestPath);
|
|
107
|
+
return originalLaunch.call(headlessBrokerHost, id, ...args);
|
|
108
|
+
});
|
|
109
|
+
const startedAt = Date.now();
|
|
110
|
+
const starting = reviveNode(nodeId, { resume: false, capacity: 'freeze' });
|
|
111
|
+
const contender = await reviveNode(nodeId, { resume: false, capacity: 'freeze' });
|
|
112
|
+
const result = await starting;
|
|
113
|
+
const elapsedMs = Date.now() - startedAt;
|
|
114
|
+
await harness.awaitBoot(nodeId);
|
|
115
|
+
assert.equal(result.outcome, 'launched');
|
|
116
|
+
assert.equal(contender.outcome, 'already-live', 'a concurrent revive cannot cross the awaited hook gap');
|
|
117
|
+
assert.equal(requestExistedBeforeLaunch, true, 'the lifecycle hook finishes before broker launch');
|
|
118
|
+
assert.equal(existsSync(join(contextDir(nodeId), 'lifecycle-nonzero-started')), true, 'a non-zero executable ran without blocking launch');
|
|
119
|
+
assert.equal(existsSync(join(contextDir(nodeId), 'lifecycle-hang-started')), true, 'the hanging executable started and was subject to the lifecycle timeout');
|
|
120
|
+
assert.ok(elapsedMs >= 4_500 && elapsedMs < 8_000, `the 5s hanging hook was bounded before launch (${elapsedMs}ms)`);
|
|
121
|
+
assert.deepEqual(readFileSync(join(contextDir(nodeId), 'lifecycle-order'), 'utf8').trim().split('\n'), ['record', 'first', 'second', 'bad', 'nonzero', 'hang'], 'lifecycle hooks preserve plugin-name then declaration-index discovery order');
|
|
122
|
+
assert.equal(existsSync(v1Marker), false, 'a command-only v1 manifest adds no node-start subprocess');
|
|
123
|
+
const request = JSON.parse(readFileSync(requestPath, 'utf8'));
|
|
124
|
+
assert.equal(request.protocolVersion, 1);
|
|
125
|
+
assert.equal(request.op, 'record');
|
|
126
|
+
assert.equal(request.event, 'node:start');
|
|
127
|
+
assert.equal(request.phase, 'on');
|
|
128
|
+
assert.match(request.operationId, /^[0-9a-f]{32}$/);
|
|
129
|
+
assert.deepEqual(request.context, { cwd: harness.node(nodeId).cwd });
|
|
130
|
+
assert.deepEqual(request.node, {
|
|
131
|
+
id: nodeId,
|
|
132
|
+
name: nodeId,
|
|
133
|
+
kind: 'developer',
|
|
134
|
+
mode: 'base',
|
|
135
|
+
lifecycle: 'terminal',
|
|
136
|
+
cwd: harness.node(nodeId).cwd,
|
|
137
|
+
nodeDir: nodeDir(nodeId),
|
|
138
|
+
contextDir: contextDir(nodeId),
|
|
139
|
+
jobDir: jobDir(nodeId),
|
|
140
|
+
reportsDir: reportsDir(nodeId),
|
|
141
|
+
});
|
|
142
|
+
assert.deepEqual(request.runtime, { isBirth: true, canvasHome: harness.home, profile: null });
|
|
143
|
+
const v1Command = harness.cli(null, ['--json', 'sys', 'version']);
|
|
144
|
+
assert.equal(v1Command.code, 0, v1Command.stderr);
|
|
145
|
+
assert.equal(existsSync(v1Marker), true, 'a v1 command hook still executes around its declared command');
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
149
|
+
await harness.dispose();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only declaration', () => {
|
|
153
|
+
const v1 = validateHookManifest({
|
|
154
|
+
schemaVersion: 1,
|
|
155
|
+
hooks: [{ target: 'canvas list', phase: 'before', op: 'check', description: 'check', effects: ['writes a log'] }],
|
|
156
|
+
});
|
|
157
|
+
assert.deepEqual(v1, {
|
|
158
|
+
manifest: {
|
|
159
|
+
schemaVersion: 1,
|
|
160
|
+
hooks: [{ target: 'canvas list', phase: 'before', op: 'check', description: 'check', effects: ['writes a log'] }],
|
|
161
|
+
},
|
|
162
|
+
issues: [],
|
|
163
|
+
});
|
|
164
|
+
const v2 = validateHookManifest({
|
|
165
|
+
schemaVersion: 2,
|
|
166
|
+
lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }],
|
|
167
|
+
});
|
|
168
|
+
assert.deepEqual(v2.issues, []);
|
|
169
|
+
assert.deepEqual(v2.manifest, {
|
|
170
|
+
schemaVersion: 2,
|
|
171
|
+
hooks: [],
|
|
172
|
+
lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }],
|
|
173
|
+
});
|
|
174
|
+
for (const manifest of [
|
|
175
|
+
{ schemaVersion: 2, hooks: [], lifecycle: [] },
|
|
176
|
+
{ schemaVersion: 2, lifecycle: [{ event: 'node:stop', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
177
|
+
{ schemaVersion: 2, lifecycle: [{ event: 'node:start', phase: 'before', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
178
|
+
{ schemaVersion: 2, lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'], unexpected: true }] },
|
|
179
|
+
]) {
|
|
180
|
+
assert.equal(validateHookManifest(manifest).manifest, undefined, 'invalid v2 lifecycle declarations are rejected');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
@@ -180,7 +180,8 @@ test('a resumed revive replays the persisted LaunchSpec (model, tools, extension
|
|
|
180
180
|
pi_session_id: 'uuid-9',
|
|
181
181
|
pi_session_file: sessionFile,
|
|
182
182
|
launch: {
|
|
183
|
-
model: 'anthropic/
|
|
183
|
+
model: 'anthropic/claude-opus-5:high',
|
|
184
|
+
modelExact: true,
|
|
184
185
|
tools: ['bash', 'read'],
|
|
185
186
|
extensions: ['/ext/a.js', '/ext/b.js'],
|
|
186
187
|
systemPrompt: 'You are a developer orchestrator.',
|
|
@@ -196,7 +197,8 @@ test('a resumed revive replays the persisted LaunchSpec (model, tools, extension
|
|
|
196
197
|
assert.equal(argv[ea - 1], '-e');
|
|
197
198
|
// Model + tools + system prompt + resume target.
|
|
198
199
|
const mi = argv.indexOf('--model');
|
|
199
|
-
assert.equal(argv[mi + 1], 'anthropic/
|
|
200
|
+
assert.equal(argv[mi + 1], 'anthropic/claude-opus-5:high');
|
|
201
|
+
assert.ok(argv.includes('--crtr-model-exact'), 'revive preserves the exact-model contract');
|
|
200
202
|
const ti = argv.indexOf('--tools');
|
|
201
203
|
assert.equal(argv[ti + 1], 'bash,read');
|
|
202
204
|
assert.ok(argv.includes('--append-system-prompt'), 'system prompt replayed');
|
|
@@ -229,7 +231,7 @@ test('reviveNode no-ops when the node has a live fleet entry — double-revive g
|
|
|
229
231
|
});
|
|
230
232
|
h.fleet.register(M, { pid: 424242, exited: new Promise(() => { }) });
|
|
231
233
|
const before = h.node(M).cycles ?? 0;
|
|
232
|
-
const res = reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
234
|
+
const res = await reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
233
235
|
assert.equal(res.resumed, false, 'guard path does not re-resume');
|
|
234
236
|
assert.equal(h.node(M).cycles ?? 0, before, 'cycle counter NOT bumped (guard returned early)');
|
|
235
237
|
}
|
|
@@ -257,7 +259,7 @@ test('reviveNode PROCEEDS when the node has NO fleet entry — cycle counter bum
|
|
|
257
259
|
pi_session_file: sessionFile,
|
|
258
260
|
});
|
|
259
261
|
assert.equal(h.node(M).cycles ?? 0, 0, 'fabricated at cycle 0 (no revive yet)');
|
|
260
|
-
reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
262
|
+
await reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
261
263
|
const after = h.node(M);
|
|
262
264
|
assert.equal(after.cycles, 1, 'a real (non-guard) revive bumped the cycle counter to 1');
|
|
263
265
|
assert.notEqual(after.pi_pid, stalePid, 'revive records the fresh broker pid immediately, not the stale dead pid');
|
|
@@ -301,11 +303,11 @@ test('a recovery relaunch keeps the armed deadline; a wake revive consumes it',
|
|
|
301
303
|
try {
|
|
302
304
|
const recovered = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-1' });
|
|
303
305
|
armDeadline(recovered, 'cron-recovery');
|
|
304
|
-
reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
|
|
306
|
+
await reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
|
|
305
307
|
assert.equal(hasPendingCancelOnWakeCron(recovered), true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
|
|
306
308
|
const woken = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-2' });
|
|
307
309
|
armDeadline(woken, 'cron-wake');
|
|
308
|
-
reviveNode(woken, { resume: true, capacity: 'freeze' });
|
|
310
|
+
await reviveNode(woken, { resume: true, capacity: 'freeze' });
|
|
309
311
|
assert.equal(hasPendingCancelOnWakeCron(woken), false, 'a wake revive won the race and cancels the deadline');
|
|
310
312
|
}
|
|
311
313
|
finally {
|
|
@@ -356,7 +358,7 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
|
|
|
356
358
|
pi_session_id: 'uuid-stale',
|
|
357
359
|
pi_session_file: missingFile,
|
|
358
360
|
});
|
|
359
|
-
const res = reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
361
|
+
const res = await reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
360
362
|
assert.equal(res.resumed, false, 'a missing .jsonl forces a fresh (non-resuming) launch');
|
|
361
363
|
const after = h.node(M);
|
|
362
364
|
assert.equal(after.pi_session_id, null, 'stale pi_session_id cleared before the fresh launch');
|
|
@@ -368,7 +370,7 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
|
|
|
368
370
|
await h.dispose();
|
|
369
371
|
}
|
|
370
372
|
});
|
|
371
|
-
test('unbound forks replay one durable source file for node-id, path, and UUID operands', () => {
|
|
373
|
+
test('unbound forks replay one durable source file for node-id, path, and UUID operands', async () => {
|
|
372
374
|
const originalLaunch = headlessBrokerHost.launch;
|
|
373
375
|
const tf = bindTestFleet();
|
|
374
376
|
const forkFile = join(home, 'ordinary-fork-source.jsonl');
|
|
@@ -402,7 +404,7 @@ test('unbound forks replay one durable source file for node-id, path, and UUID o
|
|
|
402
404
|
deleteNode('ordinary-fork-source');
|
|
403
405
|
for (let index = 0; index < operands.length; index++) {
|
|
404
406
|
const destination = `ordinary-fork-${index}`;
|
|
405
|
-
reviveNode(destination, { resume: index !== 1, capacity: 'freeze' });
|
|
407
|
+
await reviveNode(destination, { resume: index !== 1, capacity: 'freeze' });
|
|
406
408
|
const argv = launches[index];
|
|
407
409
|
const fork = argv.indexOf('--fork');
|
|
408
410
|
assert.equal(argv[fork + 1], forkFile, `${operands[index]} retries from the durable resolved file`);
|
|
@@ -435,7 +437,7 @@ test('reviveNode throws a typed cwd_missing error and does NOT crash the node wh
|
|
|
435
437
|
pi_pid: deadPid(),
|
|
436
438
|
});
|
|
437
439
|
updateNode(M, { cwd: missingCwd });
|
|
438
|
-
assert.
|
|
440
|
+
await assert.rejects(reviveNode(M, { resume: true, capacity: 'freeze' }), (e) => e instanceof InputError && e.payload.error === 'cwd_missing' && e.message.includes(missingCwd), 'reviveNode must throw a typed InputError naming the missing cwd path');
|
|
439
441
|
const after = h.node(M);
|
|
440
442
|
assert.equal(after.status, 'active', 'a cwd-missing preflight refusal must NOT crash the node (fixable in place, not a dead engine)');
|
|
441
443
|
}
|
|
@@ -453,7 +455,7 @@ test('reviveNode relaunches fresh when the session header cwd was removed (NL-14
|
|
|
453
455
|
const M = h.fabricateBrokerNode({
|
|
454
456
|
status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'stale-session', pi_session_file: sessionFile,
|
|
455
457
|
});
|
|
456
|
-
const result = reviveNode(M, { resume: true, capacity: 'freeze', recovery: true });
|
|
458
|
+
const result = await reviveNode(M, { resume: true, capacity: 'freeze', recovery: true });
|
|
457
459
|
assert.equal(result.resumed, false, 'the missing session cwd vetoes --session and launches fresh');
|
|
458
460
|
assert.equal(h.node(M)?.pi_session_id, null, 'the stale session id is cleared before the fresh launch');
|
|
459
461
|
assert.equal(h.node(M)?.pi_session_file, null, 'the stale session file is cleared before the fresh launch');
|
|
@@ -477,7 +479,7 @@ test('reviveNode crashes and clears pid when launch/preflight throws', async ()
|
|
|
477
479
|
pi_session_file: sessionFile,
|
|
478
480
|
});
|
|
479
481
|
headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
|
|
480
|
-
assert.
|
|
482
|
+
await assert.rejects(reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
|
|
481
483
|
const after = h.node(M);
|
|
482
484
|
assert.equal(after.status, 'dead', 'failed revive marks the node dead');
|
|
483
485
|
assert.equal(after.pi_pid, null, 'failed revive clears the stale pid');
|
|
@@ -510,7 +512,7 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
|
|
|
510
512
|
// was fabricated directly, not spawned through the CLI.
|
|
511
513
|
subscribe(root, M, true);
|
|
512
514
|
headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
|
|
513
|
-
assert.
|
|
515
|
+
await assert.rejects(reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
|
|
514
516
|
assert.equal(h.node(M).status, 'dead', 'failed revive marks the node dead');
|
|
515
517
|
const entries = readInboxSince(root, undefined);
|
|
516
518
|
const notice = entries.find((e) => e.data?.['reason'] === 'child-crashed' && e.data?.['child'] === M);
|
|
@@ -530,7 +532,7 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
|
|
|
530
532
|
// nothing. It woke, flailed over an empty context dir and reported itself
|
|
531
533
|
// confused. Such a relaunch is a BIRTH: replay the spawn mandate if there is
|
|
532
534
|
// one, else boot promptless and take no turn at all.
|
|
533
|
-
test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', () => {
|
|
535
|
+
test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', async () => {
|
|
534
536
|
const originalLaunch = headlessBrokerHost.launch;
|
|
535
537
|
const tf = bindTestFleet();
|
|
536
538
|
const launches = [];
|
|
@@ -545,19 +547,19 @@ test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', (
|
|
|
545
547
|
const missing = join(home, 'sessions', 'never-written.jsonl');
|
|
546
548
|
// (1) Bare interactive node — nothing on disk anywhere: launch promptless.
|
|
547
549
|
createNode(node('bare-unstarted', { cwd: home, pi_session_id: 'uuid-a', pi_session_file: missing }));
|
|
548
|
-
reviveNode('bare-unstarted', { resume: true, capacity: 'freeze' });
|
|
550
|
+
await reviveNode('bare-unstarted', { resume: true, capacity: 'freeze' });
|
|
549
551
|
const bare = launches[0];
|
|
550
552
|
assert.equal(bare.some((a) => isReviveKickoff(a)), false, 'an unstarted node is never told it lost context it never had');
|
|
551
553
|
assert.deepEqual(bare, buildPiArgv(getNode('bare-unstarted'), {}).argv, 'a promptless boot — the argv carries no trailing prompt positional at all');
|
|
552
554
|
// (2) Spawned with a mandate whose first turn never persisted: replay it.
|
|
553
555
|
createNode(node('mandate-unstarted', { cwd: home, pi_session_id: 'uuid-b', pi_session_file: missing }));
|
|
554
556
|
writeGoal('mandate-unstarted', 'do the thing');
|
|
555
|
-
reviveNode('mandate-unstarted', { resume: true, capacity: 'freeze' });
|
|
557
|
+
await reviveNode('mandate-unstarted', { resume: true, capacity: 'freeze' });
|
|
556
558
|
assert.equal(launches[1].at(-1), 'do the thing', 'a birth replays the spawn mandate');
|
|
557
559
|
// (3) Real durable progress on disk → still a genuine fresh revive.
|
|
558
560
|
createNode(node('progressed', { cwd: home, pi_session_id: 'uuid-c', pi_session_file: missing }));
|
|
559
561
|
seedRoadmap('progressed', { goal: 'ship it' });
|
|
560
|
-
reviveNode('progressed', { resume: true, capacity: 'freeze' });
|
|
562
|
+
await reviveNode('progressed', { resume: true, capacity: 'freeze' });
|
|
561
563
|
assert.equal(isReviveKickoff(launches[2].at(-1)), true, 'a node with a roadmap still rebuilds from the kickoff');
|
|
562
564
|
}
|
|
563
565
|
finally {
|
|
@@ -20,7 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import { buildLaunchSpec, isProviderPinnedModelToken, normalizeModel } from '../runtime/launch.js';
|
|
21
21
|
import { defaultModelLaddersConfig } from '../../types.js';
|
|
22
22
|
import { persistDormantModel } from '../runtime/model-swap.js';
|
|
23
|
-
import { promote } from '../runtime/promote.js';
|
|
23
|
+
import { promote, reshapeNode } from '../runtime/promote.js';
|
|
24
24
|
import { createNode, getNode } from '../canvas/canvas.js';
|
|
25
25
|
import { closeDb } from '../canvas/db.js';
|
|
26
26
|
const ladders = defaultModelLaddersConfig();
|
|
@@ -86,6 +86,15 @@ test('only concrete provider/id tokens pin; logical family/tier refs remain pref
|
|
|
86
86
|
assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier has no `/` and stays unpinned');
|
|
87
87
|
assert.equal(isProviderPinnedModelToken('opus'), false, 'a bare family alias has no `/` and stays unpinned');
|
|
88
88
|
});
|
|
89
|
+
test('a new --model override resolves once and freezes portable and concrete requests', () => {
|
|
90
|
+
for (const requested of ['anthropic/opus', 'strong', 'anthropic/claude-fable-5:high']) {
|
|
91
|
+
const launch = buildSpec({ model: requested, modelOverride: true });
|
|
92
|
+
assert.equal(launch.model, normalizeModel(requested, ladders), `${requested} resolves through the configured ladder`);
|
|
93
|
+
assert.equal(launch.modelExact, true, `${requested} is exact after resolution`);
|
|
94
|
+
assert.equal(launch.modelIntent, undefined, `${requested} has no portable route intent`);
|
|
95
|
+
assert.equal(launch.env.CRTR_MODEL_INTENT, undefined, `${requested} cannot re-enable fallback on revive`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
89
98
|
// Direction 1: an EXPLICIT concrete pin must SURVIVE a config/lifecycle rebuild
|
|
90
99
|
// even when the carried override has portablized back to a bare tier.
|
|
91
100
|
test('an explicit concrete pin survives a config/lifecycle rebuild whose portablized override reads as a bare tier', () => {
|
|
@@ -103,6 +112,20 @@ test('an explicit concrete pin survives a config/lifecycle rebuild whose portabl
|
|
|
103
112
|
// Direction 2: a deliberately TIER-run (unpinned) node must NOT be wrongly
|
|
104
113
|
// re-pinned when a subsequent rebuild/persist normalizes its override to the
|
|
105
114
|
// concrete cell it happens to share bytes with.
|
|
115
|
+
test('reshape resolves and persists a new --model override as the exact revive recipe', () => {
|
|
116
|
+
createNode(node('override-node', {
|
|
117
|
+
model_override: 'strong',
|
|
118
|
+
launch: { extensions: [], env: { CRTR_MODEL_INTENT: 'anthropic/strong' }, model: normalizeModel('strong', ladders), modelIntent: { family: 'anthropic', strength: 'strong' } },
|
|
119
|
+
}));
|
|
120
|
+
reshapeNode('override-node', { model: 'anthropic/opus' });
|
|
121
|
+
const after = getNode('override-node');
|
|
122
|
+
const expected = normalizeModel('anthropic/opus', ladders);
|
|
123
|
+
assert.equal(after?.model_override, expected, 'the raw alias never becomes the durable override');
|
|
124
|
+
assert.equal(after?.launch?.model, expected, 'revive replays the resolved provider/model');
|
|
125
|
+
assert.equal(after?.launch?.modelExact, true, 'retries and revives reject substitutes');
|
|
126
|
+
assert.equal(after?.launch?.modelIntent, undefined);
|
|
127
|
+
assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, undefined);
|
|
128
|
+
});
|
|
106
129
|
test('promotion preserves the exact-model contract required to revive a private human-work fork', () => {
|
|
107
130
|
const exactModel = 'openai-codex/gpt-5.6-sol:max';
|
|
108
131
|
createNode(node('exact-node', {
|
|
@@ -134,6 +157,7 @@ test('a deliberately tier-run node is not wrongly re-pinned when a rebuild or pe
|
|
|
134
157
|
const after = getNode('tier-node');
|
|
135
158
|
assert.equal(after?.launch?.model, ULTRA_CELL, 'the concrete spec is persisted as the launch model');
|
|
136
159
|
assert.equal(after?.model_override, ULTRA_CELL, 'the concrete spec is persisted as the override');
|
|
160
|
+
assert.equal(after?.launch?.modelExact, true, 'an explicit model change remains exact on revive');
|
|
137
161
|
assert.equal(after?.launch?.modelIntent, undefined, 'an explicit concrete choice clears the prior logical route intent');
|
|
138
162
|
assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, undefined, 'the revived broker does not inherit the prior logical route intent');
|
|
139
163
|
assert.equal(after?.launch?.modelProviderPinned, false, 'an explicit bare-tier decision clears a prior pin — the raw-token decision wins over the concrete spec shape');
|
|
@@ -179,6 +179,6 @@ test('reviveNode refuses to revive a node that became finalized after the caller
|
|
|
179
179
|
process.env['CRTR_NODE_ID'] = id;
|
|
180
180
|
await pushFinal(id, 'concurrent final');
|
|
181
181
|
assert.equal(getNode(id)?.status, 'done');
|
|
182
|
-
assert.
|
|
182
|
+
await assert.rejects(reviveNode(id, { resume: true, capacity: 'freeze' }), /finaliz/i, 'reviveNode\'s own belt-and-suspenders check refuses a now-latched target');
|
|
183
183
|
assert.equal(getNode(id)?.status, 'done', 'the node stays done, never silently revived into an unsatisfiable mandate');
|
|
184
184
|
});
|
|
@@ -18,6 +18,7 @@ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
|
18
18
|
import { closeDb } from '../canvas/db.js';
|
|
19
19
|
import { apiSocketPath } from '../canvas/paths.js';
|
|
20
20
|
import { brokerCapReached, resetFleetForTests } from '../runtime/fleet.js';
|
|
21
|
+
import { buildPiArgv } from '../runtime/launch.js';
|
|
21
22
|
import { relaunchRoot } from '../runtime/reset.js';
|
|
22
23
|
import { bindTestFleet } from './helpers/fleet.js';
|
|
23
24
|
let home;
|
|
@@ -106,6 +107,22 @@ test('relaunchRoot parks the old root done and mints a fresh active root, reapin
|
|
|
106
107
|
assert.equal(getNode('child')?.status, 'canceled', 'descendant child canceled');
|
|
107
108
|
assert.equal(getNode('grand')?.status, 'canceled', 'descendant grandchild canceled');
|
|
108
109
|
});
|
|
110
|
+
test('relaunchRoot preserves an explicit model override as an exact replay recipe', async () => {
|
|
111
|
+
const exactModel = 'anthropic/claude-opus-5:high';
|
|
112
|
+
createNode(node('root', {
|
|
113
|
+
parent: null,
|
|
114
|
+
lifecycle: 'resident',
|
|
115
|
+
model_override: exactModel,
|
|
116
|
+
launch: { extensions: [], env: {}, model: exactModel, modelExact: true },
|
|
117
|
+
}));
|
|
118
|
+
const { deps } = makeDeps(4242);
|
|
119
|
+
const result = await relaunchRoot('root', deps);
|
|
120
|
+
assert.ok(result);
|
|
121
|
+
const replacement = getNode(result.newNodeId);
|
|
122
|
+
assert.equal(replacement?.launch?.model, exactModel);
|
|
123
|
+
assert.equal(replacement?.launch?.modelExact, true, 'the replacement cannot route away from the override');
|
|
124
|
+
assert.ok(buildPiArgv(replacement, {}).argv.includes('--crtr-model-exact'), 'broker replay preserves the exact-model contract');
|
|
125
|
+
});
|
|
109
126
|
test('relaunchRoot on a boot failure leaves the old root + descendants fully intact', async () => {
|
|
110
127
|
// The mint-and-boot-FIRST ordering: a launch failure (pid=null) must occur
|
|
111
128
|
// BEFORE the old root is parked or its descendants reaped.
|
|
@@ -52,9 +52,9 @@ function node(id, over = {}) {
|
|
|
52
52
|
return id;
|
|
53
53
|
}
|
|
54
54
|
/** Fill the ledger to the cap with live brokers. */
|
|
55
|
-
function saturate() {
|
|
56
|
-
reviveNode(node(`filler-a-${seq}`), { resume: false, capacity: 'refuse' });
|
|
57
|
-
reviveNode(node(`filler-b-${seq}`), { resume: false, capacity: 'refuse' });
|
|
55
|
+
async function saturate() {
|
|
56
|
+
await reviveNode(node(`filler-a-${seq}`), { resume: false, capacity: 'refuse' });
|
|
57
|
+
await reviveNode(node(`filler-b-${seq}`), { resume: false, capacity: 'refuse' });
|
|
58
58
|
assert.equal(tf.fleet.size(), 2, 'precondition: the ledger is at its cap of 2');
|
|
59
59
|
}
|
|
60
60
|
before(() => {
|
|
@@ -78,10 +78,10 @@ after(() => {
|
|
|
78
78
|
delete process.env['CRTR_HOME'];
|
|
79
79
|
rmSync(home, { recursive: true, force: true });
|
|
80
80
|
});
|
|
81
|
-
test('a refusing caller gets an error naming the cap and the live count, never a silent no-op', () => {
|
|
82
|
-
saturate();
|
|
81
|
+
test('a refusing caller gets an error naming the cap and the live count, never a silent no-op', async () => {
|
|
82
|
+
await saturate();
|
|
83
83
|
const turned = node('refused', { pi_session_id: 'sess-refused' });
|
|
84
|
-
assert.
|
|
84
|
+
await assert.rejects(() => reviveNode(turned, { resume: true, capacity: 'refuse' }), (err) => {
|
|
85
85
|
const details = err.details ?? {};
|
|
86
86
|
assert.equal(details['error'], 'broker_cap_reached', 'the refusal is a typed InputError, so it survives the /v1 error body');
|
|
87
87
|
assert.match(String(details['message']), /2 live brokers at the cap of 2/, 'the message names both the live count and the cap');
|
|
@@ -110,21 +110,21 @@ test('a pre-spawn launch failure releases the reservation', () => {
|
|
|
110
110
|
stubLaunch();
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
|
-
test('a freezing caller gets a frozen row, marked once and left otherwise untouched', () => {
|
|
114
|
-
saturate();
|
|
113
|
+
test('a freezing caller gets a frozen row, marked once and left otherwise untouched', async () => {
|
|
114
|
+
await saturate();
|
|
115
115
|
const waiting = node('frozen', { pi_session_id: 'sess-frozen' });
|
|
116
|
-
const first = reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
116
|
+
const first = await reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
117
117
|
assert.equal(first.outcome, 'frozen', 'the caller learns it got no engine');
|
|
118
118
|
const marked = getNode(waiting);
|
|
119
119
|
assert.ok(marked.frozen_at != null, 'the row carries the durable wait mark the thaw order reads');
|
|
120
120
|
assert.equal(marked.pi_pid ?? null, null, 'a frozen row holds no pid — every status surface reads it as frozen, not stale-active');
|
|
121
121
|
assert.equal(marked.cycles ?? 0, 0, 'a frozen node never spent a cycle');
|
|
122
|
-
const second = reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
122
|
+
const second = await reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
123
123
|
assert.equal(second.outcome, 'frozen');
|
|
124
124
|
assert.equal(getNode(waiting).frozen_at, marked.frozen_at, 'freezing is once-per-episode: a second saturated tick keeps the original wait timestamp');
|
|
125
125
|
});
|
|
126
126
|
test('a durable daemon message reports capacity_frozen when its wake cannot launch', async () => {
|
|
127
|
-
saturate();
|
|
127
|
+
await saturate();
|
|
128
128
|
const waiting = node('message-frozen');
|
|
129
129
|
const delivery = await deliverNodeMessage({
|
|
130
130
|
node_id: waiting,
|
|
@@ -137,26 +137,26 @@ test('a durable daemon message reports capacity_frozen when its wake cannot laun
|
|
|
137
137
|
assert.equal(delivery.revived, false);
|
|
138
138
|
assert.equal(delivery.reason, 'capacity_frozen');
|
|
139
139
|
});
|
|
140
|
-
test('an already-live node is admitted at the cap — it consumes no new slot', () => {
|
|
140
|
+
test('an already-live node is admitted at the cap — it consumes no new slot', async () => {
|
|
141
141
|
const live = node('already-live');
|
|
142
|
-
reviveNode(live, { resume: false, capacity: 'refuse' });
|
|
143
|
-
reviveNode(node('already-live-filler'), { resume: false, capacity: 'refuse' });
|
|
142
|
+
await reviveNode(live, { resume: false, capacity: 'refuse' });
|
|
143
|
+
await reviveNode(node('already-live-filler'), { resume: false, capacity: 'refuse' });
|
|
144
144
|
assert.equal(tf.fleet.size(), 2, 'precondition: this node holds one of the two slots, and the ledger is full');
|
|
145
145
|
// Both dispositions agree: liveness is answered before capacity ever is.
|
|
146
|
-
assert.equal(reviveNode(live, { resume: true, capacity: 'refuse' }).outcome, 'already-live');
|
|
147
|
-
assert.equal(reviveNode(live, { resume: true, capacity: 'freeze' }).outcome, 'already-live');
|
|
146
|
+
assert.equal((await reviveNode(live, { resume: true, capacity: 'refuse' })).outcome, 'already-live');
|
|
147
|
+
assert.equal((await reviveNode(live, { resume: true, capacity: 'freeze' })).outcome, 'already-live');
|
|
148
148
|
assert.equal(getNode(live).frozen_at ?? null, null, 'a live node is never marked as waiting for a slot');
|
|
149
149
|
});
|
|
150
150
|
test('a freed slot lets the frozen row launch, and the launch retires the mark', async () => {
|
|
151
151
|
const filler = node('filler-exits');
|
|
152
|
-
reviveNode(filler, { resume: false, capacity: 'refuse' });
|
|
153
|
-
reviveNode(node('filler-stays'), { resume: false, capacity: 'refuse' });
|
|
152
|
+
await reviveNode(filler, { resume: false, capacity: 'refuse' });
|
|
153
|
+
await reviveNode(node('filler-stays'), { resume: false, capacity: 'refuse' });
|
|
154
154
|
const waiting = node('thaws', { pi_session_id: 'sess-thaws' });
|
|
155
|
-
assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
|
|
155
|
+
assert.equal((await reviveNode(waiting, { resume: true, capacity: 'freeze' })).outcome, 'frozen');
|
|
156
156
|
// A real exit frees the slot; its policy job runs on the lane like production.
|
|
157
157
|
tf.fleet.deliverExit(filler, { code: 0, signal: null });
|
|
158
158
|
await tf.drain();
|
|
159
|
-
assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'launched');
|
|
159
|
+
assert.equal((await reviveNode(waiting, { resume: true, capacity: 'freeze' })).outcome, 'launched');
|
|
160
160
|
assert.equal(getNode(waiting).frozen_at ?? null, null, 'a launch is the definition of not-frozen');
|
|
161
161
|
assert.equal(tf.fleet.has(waiting), true, 'the thawed node owns the freed slot');
|
|
162
162
|
});
|
|
@@ -103,10 +103,10 @@ after(() => {
|
|
|
103
103
|
else
|
|
104
104
|
process.env['CRTR_HOME'] = priorHome;
|
|
105
105
|
});
|
|
106
|
-
test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', () => {
|
|
106
|
+
test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', async () => {
|
|
107
107
|
parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
108
108
|
deliverMail('parked');
|
|
109
|
-
const result = reviveNode('parked', { resume: true, capacity: 'freeze' });
|
|
109
|
+
const result = await 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');
|
|
@@ -115,15 +115,15 @@ test('a parked node with mail waiting re-enters fresh even when the caller asked
|
|
|
115
115
|
assert.equal(reopened.intent ?? null, null, 'the park marker clears on revival');
|
|
116
116
|
assert.equal(reopened.cycle_pending, true, 'the fresh cycle is provisional until session_start confirms it');
|
|
117
117
|
});
|
|
118
|
-
test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', () => {
|
|
118
|
+
test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', async () => {
|
|
119
119
|
parkResident('goal-only', { goal: 'Watch the deploy lane.' });
|
|
120
120
|
deliverMail('goal-only');
|
|
121
|
-
assert.equal(reviveNode('goal-only', { resume: true, capacity: 'freeze' }).resumed, false);
|
|
121
|
+
assert.equal((await reviveNode('goal-only', { resume: true, capacity: 'freeze' })).resumed, false);
|
|
122
122
|
assert.equal(kickedOff(launches[0]), true);
|
|
123
123
|
});
|
|
124
|
-
test('an input-less revival of a parked node quietly resumes and injects no kickoff', () => {
|
|
124
|
+
test('an input-less revival of a parked node quietly resumes and injects no kickoff', async () => {
|
|
125
125
|
parkResident('quiet', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
126
|
-
const result = reviveNode('quiet', { resume: true, capacity: 'freeze' });
|
|
126
|
+
const result = await 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');
|
|
@@ -132,29 +132,29 @@ test('an input-less revival of a parked node quietly resumes and injects no kick
|
|
|
132
132
|
assert.equal(reopened.status, 'active');
|
|
133
133
|
assert.equal(reopened.intent ?? null, null, 'the park marker clears on a quiet resume too');
|
|
134
134
|
});
|
|
135
|
-
test('deferred mail alone is not input, so the revival still resumes quietly', () => {
|
|
135
|
+
test('deferred mail alone is not input, so the revival still resumes quietly', async () => {
|
|
136
136
|
parkResident('deferred', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
137
137
|
deliverMail('deferred', 'deferred');
|
|
138
|
-
assert.equal(reviveNode('deferred', { resume: true, capacity: 'freeze' }).resumed, true);
|
|
138
|
+
assert.equal((await 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
|
-
test('mail already covered by the cursor is not input, so the revival still resumes quietly', () => {
|
|
142
|
+
test('mail already covered by the cursor is not input, so the revival still resumes quietly', async () => {
|
|
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, capacity: 'freeze' }).resumed, true, 'only the UNSEEN suffix counts as input');
|
|
145
|
+
assert.equal((await 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
|
-
test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', () => {
|
|
148
|
+
test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', async () => {
|
|
149
149
|
parkResident('bare');
|
|
150
150
|
deliverMail('bare');
|
|
151
|
-
const result = reviveNode('bare', { resume: true, capacity: 'freeze' });
|
|
151
|
+
const result = await 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
|
});
|
|
155
|
-
test('an ordinary done node with a roadmap still resumes — only the park marker vetoes', () => {
|
|
155
|
+
test('an ordinary done node with a roadmap still resumes — only the park marker vetoes', async () => {
|
|
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, capacity: 'freeze' }).resumed, true);
|
|
159
|
+
assert.equal((await reviveNode('finished', { resume: true, capacity: 'freeze' })).resumed, true);
|
|
160
160
|
});
|
|
@@ -117,7 +117,7 @@ test('a birth at the cap freezes instead of booting, survives saturated ticks, a
|
|
|
117
117
|
const filler = await h.spawnHeadlessChild(root, 'terminal thaw filler');
|
|
118
118
|
assert.equal(h.fleet.size(), 2, 'the parked target is non-live while the cap remains full');
|
|
119
119
|
appendInbox(second, { from: root, tier: 'normal', kind: 'message', label: 'durable wake' });
|
|
120
|
-
assert.equal(reviveNode(second, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
|
|
120
|
+
assert.equal((await reviveNode(second, { resume: true, capacity: 'freeze' })).outcome, 'frozen');
|
|
121
121
|
closeDb();
|
|
122
122
|
const frozenTerminal = h.node(second);
|
|
123
123
|
assert.equal(frozenTerminal.status, 'done', 'freezing preserves the terminal status');
|
|
@@ -144,7 +144,7 @@ test('a birth at the cap freezes instead of booting, survives saturated ticks, a
|
|
|
144
144
|
created: new Date().toISOString(),
|
|
145
145
|
pi_session_id: 'resident-drain-session',
|
|
146
146
|
});
|
|
147
|
-
assert.equal(reviveNode('resident-drain', { resume: true, capacity: 'freeze' }).outcome, 'frozen');
|
|
147
|
+
assert.equal((await reviveNode('resident-drain', { resume: true, capacity: 'freeze' })).outcome, 'frozen');
|
|
148
148
|
await h.tick();
|
|
149
149
|
closeDb();
|
|
150
150
|
const drained = h.node('resident-drain');
|