@north-light/crouter 0.3.241 → 0.3.243
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/dto/broker-ops.d.ts +8 -3
- package/dist/api/dto/broker.d.ts +4 -2
- package/dist/api/dto/inbox.d.ts +1 -0
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/api/dto/review-comments.d.ts +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/clients/attach/session/profile-files.js +4 -1
- package/dist/clients/attach/viewer.js +1 -1
- package/dist/commands/node/create.js +8 -3
- package/dist/commands/sys/context/admin/docs-panel.d.ts +17 -7
- package/dist/commands/sys/context/admin/docs-panel.js +43 -23
- package/dist/commands/sys/context/admin/list-view.d.ts +22 -11
- package/dist/commands/sys/context/admin/list-view.js +112 -53
- package/dist/commands/sys/context/admin/shell.d.ts +3 -0
- package/dist/commands/sys/context/admin/shell.js +11 -2
- package/dist/commands/sys/daemon.js +1 -1
- package/dist/core/__tests__/child-death-wake.test.js +0 -48
- package/dist/core/__tests__/daemon-boot.test.js +0 -8
- package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
- package/dist/core/__tests__/lifecycle.test.js +5 -5
- package/dist/core/__tests__/relaunch-root.test.js +91 -1
- package/dist/core/__tests__/revive-capacity.test.js +36 -1
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
- package/dist/core/canvas/canvas.d.ts +6 -5
- package/dist/core/canvas/canvas.js +8 -7
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/node-named.d.ts +1 -0
- package/dist/core/runtime/host.js +114 -103
- package/dist/core/runtime/lifecycle.js +3 -2
- package/dist/core/runtime/naming.d.ts +26 -8
- package/dist/core/runtime/naming.js +75 -43
- package/dist/core/runtime/reset.js +12 -5
- package/dist/core/runtime/revive-all.d.ts +4 -11
- package/dist/core/runtime/revive-all.js +6 -13
- package/dist/core/runtime/warm-pool.js +4 -4
- package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
- package/dist/daemon/api/handlers/broker-ops.js +14 -7
- package/dist/daemon/api/handlers/feedback-comments.js +1 -1
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/crtrd.js +17 -20
- package/dist/daemon/messaging/node-message.d.ts +1 -0
- package/dist/daemon/messaging/node-message.js +6 -2
- package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
- package/dist/daemon/review/comment-notify.d.ts +1 -0
- package/dist/daemon/review/comment-notify.js +1 -0
- package/dist/pi-extensions/broker-local.d.ts +1 -0
- package/dist/pi-extensions/broker-local.js +12 -7
- package/dist/pi-extensions/canvas-recap.js +1 -0
- package/dist/pi-extensions/canvas-stophook.js +2 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -4,78 +4,137 @@
|
|
|
4
4
|
import { fuzzyMatch } from '../../../../core/tui/fuzzy.js';
|
|
5
5
|
import { deliveredCost, rungCostsOf } from './model.js';
|
|
6
6
|
export const NO_FILTERS = { rung: 'any', origin: 'any', kind: 'any', state: 'any', query: '' };
|
|
7
|
-
/** Documents whose canonical name carries no `/`
|
|
8
|
-
* rather than becoming a crowd of one-member groups. */
|
|
7
|
+
/** Documents whose canonical name carries no `/` and owns no directory share
|
|
8
|
+
* one leading section rather than becoming a crowd of one-member groups. */
|
|
9
9
|
const LEADING_KEY = '';
|
|
10
10
|
const LEADING_LABEL = 'top level';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* re-plan removed instead of accumulating them. */
|
|
11
|
+
/** Every group key the rows can produce — one per proper name prefix, since a
|
|
12
|
+
* prefix exists only because something sits under it — so collapse state can
|
|
13
|
+
* drop the keys a re-plan removed instead of accumulating them. The leading
|
|
14
|
+
* section is a key only when a name with no `/` owns no directory, which is
|
|
15
|
+
* exactly when it is not itself one of those prefixes. */
|
|
17
16
|
export function groupKeysOf(rows) {
|
|
18
|
-
|
|
17
|
+
const keys = new Set();
|
|
18
|
+
const unslashed = [];
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
if (row.name.includes('/')) {
|
|
21
|
+
for (let cut = row.name.indexOf('/'); cut >= 0; cut = row.name.indexOf('/', cut + 1)) {
|
|
22
|
+
keys.add(row.name.slice(0, cut));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
unslashed.push(row.name);
|
|
27
|
+
}
|
|
28
|
+
if (unslashed.some((name) => !keys.has(name)))
|
|
29
|
+
keys.add(LEADING_KEY);
|
|
30
|
+
return keys;
|
|
19
31
|
}
|
|
20
|
-
/** The lines to draw, in corpus order: the leading section, then each
|
|
21
|
-
* order of first appearance
|
|
22
|
-
*
|
|
23
|
-
*
|
|
32
|
+
/** The lines to draw, in corpus order: the leading section, then each directory
|
|
33
|
+
* in order of first appearance, each recursing into its own members only when
|
|
34
|
+
* it is open. A live query force-expands every directory, which is exactly the
|
|
35
|
+
* ancestor chain of every surviving match, without touching `expanded` — so
|
|
36
|
+
* clearing the query restores what the user collapsed. */
|
|
24
37
|
export function buildListView(rows, filters, expanded, event) {
|
|
25
|
-
const
|
|
26
|
-
const order = [];
|
|
38
|
+
const root = { key: '', segment: '', depth: -1, children: new Map(), selves: [] };
|
|
27
39
|
for (const row of rows) {
|
|
28
40
|
if (!passes(row, row.records.get(event), filters))
|
|
29
41
|
continue;
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
let dir = root;
|
|
43
|
+
for (const segment of row.name.split('/')) {
|
|
44
|
+
let child = dir.children.get(segment);
|
|
45
|
+
if (child === undefined) {
|
|
46
|
+
child = {
|
|
47
|
+
key: dir.depth < 0 ? segment : `${dir.key}/${segment}`,
|
|
48
|
+
segment,
|
|
49
|
+
depth: dir.depth + 1,
|
|
50
|
+
children: new Map(),
|
|
51
|
+
selves: [],
|
|
52
|
+
};
|
|
53
|
+
dir.children.set(segment, child);
|
|
54
|
+
}
|
|
55
|
+
dir = child;
|
|
35
56
|
}
|
|
36
|
-
|
|
37
|
-
held.push(row);
|
|
57
|
+
dir.selves.push(row);
|
|
38
58
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
const leading = [];
|
|
60
|
+
const dirs = [];
|
|
61
|
+
for (const child of root.children.values()) {
|
|
62
|
+
if (child.children.size === 0)
|
|
63
|
+
leading.push(...child.selves);
|
|
64
|
+
else
|
|
65
|
+
dirs.push(child);
|
|
42
66
|
}
|
|
43
67
|
const lines = [];
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const rungs = { name: 0, preview: 0, content: 0 };
|
|
47
|
-
let cost = 0;
|
|
48
|
-
for (const row of members) {
|
|
49
|
-
const record = row.records.get(event);
|
|
50
|
-
if (record !== undefined && record.finalRung !== 'none')
|
|
51
|
-
rungs[record.finalRung] += 1;
|
|
52
|
-
cost += deliveredCost(record);
|
|
53
|
-
}
|
|
54
|
-
const open = filters.query !== '' || expanded.has(key);
|
|
68
|
+
if (leading.length > 0) {
|
|
69
|
+
const open = filters.query !== '' || expanded.has(LEADING_KEY);
|
|
55
70
|
lines.push({
|
|
56
71
|
kind: 'group',
|
|
57
|
-
key,
|
|
58
|
-
label:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
cost,
|
|
72
|
+
key: LEADING_KEY,
|
|
73
|
+
label: LEADING_LABEL,
|
|
74
|
+
depth: 0,
|
|
75
|
+
...rollup(leading, event),
|
|
62
76
|
expanded: open,
|
|
63
|
-
first:
|
|
77
|
+
first: leading[0],
|
|
78
|
+
isDoc: false,
|
|
64
79
|
});
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
lines.push({
|
|
69
|
-
kind: 'doc',
|
|
70
|
-
key: row.key,
|
|
71
|
-
row,
|
|
72
|
-
label: key === LEADING_KEY ? row.name : row.name.slice(key.length + 1),
|
|
73
|
-
cost: deliveredCost(row.records.get(event)),
|
|
74
|
-
});
|
|
75
|
-
}
|
|
80
|
+
if (open)
|
|
81
|
+
for (const row of leading)
|
|
82
|
+
lines.push(docLine(row, row.name, 1, event));
|
|
76
83
|
}
|
|
84
|
+
for (const dir of dirs)
|
|
85
|
+
emit(dir, filters, expanded, event, lines);
|
|
77
86
|
return lines;
|
|
78
87
|
}
|
|
88
|
+
/** A directory's rows in drawn order: its own document first, then each child
|
|
89
|
+
* segment's subtree — so the head of the list is also the document the header
|
|
90
|
+
* hands the dossier. */
|
|
91
|
+
function subtree(dir, out = []) {
|
|
92
|
+
out.push(...dir.selves);
|
|
93
|
+
for (const child of dir.children.values())
|
|
94
|
+
subtree(child, out);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function emit(dir, filters, expanded, event, lines) {
|
|
98
|
+
if (dir.children.size === 0) {
|
|
99
|
+
for (const row of dir.selves)
|
|
100
|
+
lines.push(docLine(row, dir.segment, dir.depth, event));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const members = subtree(dir);
|
|
104
|
+
const open = filters.query !== '' || expanded.has(dir.key);
|
|
105
|
+
lines.push({
|
|
106
|
+
kind: 'group',
|
|
107
|
+
key: dir.key,
|
|
108
|
+
label: `${dir.segment}/`,
|
|
109
|
+
depth: dir.depth,
|
|
110
|
+
...rollup(members, event),
|
|
111
|
+
expanded: open,
|
|
112
|
+
first: members[0],
|
|
113
|
+
isDoc: dir.selves.length > 0,
|
|
114
|
+
});
|
|
115
|
+
if (!open)
|
|
116
|
+
return;
|
|
117
|
+
// The header already stands for the first document named exactly this path;
|
|
118
|
+
// a second store answering the same name still needs its own row.
|
|
119
|
+
for (const shadowed of dir.selves.slice(1))
|
|
120
|
+
lines.push(docLine(shadowed, dir.segment, dir.depth + 1, event));
|
|
121
|
+
for (const child of dir.children.values())
|
|
122
|
+
emit(child, filters, expanded, event, lines);
|
|
123
|
+
}
|
|
124
|
+
function docLine(row, label, depth, event) {
|
|
125
|
+
return { kind: 'doc', key: row.key, row, label, depth, cost: deliveredCost(row.records.get(event)) };
|
|
126
|
+
}
|
|
127
|
+
function rollup(members, event) {
|
|
128
|
+
const rungs = { name: 0, preview: 0, content: 0 };
|
|
129
|
+
let cost = 0;
|
|
130
|
+
for (const row of members) {
|
|
131
|
+
const record = row.records.get(event);
|
|
132
|
+
if (record !== undefined && record.finalRung !== 'none')
|
|
133
|
+
rungs[record.finalRung] += 1;
|
|
134
|
+
cost += deliveredCost(record);
|
|
135
|
+
}
|
|
136
|
+
return { docs: members.length, rungs, cost };
|
|
137
|
+
}
|
|
79
138
|
/** What the whole filtered set costs, split by the rung it arrives at. */
|
|
80
139
|
export function costTotals(rows, event) {
|
|
81
140
|
const byRung = { name: 0, preview: 0, content: 0 };
|
|
@@ -90,6 +90,9 @@ export declare class ContextAdminShell implements Component, Focusable {
|
|
|
90
90
|
private handleModal;
|
|
91
91
|
private renderModal;
|
|
92
92
|
private footer;
|
|
93
|
+
/** What the focused row does when opened. A directory answers to space, a
|
|
94
|
+
* document to enter, and a directory that is also a document to both. */
|
|
95
|
+
private docsGesture;
|
|
93
96
|
render(width: number): string[];
|
|
94
97
|
/** What the delivering documents spend on this event. An estimate, and a
|
|
95
98
|
* floor on the rendered prompt: boot wraps these documents in its own intro
|
|
@@ -228,7 +228,9 @@ export class ContextAdminShell {
|
|
|
228
228
|
this.syncDetail();
|
|
229
229
|
}
|
|
230
230
|
else if (this.isDescend(data)) {
|
|
231
|
-
|
|
231
|
+
// A directory that is also a document keeps both gestures: space opens it,
|
|
232
|
+
// and descending goes to its dossier like any other document.
|
|
233
|
+
if (this.docs.onGroup && !this.docs.onDocument) {
|
|
232
234
|
this.docs.toggleGroup();
|
|
233
235
|
this.syncDetail();
|
|
234
236
|
}
|
|
@@ -648,7 +650,7 @@ export class ContextAdminShell {
|
|
|
648
650
|
const left = this.zone === 'rail'
|
|
649
651
|
? `${move} row · space change · enter documents`
|
|
650
652
|
: this.zone === 'docs'
|
|
651
|
-
? `${move} row ·
|
|
653
|
+
? `${move} row · ${this.docsGesture()} · / search`
|
|
652
654
|
: `${move} field · enter edit · e add · x remove · g gate · b body · u unlisted · s slash · m cap · r rename`;
|
|
653
655
|
const right = `${this.label('crtr.setup.tab-next', 'tab')} event · ${this.label('crtr.setup.cancel', 'q')} close`;
|
|
654
656
|
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
@@ -656,6 +658,13 @@ export class ContextAdminShell {
|
|
|
656
658
|
? `${theme.fg('dim', left)}${' '.repeat(gap)}${theme.fg('dim', right)}`
|
|
657
659
|
: theme.fg('dim', truncateToWidth(`${left} · ${this.label('crtr.setup.cancel', 'q')} close`, width));
|
|
658
660
|
}
|
|
661
|
+
/** What the focused row does when opened. A directory answers to space, a
|
|
662
|
+
* document to enter, and a directory that is also a document to both. */
|
|
663
|
+
docsGesture() {
|
|
664
|
+
if (!this.docs.onGroup)
|
|
665
|
+
return 'enter dossier';
|
|
666
|
+
return this.docs.onDocument ? 'space open · enter dossier' : 'space/enter open';
|
|
667
|
+
}
|
|
659
668
|
render(width) {
|
|
660
669
|
const height = this.terminalRows();
|
|
661
670
|
const bodyHeight = Math.max(1, height - PAGE_CHROME_ROWS);
|
|
@@ -80,7 +80,7 @@ const daemonRestart = defineLeaf({
|
|
|
80
80
|
effects: [
|
|
81
81
|
'The daemon acknowledges immediately, then after a short grace tears its whole broker fleet down, releases its claim, spawns a successor on the runtime generation currently selected, and exits.',
|
|
82
82
|
'Each torn-down broker hands its foreground bash commands to the file-backed background job system; they keep running and report completion by urgent inbox message.',
|
|
83
|
-
'Every node it tore down — including the caller — is
|
|
83
|
+
'Every node it tore down — including the caller — is resolved by the successor through the ordinary row-driven supervision tick.',
|
|
84
84
|
'The calling process is killed as part of that teardown, AFTER this result is returned.',
|
|
85
85
|
],
|
|
86
86
|
},
|
|
@@ -1,30 +1,4 @@
|
|
|
1
1
|
// Run with: node --import tsx/esm --test src/core/__tests__/child-death-wake.test.ts
|
|
2
|
-
//
|
|
3
|
-
// DOCTRINE WAKE — "a parent that delegated and went dormant is still woken when
|
|
4
|
-
// its child reaches a genuine terminal outcome, and is NEVER spuriously woken on
|
|
5
|
-
// healthy dormancy." Drives the REAL daemon decision pass (superviseTick) + the
|
|
6
|
-
// REAL closeNode against canvas rows fabricated DIRECTLY in an isolated home — NO
|
|
7
|
-
// real tmux session, NO broker boot.
|
|
8
|
-
//
|
|
9
|
-
// BROKER CUT (this worktree): the daemon's OLD parent-wake mechanism is GONE.
|
|
10
|
-
// U7 deleted surfaceChildDeath and the entire pane-gone reaping block (the
|
|
11
|
-
// crash/finalize/release routing that used to mark a pane-gone child dead/done/
|
|
12
|
-
// idle and fan a system inbox entry to its subscribers). Under exit-event
|
|
13
|
-
// authority a viewer pane/window closing is NOT a node death, and the tick runs
|
|
14
|
-
// no liveness pass at all — a booted child's death is observed only through its
|
|
15
|
-
// broker's real exit event (fleet exit-policy job), which recovery-revives it
|
|
16
|
-
// on its saved session rather than surfacing it as dead. The doctrine wake
|
|
17
|
-
// therefore RELOCATED off the daemon's tick:
|
|
18
|
-
// • child finishes → `crtr push final` wakes subscribers via the PUSH itself;
|
|
19
|
-
// • child crashes → its exit event drives a recovery revive (it comes back
|
|
20
|
-
// and pushes later);
|
|
21
|
-
// • child never boots → the exit event terminalizes it and surfaceBootFailure
|
|
22
|
-
// pushes urgent (daemon-boot.test);
|
|
23
|
-
// • child `node close`d → close.ts fans the child-closed wake (tested below).
|
|
24
|
-
// So the daemon now fans NO liveness wake at all, which makes the old CRUX
|
|
25
|
-
// (healthy dormancy must not wake the parent) hold by construction. This file
|
|
26
|
-
// locks in (a) the new pid-only non-reaping + no-daemon-wake contract, and (b)
|
|
27
|
-
// the one genuine-terminal wake that still lives in core: close.ts.
|
|
28
2
|
import { test, before, after, beforeEach } from 'node:test';
|
|
29
3
|
import assert from 'node:assert/strict';
|
|
30
4
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
@@ -112,15 +86,6 @@ after(() => {
|
|
|
112
86
|
delete process.env['CRTR_HOME'];
|
|
113
87
|
resetFleetForTests();
|
|
114
88
|
});
|
|
115
|
-
// BROKER CUT — exit-event authority REPLACES daemon pane-gone reaping. A booted
|
|
116
|
-
// child whose engine pid is dead (and whose viewer pane is gone) is RECOVERED,
|
|
117
|
-
// not reaped, and the daemon's tick fans NO wake to the dormant parent. This
|
|
118
|
-
// single case subsumes the four deleted scenarios (crash / quiet-finalize /
|
|
119
|
-
// awaiting a live grandchild / pending self-wake): with pane state ignored and
|
|
120
|
-
// surfaceChildDeath gone, the daemon treats them all identically — no reap, no
|
|
121
|
-
// liveness wake — so the old CRUX (no spurious wake on healthy dormancy) holds
|
|
122
|
-
// by construction. The node-lifecycle tick relaunches the row through the same
|
|
123
|
-
// policy table its exit event would have used (D-12); that recovery is silent.
|
|
124
89
|
test('exit-event authority: a dead-engine booted child is recovered, NOT reaped, and the tick fans NO wake to its dormant parent', async () => {
|
|
125
90
|
const originalLaunch = headlessBrokerHost.launch;
|
|
126
91
|
const launches = [];
|
|
@@ -139,9 +104,6 @@ test('exit-event authority: a dead-engine booted child is recovered, NOT reaped,
|
|
|
139
104
|
// its saved session, never reaped to dead/done/idle.
|
|
140
105
|
assert.deepEqual(launches, ['CHILD'], 'the tick recovers the dead engine through the policy table');
|
|
141
106
|
assert.equal(getNode('CHILD').status, 'active', 'CHILD is recovered, NOT reaped');
|
|
142
|
-
// surfaceChildDeath is deleted: the daemon raises no liveness wake. The
|
|
143
|
-
// doctrine wake now rides the child's own `push final` (or close.ts), so a
|
|
144
|
-
// purely-inbox-waiting parent is never spuriously woken on dormancy/crash.
|
|
145
107
|
assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — no daemon liveness wake');
|
|
146
108
|
}
|
|
147
109
|
finally {
|
|
@@ -163,13 +125,3 @@ test('node close of a child wakes its SURVIVING manager (the parent outside the
|
|
|
163
125
|
assert.ok(wake, 'node close fanned a child-closed entry to the surviving manager (D-1: previously none)');
|
|
164
126
|
assert.match(wake.label, /closed/i, 'the entry names the closed child');
|
|
165
127
|
});
|
|
166
|
-
// NOTE (broker cut): the two NEGATIVE "CRUX" tests that used to live here — a
|
|
167
|
-
// child dormant while awaiting a live grandchild, and a child dormant on a
|
|
168
|
-
// pending self-wake — asserted the daemon's pane-gone routing chose idle-release
|
|
169
|
-
// (status 'idle', intent 'idle-release') over finalize, so it would not spuriously
|
|
170
|
-
// wake the parent. U7 DELETED that routing entirely: pane state is ignored, the
|
|
171
|
-
// daemon never finalizes/releases on liveness, and surfaceChildDeath is gone, so
|
|
172
|
-
// the daemon raises no liveness wake at all. The no-spurious-wake invariant they
|
|
173
|
-
// protected now holds by construction and is covered by the pid-only test above
|
|
174
|
-
// (PARENT inbox stays empty). Removed rather than adapted because the
|
|
175
|
-
// idle-release-vs-finalize distinction they asserted no longer exists.
|
|
@@ -93,12 +93,6 @@ test('never-booted broker exit is marked dead and surfaced to the parent as urge
|
|
|
93
93
|
assert.ok(wake, 'the doctrine wake rides beside the urgent push');
|
|
94
94
|
assert.match(wake.label, /crashed/i);
|
|
95
95
|
});
|
|
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
96
|
test('a crashed booted child is relaunched on its saved session, not reaped, and raises no false boot-failure alarm', async () => {
|
|
103
97
|
const originalLaunch = headlessBrokerHost.launch;
|
|
104
98
|
const launches = [];
|
|
@@ -123,8 +117,6 @@ test('a crashed booted child is relaunched on its saved session, not reaped, and
|
|
|
123
117
|
await superviseTick();
|
|
124
118
|
assert.deepEqual(launches, ['C2'], 'the tick recovers the crash through the policy table, exactly as the exit event would');
|
|
125
119
|
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
120
|
assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
|
|
129
121
|
clearBusy('C2');
|
|
130
122
|
}
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The invariant-5 policy table (design flow B): `classifyDeadNode` is the ONE
|
|
4
4
|
// pure function mapping a known-dead node's durable row state to its recovery
|
|
5
|
-
// action, used
|
|
6
|
-
// recovery sweep — so exit recovery and startup recovery can never diverge.
|
|
5
|
+
// action, used by the exit-policy job and the row-driven supervision tick.
|
|
7
6
|
// This file pins every table row directly.
|
|
8
7
|
import { test } from 'node:test';
|
|
9
8
|
import assert from 'node:assert/strict';
|
|
@@ -44,7 +44,7 @@ import { tmpdir } from 'node:os';
|
|
|
44
44
|
import { join } from 'node:path';
|
|
45
45
|
import { spawnSync } from 'node:child_process';
|
|
46
46
|
import { DatabaseSync } from 'node:sqlite';
|
|
47
|
-
import { createNode, deleteNode, getNode, setStatus, withFreshTerminalGuard } from '../../canvas/canvas.js';
|
|
47
|
+
import { createNode, deleteNode, getNode, setFrozen, setStatus, withFreshTerminalGuard } from '../../canvas/canvas.js';
|
|
48
48
|
import { hasNoNaturalCycle } from '../../runtime/revive-all.js';
|
|
49
49
|
import { canvasDbPath, nodeDir, apiSocketPath } from '../../canvas/paths.js';
|
|
50
50
|
import { closeDb } from '../../canvas/db.js';
|
|
@@ -170,7 +170,7 @@ test('node message send --tier deferred to an ordinary dormant (idle-released) t
|
|
|
170
170
|
assert.equal(after.status, 'idle', 'target left exactly where it was');
|
|
171
171
|
assert.equal(after.intent, 'idle-release', 'target left exactly where it was');
|
|
172
172
|
});
|
|
173
|
-
test('node message send --tier deferred
|
|
173
|
+
test('node message send --tier deferred rejects ordinary terminal targets but accepts a frozen terminal target with a thaw cycle owed', { timeout: 30_000 }, async () => {
|
|
174
174
|
for (const status of ['done', 'canceled']) {
|
|
175
175
|
const id = `terminal-${status}`;
|
|
176
176
|
createNode(node(id, { status, intent: null }));
|
|
@@ -190,6 +190,18 @@ test('node message send --tier deferred to a terminal target (done/canceled —
|
|
|
190
190
|
assert.equal(launchCalls, 0, `${status}: a rejected deferred must never attempt a revive`);
|
|
191
191
|
assert.equal(readInboxSince(id, undefined).length, inboxBefore, `${status}: a rejected deferred must not append an inbox entry`);
|
|
192
192
|
}
|
|
193
|
+
const frozenId = 'terminal-frozen';
|
|
194
|
+
createNode(node(frozenId, {
|
|
195
|
+
lifecycle: 'resident',
|
|
196
|
+
status: 'done',
|
|
197
|
+
intent: 'parked',
|
|
198
|
+
}));
|
|
199
|
+
setFrozen(frozenId, new Date().toISOString());
|
|
200
|
+
const inboxBefore = readInboxSince(frozenId, undefined).length;
|
|
201
|
+
const result = await messageSendLeaf().run({ to: frozenId, tier: 'deferred', body: 'held for thaw' });
|
|
202
|
+
assert.equal(result['guidance'], "Delivered — held for the target's next natural cycle; not revived.", 'a frozen terminal row still has a thaw cycle');
|
|
203
|
+
assert.equal(result['revived'], false, 'deferred mail waits for the thaw without launching');
|
|
204
|
+
assert.equal(readInboxSince(frozenId, undefined).length, inboxBefore + 1, 'the deferred message is durably appended');
|
|
193
205
|
});
|
|
194
206
|
// #343 third-fix-cycle review: the IMMEDIATE `--tier deferred` path had its
|
|
195
207
|
// own, separate TOCTOU — a stale-snapshot check (`rejectDeferredWithoutNaturalCycle`)
|
|
@@ -133,17 +133,17 @@ test('yield: illegal from done|dead|canceled → throws', () => {
|
|
|
133
133
|
assert.throws(() => transition(`n_${from}`, 'yield'), /illegal lifecycle transition/);
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
|
-
// release → idle + intent='idle-release', legal
|
|
137
|
-
test('release: active|idle → idle + intent=idle-release', () => {
|
|
138
|
-
for (const from of LIVE) {
|
|
136
|
+
// release → idle + intent='idle-release', legal from active|idle|done
|
|
137
|
+
test('release: active|idle|done → idle + intent=idle-release', () => {
|
|
138
|
+
for (const from of [...LIVE, 'done']) {
|
|
139
139
|
mk(`n_${from}`, from);
|
|
140
140
|
const m = transition(`n_${from}`, 'release');
|
|
141
141
|
assert.equal(m.status, 'idle', `release from ${from}`);
|
|
142
142
|
assert.equal(m.intent, 'idle-release', `release from ${from} sets intent=idle-release`);
|
|
143
143
|
}
|
|
144
144
|
});
|
|
145
|
-
test('release: illegal from
|
|
146
|
-
for (const from of
|
|
145
|
+
test('release: illegal from dead|canceled → throws', () => {
|
|
146
|
+
for (const from of ['dead', 'canceled']) {
|
|
147
147
|
mk(`n_${from}`, from);
|
|
148
148
|
assert.throws(() => transition(`n_${from}`, 'release'), /illegal lifecycle transition/);
|
|
149
149
|
}
|
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
// behavior via the injectable deps seam, so they run tmux-free and broker-free.
|
|
9
9
|
import { test, before, after, beforeEach } from 'node:test';
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { CrtrClient } from '../../api/client.js';
|
|
15
|
+
import { createApiServer } from '../../daemon/api/server.js';
|
|
16
|
+
import registerCanvasStophook from '../../pi-extensions/canvas-stophook.js';
|
|
14
17
|
import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
15
18
|
import { closeDb } from '../canvas/db.js';
|
|
19
|
+
import { apiSocketPath } from '../canvas/paths.js';
|
|
20
|
+
import { brokerCapReached, resetFleetForTests } from '../runtime/fleet.js';
|
|
16
21
|
import { relaunchRoot } from '../runtime/reset.js';
|
|
22
|
+
import { bindTestFleet } from './helpers/fleet.js';
|
|
17
23
|
let home;
|
|
18
24
|
function node(id, over = {}) {
|
|
19
25
|
return {
|
|
@@ -31,6 +37,20 @@ function node(id, over = {}) {
|
|
|
31
37
|
/** A tmux-free / broker-free deps seam. `pid` controls the new broker launch:
|
|
32
38
|
* a number = success, null = a boot failure. Records the node ids launchBroker
|
|
33
39
|
* was called with, so the boot-failure test can find the half-born new node. */
|
|
40
|
+
async function waitForApi() {
|
|
41
|
+
const client = CrtrClient.forLocalSocket({ autostart: false });
|
|
42
|
+
for (let i = 0; i < 200; i++) {
|
|
43
|
+
if (existsSync(apiSocketPath())) {
|
|
44
|
+
try {
|
|
45
|
+
await client.healthz();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
catch { /* the socket can appear before it accepts */ }
|
|
49
|
+
}
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
51
|
+
}
|
|
52
|
+
throw new Error('API socket did not become ready');
|
|
53
|
+
}
|
|
34
54
|
function makeDeps(pid) {
|
|
35
55
|
const launched = [];
|
|
36
56
|
const deps = {
|
|
@@ -102,6 +122,76 @@ test('relaunchRoot on a boot failure leaves the old root + descendants fully int
|
|
|
102
122
|
assert.equal(launched.length, 1, 'one broker launch was attempted');
|
|
103
123
|
assert.equal(getNode(launched[0])?.status, 'dead', 'the half-born new node is crashed (dead)');
|
|
104
124
|
});
|
|
125
|
+
test('relaunchRoot at capacity refuses visibly and settles the half-born row before preserving the old root', async () => {
|
|
126
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
127
|
+
createNode(node('child', { parent: 'root' }));
|
|
128
|
+
subscribe('root', 'child', true);
|
|
129
|
+
const launched = [];
|
|
130
|
+
const deps = {
|
|
131
|
+
launchBroker: (nodeId) => {
|
|
132
|
+
launched.push(nodeId);
|
|
133
|
+
throw brokerCapReached(2, 2);
|
|
134
|
+
},
|
|
135
|
+
waitForViewSocket: () => true,
|
|
136
|
+
respawnViewer: () => true,
|
|
137
|
+
teardownBroker: () => { },
|
|
138
|
+
};
|
|
139
|
+
await assert.rejects(() => relaunchRoot('root', deps), (error) => {
|
|
140
|
+
const details = error.details ?? {};
|
|
141
|
+
assert.equal(details['error'], 'broker_cap_reached');
|
|
142
|
+
assert.match(String(details['message']), /2 live brokers at the cap of 2/);
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
assert.equal(getNode('root')?.status, 'active', 'the old root remains live');
|
|
146
|
+
assert.equal(getNode('child')?.status, 'active', 'its descendants remain live');
|
|
147
|
+
assert.equal(launched.length, 1);
|
|
148
|
+
assert.equal(getNode(launched[0])?.status, 'dead', 'the half-born replacement is terminal, not an active pid-less strand');
|
|
149
|
+
});
|
|
150
|
+
test('root /new capacity refusal reaches the attached human through broker UI notify', async () => {
|
|
151
|
+
process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
|
|
152
|
+
process.env['CRTR_NODE_ID'] = 'root';
|
|
153
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
154
|
+
const tf = bindTestFleet();
|
|
155
|
+
const never = new Promise(() => { });
|
|
156
|
+
tf.fleet.register('filler-a', { pid: 700_001, exited: never });
|
|
157
|
+
tf.fleet.register('filler-b', { pid: 700_002, exited: never });
|
|
158
|
+
const server = createApiServer();
|
|
159
|
+
try {
|
|
160
|
+
await waitForApi();
|
|
161
|
+
let sessionStart;
|
|
162
|
+
registerCanvasStophook({
|
|
163
|
+
on(event, handler) {
|
|
164
|
+
if (event === 'session_start')
|
|
165
|
+
sessionStart = async (ev, ctx) => { await handler(ev, ctx); };
|
|
166
|
+
},
|
|
167
|
+
sendUserMessage() { },
|
|
168
|
+
sendMessage() { },
|
|
169
|
+
});
|
|
170
|
+
let resolveNotice;
|
|
171
|
+
const notice = new Promise((resolve) => { resolveNotice = resolve; });
|
|
172
|
+
await sessionStart?.({ reason: 'new' }, {
|
|
173
|
+
sessionManager: {
|
|
174
|
+
getSessionId: () => 'root-new-session',
|
|
175
|
+
getSessionFile: () => null,
|
|
176
|
+
getEntries: () => [],
|
|
177
|
+
},
|
|
178
|
+
ui: { notify: (message) => resolveNotice(message) },
|
|
179
|
+
});
|
|
180
|
+
const message = await Promise.race([
|
|
181
|
+
notice,
|
|
182
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('capacity notice was not shown')), 1_000)),
|
|
183
|
+
]);
|
|
184
|
+
assert.match(message, /^\/new refused: no broker slot is free/);
|
|
185
|
+
assert.match(message, /2 live brokers at the cap of 2/);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await server.close();
|
|
189
|
+
tf.fleet.dispose();
|
|
190
|
+
resetFleetForTests();
|
|
191
|
+
delete process.env['CRTR_MAX_LIVE_BROKERS'];
|
|
192
|
+
delete process.env['CRTR_NODE_ID'];
|
|
193
|
+
}
|
|
194
|
+
});
|
|
105
195
|
test('relaunchRoot is a no-op for a non-root child and an already-parked root', async () => {
|
|
106
196
|
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
107
197
|
createNode(node('child', { parent: 'root' }));
|
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
// finally happens retires the mark.
|
|
11
11
|
import { test, before, beforeEach, after } from 'node:test';
|
|
12
12
|
import assert from 'node:assert/strict';
|
|
13
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { createNode, getNode } from '../canvas/canvas.js';
|
|
17
|
+
import { jobDir } from '../canvas/paths.js';
|
|
17
18
|
import { closeDb } from '../canvas/db.js';
|
|
18
19
|
import { bindDaemonEventSource, resetEventSourceForTests } from '../events/source.js';
|
|
19
20
|
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
21
|
+
import { CANVAS_EXTENSIONS } from '../runtime/canvas-extensions.js';
|
|
20
22
|
import { headlessBrokerHost } from '../runtime/host.js';
|
|
21
23
|
import { reviveNode } from '../runtime/revive.js';
|
|
24
|
+
import { deliverNodeMessage } from '../../daemon/messaging/node-message.js';
|
|
22
25
|
import { bindTestFleet } from './helpers/fleet.js';
|
|
23
26
|
let home;
|
|
24
27
|
let tf;
|
|
@@ -89,6 +92,24 @@ test('a refusing caller gets an error naming the cap and the live count, never a
|
|
|
89
92
|
assert.equal(meta.pi_session_id, 'sess-refused', 'the refusal happens before any mutation of the session identity');
|
|
90
93
|
assert.equal(meta.cycles ?? 0, 0, 'a node turned away never spent a cycle');
|
|
91
94
|
});
|
|
95
|
+
test('a pre-spawn launch failure releases the reservation', () => {
|
|
96
|
+
const id = node('pre-spawn-failure');
|
|
97
|
+
mkdirSync(join(jobDir(id), 'broker.log'));
|
|
98
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
99
|
+
try {
|
|
100
|
+
assert.throws(() => headlessBrokerHost.launch(id, {
|
|
101
|
+
argv: CANVAS_EXTENSIONS.flatMap((path) => ['-e', path]),
|
|
102
|
+
env: {},
|
|
103
|
+
}, { cwd: home, name: id, resuming: false }), /EISDIR/);
|
|
104
|
+
assert.equal(tf.fleet.occupancy(), 0, 'the failed launch leaves no slot reserved');
|
|
105
|
+
assert.equal(tf.fleet.reserveSlot(id), true, 'the same node can claim the released slot immediately');
|
|
106
|
+
tf.fleet.releaseReservation(id);
|
|
107
|
+
assert.equal(tf.fleet.occupancy(), 0);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
stubLaunch();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
92
113
|
test('a freezing caller gets a frozen row, marked once and left otherwise untouched', () => {
|
|
93
114
|
saturate();
|
|
94
115
|
const waiting = node('frozen', { pi_session_id: 'sess-frozen' });
|
|
@@ -102,6 +123,20 @@ test('a freezing caller gets a frozen row, marked once and left otherwise untouc
|
|
|
102
123
|
assert.equal(second.outcome, 'frozen');
|
|
103
124
|
assert.equal(getNode(waiting).frozen_at, marked.frozen_at, 'freezing is once-per-episode: a second saturated tick keeps the original wait timestamp');
|
|
104
125
|
});
|
|
126
|
+
test('a durable daemon message reports capacity_frozen when its wake cannot launch', async () => {
|
|
127
|
+
saturate();
|
|
128
|
+
const waiting = node('message-frozen');
|
|
129
|
+
const delivery = await deliverNodeMessage({
|
|
130
|
+
node_id: waiting,
|
|
131
|
+
body: 'capacity-delayed notice',
|
|
132
|
+
label: 'notice',
|
|
133
|
+
mode: 'wake',
|
|
134
|
+
});
|
|
135
|
+
assert.equal(delivery.via, 'inbox');
|
|
136
|
+
assert.ok(delivery.entry_id !== null, 'the message stays durable while the broker waits');
|
|
137
|
+
assert.equal(delivery.revived, false);
|
|
138
|
+
assert.equal(delivery.reason, 'capacity_frozen');
|
|
139
|
+
});
|
|
105
140
|
test('an already-live node is admitted at the cap — it consumes no new slot', () => {
|
|
106
141
|
const live = node('already-live');
|
|
107
142
|
reviveNode(live, { resume: false, capacity: 'refuse' });
|