@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,72 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/cron-self-cancel-delivery.test.ts
|
|
2
|
+
//
|
|
3
|
+
// A cron whose own command cancels its row mid-run must still deliver the
|
|
4
|
+
// output that run produced: cancellation retires the schedule, not the run in
|
|
5
|
+
// flight.
|
|
6
|
+
import { after, afterEach, before, test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
12
|
+
import { closeDb } from '../canvas/db.js';
|
|
13
|
+
import { armCron, cancelCron, getCron } from '../canvas/crons.js';
|
|
14
|
+
import { executeCron, markSelfCanceledCronRun } from '../../daemon/cron-run.js';
|
|
15
|
+
let home;
|
|
16
|
+
let realHome;
|
|
17
|
+
function arm(overrides) {
|
|
18
|
+
armCron({
|
|
19
|
+
name: overrides.cron_id,
|
|
20
|
+
created_by: null,
|
|
21
|
+
fire_at: new Date(Date.now() - 1_000).toISOString(),
|
|
22
|
+
recur: null,
|
|
23
|
+
tz: null,
|
|
24
|
+
expires_at: null,
|
|
25
|
+
anchor_node: null,
|
|
26
|
+
cancel_on_wake: false,
|
|
27
|
+
cwd: home,
|
|
28
|
+
env_json: null,
|
|
29
|
+
profile: null,
|
|
30
|
+
scope: 'profile',
|
|
31
|
+
run_timeout_s: 30,
|
|
32
|
+
overlap: 'skip',
|
|
33
|
+
on_output: 'silent',
|
|
34
|
+
sink: '{}',
|
|
35
|
+
tier: 'normal',
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
before(() => {
|
|
40
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-cron-broker-capacity-'));
|
|
41
|
+
realHome = process.env['CRTR_HOME'];
|
|
42
|
+
process.env['CRTR_HOME'] = home;
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
closeDb();
|
|
46
|
+
});
|
|
47
|
+
after(() => {
|
|
48
|
+
closeDb();
|
|
49
|
+
rmSync(home, { recursive: true, force: true });
|
|
50
|
+
if (realHome === undefined)
|
|
51
|
+
delete process.env['CRTR_HOME'];
|
|
52
|
+
else
|
|
53
|
+
process.env['CRTR_HOME'] = realHome;
|
|
54
|
+
});
|
|
55
|
+
test('an on-change cron that cancels itself still delivers its final output', async () => {
|
|
56
|
+
const runIdPath = join(home, 'self-cancel-run-id');
|
|
57
|
+
arm({
|
|
58
|
+
cron_id: 'self-cancel-delivery',
|
|
59
|
+
command: `printf '%s' "$CRTR_RUN_ID" > '${runIdPath}'; echo settled; sleep 0.1`,
|
|
60
|
+
recur: JSON.stringify({ every: '1h' }),
|
|
61
|
+
on_output: 'on-change',
|
|
62
|
+
sink: JSON.stringify({ human: true }),
|
|
63
|
+
});
|
|
64
|
+
const run = executeCron(getCron('self-cancel-delivery'), { outOfBand: false });
|
|
65
|
+
for (let i = 0; i < 50 && !existsSync(runIdPath); i++)
|
|
66
|
+
await delay(10);
|
|
67
|
+
assert.ok(existsSync(runIdPath), 'the live run exposed its id to its self-cancel command');
|
|
68
|
+
assert.equal(markSelfCanceledCronRun('self-cancel-delivery', readFileSync(runIdPath, 'utf8')), true);
|
|
69
|
+
assert.equal(cancelCron('self-cancel-delivery', { preserveRunningProcess: true }), true);
|
|
70
|
+
const record = await run;
|
|
71
|
+
assert.equal(record?.delivered, 'delivered: human send --inbox');
|
|
72
|
+
});
|
|
@@ -15,6 +15,7 @@ import { verifyDaemonStartup, waitForDaemonExit } from '../../daemon/manage.js';
|
|
|
15
15
|
import { waitForBrokerViewSocket } from '../runtime/placement-tmux.js';
|
|
16
16
|
import { viewSocketPath } from '../canvas/paths.js';
|
|
17
17
|
import { markBusy, clearBusy } from '../runtime/busy.js';
|
|
18
|
+
import { headlessBrokerHost } from '../runtime/host.js';
|
|
18
19
|
import { bindTestFleet } from './helpers/fleet.js';
|
|
19
20
|
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
20
21
|
let home;
|
|
@@ -92,34 +93,45 @@ test('never-booted broker exit is marked dead and surfaced to the parent as urge
|
|
|
92
93
|
assert.ok(wake, 'the doctrine wake rides beside the urgent push');
|
|
93
94
|
assert.match(wake.label, /crashed/i);
|
|
94
95
|
});
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
96
|
+
// surfaceChildDeath was DELETED, and the node-lifecycle tick recovers a crashed
|
|
97
|
+
// booted child through the SAME policy table its exit event would have used
|
|
98
|
+
// (design D-12), so tick recovery and exit recovery can never diverge. A row
|
|
99
|
+
// whose recorded pid is provably dead and whose session was recorded is a
|
|
100
|
+
// resumable crash: it is relaunched on that saved session, never reaped to
|
|
101
|
+
// `dead` and never announced as a boot failure or a child death.
|
|
102
|
+
test('a crashed booted child is relaunched on its saved session, not reaped, and raises no false boot-failure alarm', async () => {
|
|
103
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
104
|
+
const launches = [];
|
|
105
|
+
headlessBrokerHost.launch = (nodeId) => {
|
|
106
|
+
launches.push(nodeId);
|
|
107
|
+
const handle = { pid: 424_242, exited: new Promise(() => { }) };
|
|
108
|
+
tf.fleet.register(nodeId, handle); // the real host contract: registration at the launch seam
|
|
109
|
+
return handle;
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
|
|
113
|
+
createNode(node('C2', {
|
|
114
|
+
cwd: home, // a cwd that exists — reviveNode's preflight refuses a missing one
|
|
115
|
+
parent: 'P2',
|
|
116
|
+
kind: 'explore',
|
|
117
|
+
pi_pid: deadPid(), // engine crashed — its recorded pid is dead
|
|
118
|
+
pi_session_id: '019e8f00-booted-once', // it booted before dying
|
|
119
|
+
intent: null,
|
|
120
|
+
}));
|
|
121
|
+
spawnEdge('P2', 'C2');
|
|
122
|
+
markBusy('C2'); // died mid-generation
|
|
123
|
+
await superviseTick();
|
|
124
|
+
assert.deepEqual(launches, ['C2'], 'the tick recovers the crash through the policy table, exactly as the exit event would');
|
|
125
|
+
assert.equal(getNode('C2').status, 'active', 'crashed booted child is relaunched, NOT marked dead');
|
|
126
|
+
// surfaceChildDeath is gone and this is not a never-booted node, so the daemon
|
|
127
|
+
// fans NO push here — no false boot-failure / child-death alarm on a crash.
|
|
128
|
+
assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
|
|
129
|
+
clearBusy('C2');
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
133
|
+
tf.fleet.forget('C2');
|
|
134
|
+
}
|
|
123
135
|
});
|
|
124
136
|
// An unplaced active node is not daemon-managed; the daemon leaves it untouched
|
|
125
137
|
// rather than treating the missing broker/session fields as a boot failure.
|
|
@@ -192,7 +192,7 @@ describe('terminal ticket delivery', () => {
|
|
|
192
192
|
await deliverTerminalResult(bridge);
|
|
193
193
|
assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
|
|
194
194
|
assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
|
|
195
|
-
assert.throws(() => reviveNode(companion, { resume: true }), /finalization latch/);
|
|
195
|
+
assert.throws(() => reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
|
|
196
196
|
const { bridge: dismissedBridge } = bridgeUnder();
|
|
197
197
|
const dismissedDir = submitBridgePage(dismissedBridge);
|
|
198
198
|
const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
|
|
@@ -14,12 +14,11 @@
|
|
|
14
14
|
// (pid==null, session==null) as a never-booted broker and boot-failed it —
|
|
15
15
|
// killing the human prompt before the person could ever answer.
|
|
16
16
|
//
|
|
17
|
-
// THE FIX under exit-event authority
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// of the exact bug.
|
|
17
|
+
// THE FIX under exit-event authority is superviseTick's one filter, driven
|
|
18
|
+
// here: it drops kind:'human' rows before every row pass, including the
|
|
19
|
+
// node-lifecycle tick that classifies a row with no live broker as known-dead.
|
|
20
|
+
// An unfiltered human row (session null, no fork claim) would land in policy
|
|
21
|
+
// row 8 (boot failure), the modern re-incarnation of the exact bug.
|
|
23
22
|
import { test, before, after, beforeEach } from 'node:test';
|
|
24
23
|
import assert from 'node:assert/strict';
|
|
25
24
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
@@ -28,7 +27,7 @@ import { join } from 'node:path';
|
|
|
28
27
|
import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
29
28
|
import { closeDb } from '../canvas/db.js';
|
|
30
29
|
import { readInboxSince } from '../feed/inbox.js';
|
|
31
|
-
import { superviseTick
|
|
30
|
+
import { superviseTick } from '../../daemon/crtrd.js';
|
|
32
31
|
import { bindTestFleet } from './helpers/fleet.js';
|
|
33
32
|
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
34
33
|
let home;
|
|
@@ -49,7 +48,7 @@ function node(id, over = {}) {
|
|
|
49
48
|
before(() => {
|
|
50
49
|
home = mkdtempSync(join(tmpdir(), 'crtr-human-not-supervised-'));
|
|
51
50
|
process.env['CRTR_HOME'] = home;
|
|
52
|
-
tf = bindTestFleet(); // superviseTick
|
|
51
|
+
tf = bindTestFleet(); // superviseTick requires a bound fleet (D-2)
|
|
53
52
|
});
|
|
54
53
|
beforeEach(() => {
|
|
55
54
|
closeDb();
|
|
@@ -79,16 +78,12 @@ test('a kind:"human" bridge row is NEVER boot-failed by the daemon (it has no br
|
|
|
79
78
|
// the tick leave IT alone — it is only here to catch a push.
|
|
80
79
|
createNode(node('ASKER', { status: 'idle', intent: 'idle-release', pi_session_id: 'booted' }));
|
|
81
80
|
subscribe('ASKER', 'HUMAN', true);
|
|
82
|
-
// Two well-spaced ticks:
|
|
83
|
-
//
|
|
84
|
-
//
|
|
81
|
+
// Two well-spaced ticks: the row passes (controller-wait settle, inbox wake,
|
|
82
|
+
// and the node-lifecycle classification of a row with no live broker) must
|
|
83
|
+
// all drop the human row before reading it.
|
|
85
84
|
const t0 = Date.now();
|
|
86
85
|
await superviseTick(t0);
|
|
87
86
|
await superviseTick(t0 + 10_000_000);
|
|
88
|
-
// The startup recovery sweep is the sharper threat: it classifies every swept
|
|
89
|
-
// active|idle row as known-dead, so only its kind:'human' filter keeps the
|
|
90
|
-
// bridge row out of policy row 8 (boot failure).
|
|
91
|
-
await runStartupRecoverySweep(tf.fleet);
|
|
92
87
|
assert.equal(getNode('HUMAN').status, 'active', 'human bridge row stays alive — never crash-reaped as a dead broker');
|
|
93
88
|
assert.equal(readInboxSince('ASKER').length, 0, 'no surfaceBootFailure urgent push — the asker is never told its human prompt "never started"');
|
|
94
89
|
});
|
|
@@ -226,7 +226,7 @@ test('reviveNode no-ops when the node has a live fleet entry — double-revive g
|
|
|
226
226
|
});
|
|
227
227
|
h.fleet.register(M, { pid: 424242, exited: new Promise(() => { }) });
|
|
228
228
|
const before = h.node(M).cycles ?? 0;
|
|
229
|
-
const res = reviveNode(M, { resume: true });
|
|
229
|
+
const res = reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
230
230
|
assert.equal(res.resumed, false, 'guard path does not re-resume');
|
|
231
231
|
assert.equal(h.node(M).cycles ?? 0, before, 'cycle counter NOT bumped (guard returned early)');
|
|
232
232
|
}
|
|
@@ -254,7 +254,7 @@ test('reviveNode PROCEEDS when the node has NO fleet entry — cycle counter bum
|
|
|
254
254
|
pi_session_file: sessionFile,
|
|
255
255
|
});
|
|
256
256
|
assert.equal(h.node(M).cycles ?? 0, 0, 'fabricated at cycle 0 (no revive yet)');
|
|
257
|
-
reviveNode(M, { resume: true });
|
|
257
|
+
reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
258
258
|
const after = h.node(M);
|
|
259
259
|
assert.equal(after.cycles, 1, 'a real (non-guard) revive bumped the cycle counter to 1');
|
|
260
260
|
assert.notEqual(after.pi_pid, stalePid, 'revive records the fresh broker pid immediately, not the stale dead pid');
|
|
@@ -298,11 +298,11 @@ test('a recovery relaunch keeps the armed deadline; a wake revive consumes it',
|
|
|
298
298
|
try {
|
|
299
299
|
const recovered = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-1' });
|
|
300
300
|
armDeadline(recovered, 'cron-recovery');
|
|
301
|
-
reviveNode(recovered, { resume: true, recovery: true });
|
|
301
|
+
reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
|
|
302
302
|
assert.equal(hasPendingCancelOnWakeCron(recovered), true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
|
|
303
303
|
const woken = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-2' });
|
|
304
304
|
armDeadline(woken, 'cron-wake');
|
|
305
|
-
reviveNode(woken, { resume: true });
|
|
305
|
+
reviveNode(woken, { resume: true, capacity: 'freeze' });
|
|
306
306
|
assert.equal(hasPendingCancelOnWakeCron(woken), false, 'a wake revive won the race and cancels the deadline');
|
|
307
307
|
}
|
|
308
308
|
finally {
|
|
@@ -327,7 +327,7 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
|
|
|
327
327
|
pi_session_id: 'uuid-stale',
|
|
328
328
|
pi_session_file: missingFile,
|
|
329
329
|
});
|
|
330
|
-
const res = reviveNode(M, { resume: true });
|
|
330
|
+
const res = reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
331
331
|
assert.equal(res.resumed, false, 'a missing .jsonl forces a fresh (non-resuming) launch');
|
|
332
332
|
const after = h.node(M);
|
|
333
333
|
assert.equal(after.pi_session_id, null, 'stale pi_session_id cleared before the fresh launch');
|
|
@@ -373,7 +373,7 @@ test('unbound forks replay one durable source file for node-id, path, and UUID o
|
|
|
373
373
|
deleteNode('ordinary-fork-source');
|
|
374
374
|
for (let index = 0; index < operands.length; index++) {
|
|
375
375
|
const destination = `ordinary-fork-${index}`;
|
|
376
|
-
reviveNode(destination, { resume: index !== 1 });
|
|
376
|
+
reviveNode(destination, { resume: index !== 1, capacity: 'freeze' });
|
|
377
377
|
const argv = launches[index];
|
|
378
378
|
const fork = argv.indexOf('--fork');
|
|
379
379
|
assert.equal(argv[fork + 1], forkFile, `${operands[index]} retries from the durable resolved file`);
|
|
@@ -406,7 +406,7 @@ test('reviveNode throws a typed cwd_missing error and does NOT crash the node wh
|
|
|
406
406
|
pi_pid: deadPid(),
|
|
407
407
|
});
|
|
408
408
|
updateNode(M, { cwd: missingCwd });
|
|
409
|
-
assert.throws(() => reviveNode(M, { resume: true }), (e) => e instanceof InputError && e.payload.error === 'cwd_missing' && e.message.includes(missingCwd), 'reviveNode must throw a typed InputError naming the missing cwd path');
|
|
409
|
+
assert.throws(() => 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');
|
|
410
410
|
const after = h.node(M);
|
|
411
411
|
assert.equal(after.status, 'active', 'a cwd-missing preflight refusal must NOT crash the node (fixable in place, not a dead engine)');
|
|
412
412
|
}
|
|
@@ -429,7 +429,7 @@ test('reviveNode crashes and clears pid when launch/preflight throws', async ()
|
|
|
429
429
|
pi_session_file: sessionFile,
|
|
430
430
|
});
|
|
431
431
|
headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
|
|
432
|
-
assert.throws(() => reviveNode(M, { resume: true }), /refusing to revive/i);
|
|
432
|
+
assert.throws(() => reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
|
|
433
433
|
const after = h.node(M);
|
|
434
434
|
assert.equal(after.status, 'dead', 'failed revive marks the node dead');
|
|
435
435
|
assert.equal(after.pi_pid, null, 'failed revive clears the stale pid');
|
|
@@ -462,7 +462,7 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
|
|
|
462
462
|
// was fabricated directly, not spawned through the CLI.
|
|
463
463
|
subscribe(root, M, true);
|
|
464
464
|
headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
|
|
465
|
-
assert.throws(() => reviveNode(M, { resume: true }), /refusing to revive/i);
|
|
465
|
+
assert.throws(() => reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
|
|
466
466
|
assert.equal(h.node(M).status, 'dead', 'failed revive marks the node dead');
|
|
467
467
|
const entries = readInboxSince(root, undefined);
|
|
468
468
|
const notice = entries.find((e) => e.data?.['reason'] === 'child-crashed' && e.data?.['child'] === M);
|
|
@@ -497,19 +497,19 @@ test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', (
|
|
|
497
497
|
const missing = join(home, 'sessions', 'never-written.jsonl');
|
|
498
498
|
// (1) Bare interactive node — nothing on disk anywhere: launch promptless.
|
|
499
499
|
createNode(node('bare-unstarted', { cwd: home, pi_session_id: 'uuid-a', pi_session_file: missing }));
|
|
500
|
-
reviveNode('bare-unstarted', { resume: true });
|
|
500
|
+
reviveNode('bare-unstarted', { resume: true, capacity: 'freeze' });
|
|
501
501
|
const bare = launches[0];
|
|
502
502
|
assert.equal(bare.some((a) => isReviveKickoff(a)), false, 'an unstarted node is never told it lost context it never had');
|
|
503
503
|
assert.deepEqual(bare, buildPiArgv(getNode('bare-unstarted'), {}).argv, 'a promptless boot — the argv carries no trailing prompt positional at all');
|
|
504
504
|
// (2) Spawned with a mandate whose first turn never persisted: replay it.
|
|
505
505
|
createNode(node('mandate-unstarted', { cwd: home, pi_session_id: 'uuid-b', pi_session_file: missing }));
|
|
506
506
|
writeGoal('mandate-unstarted', 'do the thing');
|
|
507
|
-
reviveNode('mandate-unstarted', { resume: true });
|
|
507
|
+
reviveNode('mandate-unstarted', { resume: true, capacity: 'freeze' });
|
|
508
508
|
assert.equal(launches[1].at(-1), 'do the thing', 'a birth replays the spawn mandate');
|
|
509
509
|
// (3) Real durable progress on disk → still a genuine fresh revive.
|
|
510
510
|
createNode(node('progressed', { cwd: home, pi_session_id: 'uuid-c', pi_session_file: missing }));
|
|
511
511
|
seedRoadmap('progressed', { goal: 'ship it' });
|
|
512
|
-
reviveNode('progressed', { resume: true });
|
|
512
|
+
reviveNode('progressed', { resume: true, capacity: 'freeze' });
|
|
513
513
|
assert.equal(isReviveKickoff(launches[2].at(-1)), true, 'a node with a roadmap still rebuilds from the kickoff');
|
|
514
514
|
}
|
|
515
515
|
finally {
|
|
@@ -15,6 +15,8 @@ import { spawnNode } from '../../runtime/nodes.js';
|
|
|
15
15
|
import { readGoal } from '../../runtime/kickoff.js';
|
|
16
16
|
import { spawnChild, resolveSpawner } from '../../runtime/spawn.js';
|
|
17
17
|
import { headlessBrokerHost } from '../../runtime/host.js';
|
|
18
|
+
import { resetFleetForTests } from '../../runtime/fleet.js';
|
|
19
|
+
import { bindTestFleet } from '../helpers/fleet.js';
|
|
18
20
|
import { recordFault } from '../../runtime/fault.js';
|
|
19
21
|
import { piSessionsRoot } from '../../runtime/pi-vendored.js';
|
|
20
22
|
import { emptyContextExposureState, exposureTarget, loadContextExposureState, registerExposure, saveContextExposureState, } from '../../substrate/injected-store.js';
|
|
@@ -40,6 +42,9 @@ before(() => {
|
|
|
40
42
|
process.env['CRTR_HOME'] = home;
|
|
41
43
|
previousPidfile = process.env['CRTR_PIDFILE'];
|
|
42
44
|
process.env['CRTR_PIDFILE'] = join(home, 'crtrd.pid');
|
|
45
|
+
// Every launcher claims a slot from the ledger before it reaches the host, so
|
|
46
|
+
// a spawn in-process needs a bound fleet (D-2).
|
|
47
|
+
bindTestFleet();
|
|
43
48
|
});
|
|
44
49
|
beforeEach(() => {
|
|
45
50
|
closeDb();
|
|
@@ -52,6 +57,7 @@ beforeEach(() => {
|
|
|
52
57
|
});
|
|
53
58
|
after(() => {
|
|
54
59
|
closeDb();
|
|
60
|
+
resetFleetForTests();
|
|
55
61
|
rmSync(home, { recursive: true, force: true });
|
|
56
62
|
if (previousPidfile === undefined)
|
|
57
63
|
delete process.env['CRTR_PIDFILE'];
|
|
@@ -9,6 +9,9 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { gitSync } from '../../git.js';
|
|
11
11
|
import { createManagedWorktree, closeManagedWorktree } from '../../worktree.js';
|
|
12
|
+
import { createApiServer } from '../../../daemon/api/server.js';
|
|
13
|
+
import { apiSocketPath } from '../../canvas/paths.js';
|
|
14
|
+
import { CrtrClient } from '../../../api/index.js';
|
|
12
15
|
import { createNode, getNode } from '../../canvas/canvas.js';
|
|
13
16
|
import { closeDb } from '../../canvas/db.js';
|
|
14
17
|
let home;
|
|
@@ -193,6 +196,35 @@ test('autoDropCleanManagedWorktree refuses a worktree with real uncommitted chan
|
|
|
193
196
|
assert.equal(result, null, 'real uncommitted changes must never be silently dropped');
|
|
194
197
|
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open');
|
|
195
198
|
});
|
|
199
|
+
test('abandon preserves committed and dirty work, records its retained tip, and releases finalization', async () => {
|
|
200
|
+
const nodeId = 'wt-abandon';
|
|
201
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
202
|
+
writeFileSync(join(wt.path, 'committed.txt'), 'committed work\n');
|
|
203
|
+
run(['add', '.'], wt.path);
|
|
204
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'real work'], wt.path);
|
|
205
|
+
writeFileSync(join(wt.path, 'dirty.txt'), 'dirty work\n');
|
|
206
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
207
|
+
const server = createApiServer();
|
|
208
|
+
try {
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
210
|
+
const client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
|
|
211
|
+
const result = await client.abandonWorktree(nodeId, { by: 'test-invoker' });
|
|
212
|
+
const meta = getNode(nodeId);
|
|
213
|
+
assert.equal(gitSync(['rev-parse', wt.branch], sourceDir).stdout.trim(), result.tip_sha, 'the retained branch names the recorded tip');
|
|
214
|
+
assert.equal(gitSync(['show', `${result.tip_sha}:committed.txt`], sourceDir).stdout, 'committed work\n', 'the pre-existing commit remains reachable');
|
|
215
|
+
assert.equal(gitSync(['show', `${result.tip_sha}:dirty.txt`], sourceDir).stdout, 'dirty work\n', 'the WIP commit preserves dirty work');
|
|
216
|
+
assert.equal(gitSync(['show', '-s', '--format=%an <%ae>%n%s%n%(trailers:only)', result.tip_sha], sourceDir).stdout, `crtr-abandon <crtr-abandon@localhost>\ncrtr-abandon\nCrtr-Abandon: ${nodeId} ${meta?.managed_worktree?.abandoned?.at}\n\n`, 'the WIP commit has the reserved author, subject sentinel, and durable trailer');
|
|
217
|
+
assert.equal(existsSync(wt.path), false, 'the checkout is removed');
|
|
218
|
+
assert.equal(meta?.managed_worktree?.state, 'abandoned', 'the node records the abandoned disposition');
|
|
219
|
+
assert.deepEqual(meta?.managed_worktree?.abandoned, { branch: wt.branch, tip_sha: result.tip_sha, at: meta?.managed_worktree?.abandoned?.at, by: 'test-invoker' }, 'the metadata names the retained tip and invoker');
|
|
220
|
+
assert.equal(meta?.cwd, wt.repo_root, 'the node cwd repoints to the repository root');
|
|
221
|
+
await client.pushReport(nodeId, { tier: 'final', body: 'abandoned work preserved' });
|
|
222
|
+
assert.ok(getNode(nodeId)?.final_report, 'push final proceeds after abandon releases the worktree gate');
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
await server.close();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
196
228
|
// The actual #327 positive case: a branch with REAL commits whose tip is
|
|
197
229
|
// already contained in a base that moved forward past this worktree's
|
|
198
230
|
// original base_sha (a manager landed the same change another way, without
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { after, before, beforeEach, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createNode, getNode } from '../canvas/canvas.js';
|
|
7
|
+
import { closeDb } from '../canvas/db.js';
|
|
8
|
+
import { transition } from '../runtime/lifecycle.js';
|
|
9
|
+
let home;
|
|
10
|
+
function seed(nodeId) {
|
|
11
|
+
const node = {
|
|
12
|
+
node_id: nodeId,
|
|
13
|
+
name: nodeId,
|
|
14
|
+
created: new Date().toISOString(),
|
|
15
|
+
cwd: '/tmp/work',
|
|
16
|
+
kind: 'general',
|
|
17
|
+
mode: 'base',
|
|
18
|
+
lifecycle: 'terminal',
|
|
19
|
+
status: 'active',
|
|
20
|
+
};
|
|
21
|
+
createNode(node);
|
|
22
|
+
}
|
|
23
|
+
function reason(nodeId) {
|
|
24
|
+
return getNode(nodeId)?.terminal_reason;
|
|
25
|
+
}
|
|
26
|
+
before(() => {
|
|
27
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-lifecycle-reasons-'));
|
|
28
|
+
process.env['CRTR_HOME'] = home;
|
|
29
|
+
});
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
closeDb();
|
|
32
|
+
rmSync(home, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
after(() => {
|
|
35
|
+
closeDb();
|
|
36
|
+
rmSync(home, { recursive: true, force: true });
|
|
37
|
+
delete process.env['CRTR_HOME'];
|
|
38
|
+
});
|
|
39
|
+
test('transition records terminal reasons and clears them on terminal-to-live transitions', () => {
|
|
40
|
+
for (const [event, expected] of [
|
|
41
|
+
['finish', 'finished'],
|
|
42
|
+
['park', 'parked'],
|
|
43
|
+
['cancel', 'stranded'],
|
|
44
|
+
['crash', 'crashed'],
|
|
45
|
+
['yield', null],
|
|
46
|
+
['release', null],
|
|
47
|
+
]) {
|
|
48
|
+
const nodeId = `event-${event}`;
|
|
49
|
+
seed(nodeId);
|
|
50
|
+
transition(nodeId, event);
|
|
51
|
+
assert.equal(reason(nodeId), expected, `${event} records its terminal reason`);
|
|
52
|
+
}
|
|
53
|
+
seed('refresh-launch');
|
|
54
|
+
transition('refresh-launch', 'cancel');
|
|
55
|
+
transition('refresh-launch', 'refresh-launch');
|
|
56
|
+
assert.equal(reason('refresh-launch'), null, 'refresh-launch clears a terminal reason');
|
|
57
|
+
seed('revive');
|
|
58
|
+
transition('revive', 'finish');
|
|
59
|
+
transition('revive', 'revive');
|
|
60
|
+
assert.equal(reason('revive'), null, 'revive clears a terminal reason');
|
|
61
|
+
});
|
|
@@ -215,6 +215,43 @@ test('a fresh db has the four additive runtime columns', () => {
|
|
|
215
215
|
assert.equal(rt['window'], null);
|
|
216
216
|
assert.equal(rt['tmux_session'], null);
|
|
217
217
|
});
|
|
218
|
+
test('node lifecycle substrate migration adds six columns and backfills terminal reasons', () => {
|
|
219
|
+
ensureHome();
|
|
220
|
+
const raw = new DatabaseSync(canvasDbPath());
|
|
221
|
+
migrateToVersion(raw, 34);
|
|
222
|
+
const lifecycleColumns = [
|
|
223
|
+
'frozen_at',
|
|
224
|
+
'respawn_failures',
|
|
225
|
+
'respawn_not_before',
|
|
226
|
+
'launched_at',
|
|
227
|
+
'last_settled_at',
|
|
228
|
+
'terminal_reason',
|
|
229
|
+
];
|
|
230
|
+
for (const column of lifecycleColumns)
|
|
231
|
+
assert.ok(!nodeColumns(raw).includes(column));
|
|
232
|
+
const insert = raw.prepare('INSERT INTO nodes (node_id, name, kind, status, intent, final_report, cwd, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
|
233
|
+
const created = '2020-01-01T00:00:00Z';
|
|
234
|
+
insert.run('finalized', 'finalized', 'general', 'done', 'done', 'final.md', '/tmp', created);
|
|
235
|
+
insert.run('parked', 'parked', 'general', 'done', 'parked', null, '/tmp', created);
|
|
236
|
+
insert.run('finished', 'finished', 'general', 'done', 'done', null, '/tmp', created);
|
|
237
|
+
insert.run('stranded', 'stranded', 'general', 'canceled', null, null, '/tmp', created);
|
|
238
|
+
insert.run('crashed', 'crashed', 'general', 'dead', null, null, '/tmp', created);
|
|
239
|
+
insert.run('active', 'active', 'general', 'active', null, null, '/tmp', created);
|
|
240
|
+
raw.close();
|
|
241
|
+
const db = openDb();
|
|
242
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
243
|
+
for (const column of lifecycleColumns)
|
|
244
|
+
assert.ok(nodeColumns(db).includes(column));
|
|
245
|
+
const reasons = Object.fromEntries(db.prepare('SELECT node_id, terminal_reason FROM nodes ORDER BY node_id').all().map((row) => [row.node_id, row.terminal_reason]));
|
|
246
|
+
assert.deepEqual(reasons, {
|
|
247
|
+
active: null,
|
|
248
|
+
crashed: 'crashed',
|
|
249
|
+
finalized: 'finalized',
|
|
250
|
+
finished: 'finished',
|
|
251
|
+
parked: 'parked',
|
|
252
|
+
stranded: 'stranded',
|
|
253
|
+
});
|
|
254
|
+
});
|
|
218
255
|
// v4: edges FK ON DELETE CASCADE (the one irreversible rebuild)
|
|
219
256
|
/** Build a hand-rolled db at exactly v3 (baseline tables + the four runtime
|
|
220
257
|
* columns, edges with NO foreign key, `user_version = 3`) — the shape the live
|
|
@@ -337,7 +374,7 @@ test('v16 repairs a v15 database missing canonical finalization columns', () =>
|
|
|
337
374
|
ensureHome();
|
|
338
375
|
const db = new DatabaseSync(join(home, 'finalization.db'));
|
|
339
376
|
try {
|
|
340
|
-
|
|
377
|
+
migrateToVersion(db, 13);
|
|
341
378
|
db.exec('PRAGMA user_version = 15');
|
|
342
379
|
migrate(db);
|
|
343
380
|
const columns = nodeColumns(db);
|
|
@@ -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.throws(() => reviveNode(id, { resume: true }), /finaliz/i, 'reviveNode\'s own belt-and-suspenders check refuses a now-latched target');
|
|
182
|
+
assert.throws(() => 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
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|