@north-light/crouter 0.3.235 → 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 +14 -5
- 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 +399 -399
- package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
- package/dist/clients/inbox/review/roundtrip.js +1 -1
- package/dist/commands/__tests__/node-new.test.js +58 -0
- 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 +4 -1
- package/dist/commands/node/lifecycle.js +2 -2
- 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__/canvas-inbox-watcher-naming.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
- package/dist/core/__tests__/child-death-wake.test.js +38 -22
- package/dist/core/__tests__/cron-self-cancel-delivery.test.d.ts +1 -0
- 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__/helpers/harness.js +1 -0
- 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 +76 -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 +22 -3
- 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/launch-target.js +1 -1
- 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 +9 -13
- package/dist/core/runtime/spawn.js +40 -17
- 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 -4
- 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/dist/pi-extensions/canvas-inbox-watcher.js +12 -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 → commands/__tests__/node-new.test.d.ts} +0 -0
|
@@ -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 {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/revive-capacity.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The broker cap is a real admission gate, and `reviveNode` is where every
|
|
4
|
+
// launcher meets it. A caller declares what it wants when the ledger is full —
|
|
5
|
+
// `freeze` (its reason to run survives waiting) or `refuse` (it needs an engine
|
|
6
|
+
// now) — and this file pins the whole disposition matrix: the refusal names the
|
|
7
|
+
// cap and the live count instead of failing silently, the freeze marks the row
|
|
8
|
+
// once per episode without touching its session identity, an already-live node
|
|
9
|
+
// is admitted at the cap because it consumes no new slot, and the launch that
|
|
10
|
+
// finally happens retires the mark.
|
|
11
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { createNode, getNode } from '../canvas/canvas.js';
|
|
17
|
+
import { closeDb } from '../canvas/db.js';
|
|
18
|
+
import { bindDaemonEventSource, resetEventSourceForTests } from '../events/source.js';
|
|
19
|
+
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
20
|
+
import { headlessBrokerHost } from '../runtime/host.js';
|
|
21
|
+
import { reviveNode } from '../runtime/revive.js';
|
|
22
|
+
import { bindTestFleet } from './helpers/fleet.js';
|
|
23
|
+
let home;
|
|
24
|
+
let tf;
|
|
25
|
+
let seq = 0;
|
|
26
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
27
|
+
/** The real host contract, minus the process: register at the launch seam, so
|
|
28
|
+
* the ledger consumes the reservation exactly as production does. */
|
|
29
|
+
function stubLaunch() {
|
|
30
|
+
headlessBrokerHost.launch = (nodeId) => {
|
|
31
|
+
const handle = { pid: 500_000 + seq++, exited: new Promise(() => { }) };
|
|
32
|
+
tf.fleet.register(nodeId, handle);
|
|
33
|
+
return handle;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function node(id, over = {}) {
|
|
37
|
+
createNode({
|
|
38
|
+
node_id: id,
|
|
39
|
+
name: id,
|
|
40
|
+
created: new Date().toISOString(),
|
|
41
|
+
cwd: home,
|
|
42
|
+
kind: 'developer',
|
|
43
|
+
mode: 'base',
|
|
44
|
+
lifecycle: 'terminal',
|
|
45
|
+
status: 'active',
|
|
46
|
+
host_kind: 'broker',
|
|
47
|
+
...over,
|
|
48
|
+
});
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
/** Fill the ledger to the cap with live brokers. */
|
|
52
|
+
function saturate() {
|
|
53
|
+
reviveNode(node(`filler-a-${seq}`), { resume: false, capacity: 'refuse' });
|
|
54
|
+
reviveNode(node(`filler-b-${seq}`), { resume: false, capacity: 'refuse' });
|
|
55
|
+
assert.equal(tf.fleet.size(), 2, 'precondition: the ledger is at its cap of 2');
|
|
56
|
+
}
|
|
57
|
+
before(() => {
|
|
58
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-revive-capacity-'));
|
|
59
|
+
process.env['CRTR_HOME'] = home;
|
|
60
|
+
process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
|
|
61
|
+
resetEventSourceForTests();
|
|
62
|
+
bindDaemonEventSource();
|
|
63
|
+
});
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
tf = bindTestFleet();
|
|
66
|
+
stubLaunch();
|
|
67
|
+
});
|
|
68
|
+
after(() => {
|
|
69
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
70
|
+
tf.fleet.dispose();
|
|
71
|
+
resetFleetForTests();
|
|
72
|
+
resetEventSourceForTests();
|
|
73
|
+
closeDb();
|
|
74
|
+
delete process.env['CRTR_MAX_LIVE_BROKERS'];
|
|
75
|
+
delete process.env['CRTR_HOME'];
|
|
76
|
+
rmSync(home, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
test('a refusing caller gets an error naming the cap and the live count, never a silent no-op', () => {
|
|
79
|
+
saturate();
|
|
80
|
+
const turned = node('refused', { pi_session_id: 'sess-refused' });
|
|
81
|
+
assert.throws(() => reviveNode(turned, { resume: true, capacity: 'refuse' }), (err) => {
|
|
82
|
+
const details = err.details ?? {};
|
|
83
|
+
assert.equal(details['error'], 'broker_cap_reached', 'the refusal is a typed InputError, so it survives the /v1 error body');
|
|
84
|
+
assert.match(String(details['message']), /2 live brokers at the cap of 2/, 'the message names both the live count and the cap');
|
|
85
|
+
return true;
|
|
86
|
+
});
|
|
87
|
+
const meta = getNode(turned);
|
|
88
|
+
assert.equal(meta.frozen_at ?? null, null, 'a refusal is not a freeze — nothing waits for this row');
|
|
89
|
+
assert.equal(meta.pi_session_id, 'sess-refused', 'the refusal happens before any mutation of the session identity');
|
|
90
|
+
assert.equal(meta.cycles ?? 0, 0, 'a node turned away never spent a cycle');
|
|
91
|
+
});
|
|
92
|
+
test('a freezing caller gets a frozen row, marked once and left otherwise untouched', () => {
|
|
93
|
+
saturate();
|
|
94
|
+
const waiting = node('frozen', { pi_session_id: 'sess-frozen' });
|
|
95
|
+
const first = reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
96
|
+
assert.equal(first.outcome, 'frozen', 'the caller learns it got no engine');
|
|
97
|
+
const marked = getNode(waiting);
|
|
98
|
+
assert.ok(marked.frozen_at != null, 'the row carries the durable wait mark the thaw order reads');
|
|
99
|
+
assert.equal(marked.pi_pid ?? null, null, 'a frozen row holds no pid — every status surface reads it as frozen, not stale-active');
|
|
100
|
+
assert.equal(marked.cycles ?? 0, 0, 'a frozen node never spent a cycle');
|
|
101
|
+
const second = reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
102
|
+
assert.equal(second.outcome, 'frozen');
|
|
103
|
+
assert.equal(getNode(waiting).frozen_at, marked.frozen_at, 'freezing is once-per-episode: a second saturated tick keeps the original wait timestamp');
|
|
104
|
+
});
|
|
105
|
+
test('an already-live node is admitted at the cap — it consumes no new slot', () => {
|
|
106
|
+
const live = node('already-live');
|
|
107
|
+
reviveNode(live, { resume: false, capacity: 'refuse' });
|
|
108
|
+
reviveNode(node('already-live-filler'), { resume: false, capacity: 'refuse' });
|
|
109
|
+
assert.equal(tf.fleet.size(), 2, 'precondition: this node holds one of the two slots, and the ledger is full');
|
|
110
|
+
// Both dispositions agree: liveness is answered before capacity ever is.
|
|
111
|
+
assert.equal(reviveNode(live, { resume: true, capacity: 'refuse' }).outcome, 'already-live');
|
|
112
|
+
assert.equal(reviveNode(live, { resume: true, capacity: 'freeze' }).outcome, 'already-live');
|
|
113
|
+
assert.equal(getNode(live).frozen_at ?? null, null, 'a live node is never marked as waiting for a slot');
|
|
114
|
+
});
|
|
115
|
+
test('a freed slot lets the frozen row launch, and the launch retires the mark', async () => {
|
|
116
|
+
const filler = node('filler-exits');
|
|
117
|
+
reviveNode(filler, { resume: false, capacity: 'refuse' });
|
|
118
|
+
reviveNode(node('filler-stays'), { resume: false, capacity: 'refuse' });
|
|
119
|
+
const waiting = node('thaws', { pi_session_id: 'sess-thaws' });
|
|
120
|
+
assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
|
|
121
|
+
// A real exit frees the slot; its policy job runs on the lane like production.
|
|
122
|
+
tf.fleet.deliverExit(filler, { code: 0, signal: null });
|
|
123
|
+
await tf.drain();
|
|
124
|
+
assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'launched');
|
|
125
|
+
assert.equal(getNode(waiting).frozen_at ?? null, null, 'a launch is the definition of not-frozen');
|
|
126
|
+
assert.equal(tf.fleet.has(waiting), true, 'the thawed node owns the freed slot');
|
|
127
|
+
});
|
|
@@ -106,7 +106,7 @@ after(() => {
|
|
|
106
106
|
test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', () => {
|
|
107
107
|
parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
108
108
|
deliverMail('parked');
|
|
109
|
-
const result = reviveNode('parked', { resume: true });
|
|
109
|
+
const result = reviveNode('parked', { resume: true, capacity: 'freeze' });
|
|
110
110
|
assert.equal(result.resumed, false, 'the concluded transcript is not replayed');
|
|
111
111
|
assert.deepEqual(placements(), [{ nodeId: 'parked', resuming: false }]);
|
|
112
112
|
assert.equal(kickedOff(launches[0]), true, 'the fresh window is handed its bearings');
|
|
@@ -118,12 +118,12 @@ test('a parked node with mail waiting re-enters fresh even when the caller asked
|
|
|
118
118
|
test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', () => {
|
|
119
119
|
parkResident('goal-only', { goal: 'Watch the deploy lane.' });
|
|
120
120
|
deliverMail('goal-only');
|
|
121
|
-
assert.equal(reviveNode('goal-only', { resume: true }).resumed, false);
|
|
121
|
+
assert.equal(reviveNode('goal-only', { resume: true, capacity: 'freeze' }).resumed, false);
|
|
122
122
|
assert.equal(kickedOff(launches[0]), true);
|
|
123
123
|
});
|
|
124
124
|
test('an input-less revival of a parked node quietly resumes and injects no kickoff', () => {
|
|
125
125
|
parkResident('quiet', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
126
|
-
const result = reviveNode('quiet', { resume: true });
|
|
126
|
+
const result = reviveNode('quiet', { resume: true, capacity: 'freeze' });
|
|
127
127
|
assert.equal(result.resumed, true, 'nothing arrived, so there is nothing a fresh window would answer');
|
|
128
128
|
assert.deepEqual(placements(), [{ nodeId: 'quiet', resuming: true }]);
|
|
129
129
|
assert.equal(kickedOff(launches[0]), false, 'a quiet resume generates nothing');
|
|
@@ -135,20 +135,20 @@ test('an input-less revival of a parked node quietly resumes and injects no kick
|
|
|
135
135
|
test('deferred mail alone is not input, so the revival still resumes quietly', () => {
|
|
136
136
|
parkResident('deferred', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
137
137
|
deliverMail('deferred', 'deferred');
|
|
138
|
-
assert.equal(reviveNode('deferred', { resume: true }).resumed, true);
|
|
138
|
+
assert.equal(reviveNode('deferred', { resume: true, capacity: 'freeze' }).resumed, true);
|
|
139
139
|
assert.equal(kickedOff(launches[0]), false);
|
|
140
140
|
assert.equal(readCursor('deferred'), undefined, 'the deferred entry rides the next natural cycle, unconsumed');
|
|
141
141
|
});
|
|
142
142
|
test('mail already covered by the cursor is not input, so the revival still resumes quietly', () => {
|
|
143
143
|
parkResident('stale', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
144
144
|
writeCursor('stale', deliverMail('stale'));
|
|
145
|
-
assert.equal(reviveNode('stale', { resume: true }).resumed, true, 'only the UNSEEN suffix counts as input');
|
|
145
|
+
assert.equal(reviveNode('stale', { resume: true, capacity: 'freeze' }).resumed, true, 'only the UNSEEN suffix counts as input');
|
|
146
146
|
assert.equal(kickedOff(launches[0]), false);
|
|
147
147
|
});
|
|
148
148
|
test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', () => {
|
|
149
149
|
parkResident('bare');
|
|
150
150
|
deliverMail('bare');
|
|
151
|
-
const result = reviveNode('bare', { resume: true });
|
|
151
|
+
const result = reviveNode('bare', { resume: true, capacity: 'freeze' });
|
|
152
152
|
assert.equal(result.resumed, true, 'the degraded park has nothing on disk to ground a fresh window');
|
|
153
153
|
assert.deepEqual(placements(), [{ nodeId: 'bare', resuming: true }]);
|
|
154
154
|
});
|
|
@@ -156,5 +156,5 @@ test('an ordinary done node with a roadmap still resumes — only the park marke
|
|
|
156
156
|
parkResident('finished', { roadmap: '# Roadmap\n\n- one open item\n' });
|
|
157
157
|
transition('finished', 'revive');
|
|
158
158
|
transition('finished', 'finish');
|
|
159
|
-
assert.equal(reviveNode('finished', { resume: true }).resumed, true);
|
|
159
|
+
assert.equal(reviveNode('finished', { resume: true, capacity: 'freeze' }).resumed, true);
|
|
160
160
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Run with: npm run build && node --test dist/core/__tests__/seam/broker-cap-freeze.test.js
|
|
2
|
+
//
|
|
3
|
+
// The cap as the host actually meets it: real broker processes, the real
|
|
4
|
+
// ledger, and the real supervision tick. With CRTR_MAX_LIVE_BROKERS=2 a third
|
|
5
|
+
// birth gets no engine at all — the row exists, carries the durable wait mark,
|
|
6
|
+
// and holds no pid — and it survives repeated saturated ticks rather than being
|
|
7
|
+
// written off as a boot failure. When a live node finishes and gives its slot
|
|
8
|
+
// back, the next tick spends that slot on the waiting row and retires the mark.
|
|
9
|
+
//
|
|
10
|
+
// This is the whole point of P3, and none of it is observable without real
|
|
11
|
+
// processes: the freeze is decided at the launch seam and the thaw is decided
|
|
12
|
+
// by the daemon pass, so it belongs in the compiled seam tier.
|
|
13
|
+
import { after, before, test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { closeDb } from '../../canvas/db.js';
|
|
16
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
17
|
+
import { closeNode } from '../../runtime/close.js';
|
|
18
|
+
import { spawnChild } from '../../runtime/spawn.js';
|
|
19
|
+
import { createHarness } from '../helpers/harness.js';
|
|
20
|
+
let h;
|
|
21
|
+
let root;
|
|
22
|
+
before(async () => {
|
|
23
|
+
// Read per launch by brokerThresholdsForDaemon(), so setting it before the
|
|
24
|
+
// harness boots covers every launch this file drives.
|
|
25
|
+
process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
|
|
26
|
+
h = await createHarness({ headless: true, sessionPrefix: 'crtr-broker-cap' });
|
|
27
|
+
root = h.spawnRoot('broker cap suite root');
|
|
28
|
+
});
|
|
29
|
+
after(async () => {
|
|
30
|
+
if (h !== undefined)
|
|
31
|
+
await h.dispose();
|
|
32
|
+
delete process.env['CRTR_MAX_LIVE_BROKERS'];
|
|
33
|
+
});
|
|
34
|
+
test('a birth at the cap freezes instead of booting, survives saturated ticks, and thaws when a slot frees', { timeout: 120_000 }, async () => {
|
|
35
|
+
const first = await h.spawnHeadlessChild(root, 'first live child');
|
|
36
|
+
const second = await h.spawnHeadlessChild(root, 'second live child');
|
|
37
|
+
assert.equal(h.fleet.size(), 2, 'precondition: both slots are held by real brokers');
|
|
38
|
+
// The third birth meets a full ledger. spawnChild returns rather than
|
|
39
|
+
// crashing the row: a node that only has to wait is not a failed launch.
|
|
40
|
+
const born = await spawnChild({
|
|
41
|
+
kind: 'general',
|
|
42
|
+
cwd: h.node(first).cwd,
|
|
43
|
+
prompt: 'third child, born at the cap',
|
|
44
|
+
parent: root,
|
|
45
|
+
});
|
|
46
|
+
closeDb();
|
|
47
|
+
const third = born.node.node_id;
|
|
48
|
+
assert.equal(born.frozen, true, 'the birth reports that it got no engine');
|
|
49
|
+
const frozenRow = h.node(third);
|
|
50
|
+
assert.ok(frozenRow.frozen_at != null, 'the row carries the durable wait mark');
|
|
51
|
+
assert.equal(frozenRow.pi_pid ?? null, null, 'no pid was ever recorded — status surfaces read frozen, not stale-active');
|
|
52
|
+
assert.equal(h.fleet.has(third), false, 'no broker process exists for the frozen row');
|
|
53
|
+
assert.equal(h.bootCount(third), 0, 'nothing booted');
|
|
54
|
+
// Six saturated ticks: the cap is not a failure, so nothing is charged
|
|
55
|
+
// against the row and nothing terminalizes it.
|
|
56
|
+
for (let i = 0; i < 6; i++)
|
|
57
|
+
await h.tick();
|
|
58
|
+
closeDb();
|
|
59
|
+
const survived = h.node(third);
|
|
60
|
+
assert.equal(survived.status, 'active', 'six saturated ticks leave the row alive and still owed an engine');
|
|
61
|
+
assert.equal(survived.respawn_failures ?? 0, 0, 'waiting for a slot is not a respawn failure');
|
|
62
|
+
assert.equal(survived.frozen_at, frozenRow.frozen_at, 'the original wait timestamp survives — the thaw order reads it');
|
|
63
|
+
assert.equal(h.fleet.size(), 2, 'the two live brokers were never disturbed');
|
|
64
|
+
// Finish one node: its broker exits and gives the slot back.
|
|
65
|
+
const firstPid = h.node(first).pi_pid;
|
|
66
|
+
closeNode(first, { rootEvent: 'finish' });
|
|
67
|
+
closeDb();
|
|
68
|
+
await h.awaitFleetExit(first);
|
|
69
|
+
assert.equal(h.fleet.size(), 1, 'the finished node released its slot');
|
|
70
|
+
// The next tick spends the freed slot on the waiting row.
|
|
71
|
+
await h.tick();
|
|
72
|
+
closeDb();
|
|
73
|
+
await h.awaitBoot(third);
|
|
74
|
+
closeDb();
|
|
75
|
+
const thawed = h.node(third);
|
|
76
|
+
assert.equal(thawed.frozen_at ?? null, null, 'the launch retired the wait mark');
|
|
77
|
+
assert.ok(thawed.pi_pid != null && isPidAlive(thawed.pi_pid), 'the thawed node holds a live broker');
|
|
78
|
+
assert.equal(h.fleet.has(third), true, 'the freed slot is now the thawed node\u2019s');
|
|
79
|
+
assert.equal(h.fleet.has(second), true, 'the untouched live node kept its slot throughout');
|
|
80
|
+
assert.notEqual(thawed.pi_pid, firstPid, 'the thaw is a real new broker, not the finished node\u2019s pid lingering');
|
|
81
|
+
});
|