@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
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// Session naming — turn a node's first prompt into a short, human-readable
|
|
2
|
-
//
|
|
2
|
+
// label.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// One call produces BOTH forms a surface needs, so no consumer has to un-mangle
|
|
5
|
+
// the other's format: `name` is the 3-8 word kebab-case handle that sits in a
|
|
6
|
+
// node's editor label (`<mode-icon> <name> <kind>`, see editorLabel in
|
|
7
|
+
// labels.ts) and a tmux window name, `title` is the prose sentence a person
|
|
8
|
+
// reads in a conversation list, punctuation intact.
|
|
8
9
|
//
|
|
9
|
-
// The namer returns a STRUCTURED result — {name, icon} — produced by
|
|
10
|
-
// sampling through the one-shot `name_session` tool, not by parsing
|
|
11
|
-
// icon is a Nerd Font glyph the model picks freely
|
|
10
|
+
// The namer returns a STRUCTURED result — {name, title, icon} — produced by
|
|
11
|
+
// constrained sampling through the one-shot `name_session` tool, not by parsing
|
|
12
|
+
// prose. The icon is a Nerd Font glyph the model picks freely
|
|
12
13
|
// (guided by example blocks, not a closed enum) and is stored as its own meta
|
|
13
14
|
// field, so each surface decides whether to render it.
|
|
14
15
|
//
|
|
@@ -47,13 +48,19 @@ const ICON_EXAMPLES = [
|
|
|
47
48
|
'interface/design: U+F1FC paintbrush, U+F03E image, U+F108 display',
|
|
48
49
|
'communication/coordination: U+F1D8 paper plane, U+F0E6 comments, U+F0E8 sitemap, U+F0C1 link',
|
|
49
50
|
].join('\n');
|
|
50
|
-
const NAME_SYSTEM_PROMPT = 'You
|
|
51
|
-
'
|
|
52
|
-
'
|
|
51
|
+
const NAME_SYSTEM_PROMPT = 'You label agent sessions. A session is whatever a person opened it for: a coding task, ' +
|
|
52
|
+
'a question, a piece of research, an idea they are turning over, or an ordinary ' +
|
|
53
|
+
'conversation. You are labelling that session, never taking part in it — do not answer ' +
|
|
54
|
+
'the message, do not do the work it asks for, do not comment on it. Every session gets a ' +
|
|
55
|
+
'label, however short, casual, misspelled, or open-ended its opening message is.\n\n' +
|
|
53
56
|
'Call the `name_session` tool exactly once with:\n' +
|
|
54
|
-
'- `
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
+
'- `title`: how a person would refer to this session in a list of their conversations — ' +
|
|
58
|
+
'2-8 words, sentence case, written naturally with its punctuation intact (e.g. ' +
|
|
59
|
+
"`What's your favorite color?`, `Payroll export mapping bug`, `Ideas for the launch " +
|
|
60
|
+
'email`). No quotes, no markdown, no trailing period.\n' +
|
|
61
|
+
'- `name`: the same subject as a concise 3-8 word kebab-case handle — lowercase words ' +
|
|
62
|
+
'joined by single hyphens (e.g. `favorite-color-question`, `refactor-auth-token-flow`). ' +
|
|
63
|
+
'No punctuation, quotes, or prose.\n' +
|
|
57
64
|
'- `icon`: ONE Nerd Font glyph, written as its codepoint in `U+XXXX` form (e.g. ' +
|
|
58
65
|
'`U+F188`). Emit a codepoint, not a name like `nf-fa-bug`, and not an emoji.\n\n' +
|
|
59
66
|
'Choosing the icon — work through it in this order:\n' +
|
|
@@ -76,14 +83,11 @@ const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. The name is a l
|
|
|
76
83
|
* text for the instruction. The source is capped first, so the closing tag is
|
|
77
84
|
* always present. */
|
|
78
85
|
function nameUserPrompt(source, sourceCap = PROMPT_CAP) {
|
|
79
|
-
return `<source>\n${source.slice(0, sourceCap)}\n</source>\n\
|
|
86
|
+
return `<source>\n${source.slice(0, sourceCap)}\n</source>\n\nLabel the session this text is from: call \`name_session\` once with a title, a name, and an icon depicting it, then stop. Do not answer it.`;
|
|
80
87
|
}
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'is', 'are', 'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'into',
|
|
85
|
-
'please', 'can', 'you', 'i', 'we', 'my', 'our', 'me', 'so', 'then',
|
|
86
|
-
]);
|
|
88
|
+
/** Cap on a generated title — room for a full sentence-case phrase, short
|
|
89
|
+
* enough that a conversation list stays scannable. */
|
|
90
|
+
const TITLE_CAP = 72;
|
|
87
91
|
/** Coerce arbitrary text into a 3-8 word kebab-case name, or '' if nothing
|
|
88
92
|
* usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
|
|
89
93
|
* single hyphen, and clamps to the first 8 words. */
|
|
@@ -96,17 +100,34 @@ export function sanitizeSessionName(raw) {
|
|
|
96
100
|
.filter((w) => w !== '');
|
|
97
101
|
return words.slice(0, 8).join('-');
|
|
98
102
|
}
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
103
|
+
/** Coerce arbitrary text into a one-line prose title, or '' if nothing usable
|
|
104
|
+
* survives. Takes the first non-empty line, collapses runs of whitespace, drops
|
|
105
|
+
* wrapping quotes, clamps at a word boundary, and capitalizes the opening
|
|
106
|
+
* letter. Punctuation INSIDE the line survives — carrying it is what `title`
|
|
107
|
+
* is for. */
|
|
108
|
+
export function sanitizeSessionTitle(raw) {
|
|
109
|
+
const line = (raw ?? '').split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
|
|
110
|
+
const collapsed = line.replace(/\s+/g, ' ').replace(/^["'`]+|["'`]+$/g, '').trim();
|
|
111
|
+
if (collapsed === '')
|
|
112
|
+
return '';
|
|
113
|
+
let clamped = collapsed;
|
|
114
|
+
if (collapsed.length > TITLE_CAP) {
|
|
115
|
+
const cut = collapsed.slice(0, TITLE_CAP);
|
|
116
|
+
const boundary = cut.lastIndexOf(' ');
|
|
117
|
+
clamped = `${(boundary > TITLE_CAP / 2 ? cut.slice(0, boundary) : cut).trimEnd()}\u2026`;
|
|
118
|
+
}
|
|
119
|
+
return clamped.charAt(0).toUpperCase() + clamped.slice(1);
|
|
120
|
+
}
|
|
121
|
+
/** Local fallback: the opening of the prompt as a title (no pi call), with its
|
|
122
|
+
* punctuation. Whitespace collapses first so a multi-line message contributes
|
|
123
|
+
* its actual opening words rather than whatever its first line happened to be. */
|
|
124
|
+
export function titleFromPrompt(prompt) {
|
|
125
|
+
return sanitizeSessionTitle((prompt ?? '').replace(/\s+/g, ' '));
|
|
126
|
+
}
|
|
127
|
+
/** Local fallback: derive a kebab handle straight from the prompt (no pi call),
|
|
128
|
+
* from the same opening words the fallback title keeps. */
|
|
101
129
|
export function slugFromPrompt(prompt) {
|
|
102
|
-
|
|
103
|
-
.toLowerCase()
|
|
104
|
-
.replace(/[^a-z0-9]+/g, ' ')
|
|
105
|
-
.split(' ')
|
|
106
|
-
.filter((w) => w !== '' && !STOPWORDS.has(w));
|
|
107
|
-
const picked = (words.length > 0 ? words : (prompt ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter(Boolean))
|
|
108
|
-
.slice(0, 3);
|
|
109
|
-
return sanitizeSessionName(picked.join('-')) || 'session';
|
|
130
|
+
return sanitizeSessionName(titleFromPrompt(prompt)) || 'session';
|
|
110
131
|
}
|
|
111
132
|
/** The namer's model: the `light` rung of the DEFAULT provider's ladder, with
|
|
112
133
|
* any thinking suffix stripped because the one-shot session uses thinking off.
|
|
@@ -134,6 +155,10 @@ function nameModelRequest() {
|
|
|
134
155
|
const NAME_SESSION_SCHEMA = {
|
|
135
156
|
type: 'object',
|
|
136
157
|
properties: {
|
|
158
|
+
title: {
|
|
159
|
+
type: 'string',
|
|
160
|
+
description: 'A 2-8 word sentence-case title a person would recognize this session by in a list, with its punctuation intact, e.g. Payroll export mapping bug. No quotes, markdown, or trailing period.',
|
|
161
|
+
},
|
|
137
162
|
name: {
|
|
138
163
|
type: 'string',
|
|
139
164
|
description: 'A 3-8 word kebab-case session name: lowercase words joined by single hyphens, e.g. refactor-auth-token-flow. No punctuation, quotes, or prose.',
|
|
@@ -143,7 +168,7 @@ const NAME_SESSION_SCHEMA = {
|
|
|
143
168
|
description: 'The Nerd Font glyph depicting this session, as a codepoint in U+XXXX form (e.g. U+F188).',
|
|
144
169
|
},
|
|
145
170
|
},
|
|
146
|
-
required: ['name', 'icon'],
|
|
171
|
+
required: ['title', 'name', 'icon'],
|
|
147
172
|
additionalProperties: false,
|
|
148
173
|
};
|
|
149
174
|
/** Normalize a model-supplied icon to a single renderable glyph, or '' when
|
|
@@ -181,35 +206,42 @@ export function sanitizeIcon(raw) {
|
|
|
181
206
|
return '';
|
|
182
207
|
return points[0].codePointAt(0) >= 0x2000 ? trimmed : '';
|
|
183
208
|
}
|
|
184
|
-
/** Ask Pi headlessly for a structured {name, icon} for `body`, async.
|
|
185
|
-
* to a sanitized SessionName, or `{name:'',icon:''}` on
|
|
186
|
-
* call, or a malformed emission so the caller can fall
|
|
209
|
+
/** Ask Pi headlessly for a structured {name, title, icon} for `body`, async.
|
|
210
|
+
* Resolves to a sanitized SessionName, or `{name:'',title:'',icon:''}` on
|
|
211
|
+
* timeout, a missing tool call, or a malformed emission so the caller can fall
|
|
212
|
+
* back to the prompt's own words.
|
|
213
|
+
*
|
|
214
|
+
* Nothing FORCES the tool call — constrained sampling shapes the arguments once
|
|
215
|
+
* the model decides to call it — so a model that answers in prose instead gets
|
|
216
|
+
* one more attempt before the caller falls back.
|
|
187
217
|
*
|
|
188
218
|
* This module stays canvas-free so CLI leaves can reach it. The canvas-coupled
|
|
189
219
|
* commit path lives in pi-extensions/broker-local.ts, which imports this pure
|
|
190
220
|
* helper and writes through crtrd's guarded generated-name operation. */
|
|
191
221
|
export async function headlessName(body, sourceCap = PROMPT_CAP) {
|
|
192
|
-
const empty = { name: '', icon: '' };
|
|
193
|
-
const
|
|
194
|
-
const emitted = await headlessToolCall({
|
|
222
|
+
const empty = { name: '', title: '', icon: '' };
|
|
223
|
+
const request = {
|
|
195
224
|
systemPrompt: NAME_SYSTEM_PROMPT,
|
|
196
225
|
userPrompt: nameUserPrompt(body, sourceCap),
|
|
197
|
-
...
|
|
226
|
+
...nameModelRequest(),
|
|
198
227
|
timeoutMs: NAME_TIMEOUT_MS,
|
|
199
228
|
tool: {
|
|
200
229
|
name: 'name_session',
|
|
201
230
|
label: 'Name session',
|
|
202
|
-
description: 'Record the name and icon for this
|
|
231
|
+
description: 'Record the title, name, and icon for this session. Call exactly once, then stop.',
|
|
203
232
|
parameters: NAME_SESSION_SCHEMA,
|
|
204
233
|
constrainedSampling: { type: 'json_schema', strict: 'prefer' },
|
|
205
234
|
},
|
|
206
|
-
}
|
|
235
|
+
};
|
|
236
|
+
const emitted = await headlessToolCall(request) ?? await headlessToolCall(request);
|
|
207
237
|
if (emitted === null || typeof emitted !== 'object')
|
|
208
238
|
return empty;
|
|
209
239
|
const parsed = emitted;
|
|
210
|
-
const
|
|
240
|
+
const title = sanitizeSessionTitle(typeof parsed['title'] === 'string' ? parsed['title'] : '');
|
|
241
|
+
const name = sanitizeSessionName(typeof parsed['name'] === 'string' ? parsed['name'] : '')
|
|
242
|
+
|| sanitizeSessionName(title);
|
|
211
243
|
const icon = sanitizeIcon(typeof parsed['icon'] === 'string' ? parsed['icon'] : '');
|
|
212
|
-
return name !== '' ? { name, icon } : empty;
|
|
244
|
+
return name !== '' ? { name, title, icon } : empty;
|
|
213
245
|
}
|
|
214
246
|
/** Generate a name from a bounded conversation source rather than a single
|
|
215
247
|
* kickoff message. The caller owns selecting representative conversation text;
|
|
@@ -111,11 +111,18 @@ export async function relaunchRoot(oldId, deps = {}) {
|
|
|
111
111
|
...inv.env,
|
|
112
112
|
CRTR_SUBTREE: rootOfSpine(newMeta.node_id),
|
|
113
113
|
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
let placed;
|
|
115
|
+
try {
|
|
116
|
+
placed = launchBroker(newMeta.node_id, inv, {
|
|
117
|
+
cwd: old.cwd,
|
|
118
|
+
name: fullName(newMeta),
|
|
119
|
+
resuming: false,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
transition(newMeta.node_id, 'crash');
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
119
126
|
if (placed.pid == null) {
|
|
120
127
|
// The new broker never started — crash the half-born node and BAIL. The old
|
|
121
128
|
// root is still live and untouched (nothing below this point has run).
|
|
@@ -7,16 +7,9 @@ import type { NodeMeta, NodeStatus } from '../canvas/types.js';
|
|
|
7
7
|
* is involuntary, so it is swept. (Silas, 2026-06-13: no opt-in to include these
|
|
8
8
|
* — the exclusion is unconditional.) */
|
|
9
9
|
export declare const TERMINAL_BY_CHOICE: readonly NodeStatus[];
|
|
10
|
-
/** True when `meta` has no natural future cycle ahead of it
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
* inbox watcher's own hold-and-flush) ever brings such a node back on its
|
|
14
|
-
* own — only an explicit revive/reopen does. Shared by `isDisconnected`
|
|
15
|
-
* (mass-revive scope, below) and `node message send --tier deferred`'s terminal-edge
|
|
16
|
-
* rejection — a deferred entry appended to such a target would
|
|
17
|
-
* never be delivered, so that doorway rejects instead of silently holding
|
|
18
|
-
* it forever. */
|
|
19
|
-
export declare function hasNoNaturalCycle(meta: Pick<NodeMeta, 'status' | 'final_report'>): boolean;
|
|
10
|
+
/** True when `meta` has no natural future cycle ahead of it. A frozen row is
|
|
11
|
+
* excluded because its mark records a thaw cycle already owed. */
|
|
12
|
+
export declare function hasNoNaturalCycle(meta: Pick<NodeMeta, 'status' | 'final_report' | 'frozen_at'>): boolean;
|
|
20
13
|
/** True when `meta`'s engine is NOT running but it has a resumable saved session
|
|
21
14
|
* — the precise "disconnected" predicate.
|
|
22
15
|
*
|
|
@@ -28,7 +21,7 @@ export declare function hasNoNaturalCycle(meta: Pick<NodeMeta, 'status' | 'final
|
|
|
28
21
|
* it — so such a node comes back fresh).
|
|
29
22
|
* - `human`-kind rows are the `crtr human` bridge, never a pi engine, so they
|
|
30
23
|
* are never "disconnected" (mirrors the daemon's superviseTick carve-out).
|
|
31
|
-
* - terminal-by-choice (`done`/`canceled`) is
|
|
24
|
+
* - unfrozen terminal-by-choice (`done`/`canceled`) is excluded.
|
|
32
25
|
* - a node that already pushed its FINAL REPORT (`final_report` set) FINISHED
|
|
33
26
|
* its own work — it is excluded regardless of status. A crash mid-reap can
|
|
34
27
|
* leave it `dead` (not terminal-by-choice) with its report already committed;
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// After a mass-disconnect event (a reboot, a killed login/tmux session, a mass
|
|
4
4
|
// crash, or the daemon being down a while) many nodes end up with their
|
|
5
5
|
// canvas.db row + saved conversation intact but NO broker engine running. The
|
|
6
|
-
// daemon recovers active|idle rows during its
|
|
7
|
-
// broker exit events during its own epoch, but it deliberately never touches
|
|
6
|
+
// daemon recovers active|idle rows during its row-driven supervision tick and
|
|
7
|
+
// from broker exit events during its own epoch, but it deliberately never touches
|
|
8
8
|
// terminal/dormant states, so the operator is left reviving survivors one id at
|
|
9
9
|
// a time.
|
|
10
10
|
//
|
|
@@ -24,17 +24,10 @@ import { reviveNode } from './revive.js';
|
|
|
24
24
|
* is involuntary, so it is swept. (Silas, 2026-06-13: no opt-in to include these
|
|
25
25
|
* — the exclusion is unconditional.) */
|
|
26
26
|
export const TERMINAL_BY_CHOICE = ['done', 'canceled'];
|
|
27
|
-
/** True when `meta` has no natural future cycle ahead of it
|
|
28
|
-
*
|
|
29
|
-
* TERMINAL_BY_CHOICE. Nothing in the runtime (daemon supervision, the live
|
|
30
|
-
* inbox watcher's own hold-and-flush) ever brings such a node back on its
|
|
31
|
-
* own — only an explicit revive/reopen does. Shared by `isDisconnected`
|
|
32
|
-
* (mass-revive scope, below) and `node message send --tier deferred`'s terminal-edge
|
|
33
|
-
* rejection — a deferred entry appended to such a target would
|
|
34
|
-
* never be delivered, so that doorway rejects instead of silently holding
|
|
35
|
-
* it forever. */
|
|
27
|
+
/** True when `meta` has no natural future cycle ahead of it. A frozen row is
|
|
28
|
+
* excluded because its mark records a thaw cycle already owed. */
|
|
36
29
|
export function hasNoNaturalCycle(meta) {
|
|
37
|
-
return meta.final_report != null || TERMINAL_BY_CHOICE.includes(meta.status);
|
|
30
|
+
return meta.frozen_at == null && (meta.final_report != null || TERMINAL_BY_CHOICE.includes(meta.status));
|
|
38
31
|
}
|
|
39
32
|
/** True when `meta`'s engine is NOT running but it has a resumable saved session
|
|
40
33
|
* — the precise "disconnected" predicate.
|
|
@@ -47,7 +40,7 @@ export function hasNoNaturalCycle(meta) {
|
|
|
47
40
|
* it — so such a node comes back fresh).
|
|
48
41
|
* - `human`-kind rows are the `crtr human` bridge, never a pi engine, so they
|
|
49
42
|
* are never "disconnected" (mirrors the daemon's superviseTick carve-out).
|
|
50
|
-
* - terminal-by-choice (`done`/`canceled`) is
|
|
43
|
+
* - unfrozen terminal-by-choice (`done`/`canceled`) is excluded.
|
|
51
44
|
* - a node that already pushed its FINAL REPORT (`final_report` set) FINISHED
|
|
52
45
|
* its own work — it is excluded regardless of status. A crash mid-reap can
|
|
53
46
|
* leave it `dead` (not terminal-by-choice) with its report already committed;
|
|
@@ -50,10 +50,10 @@
|
|
|
50
50
|
// different directory than the last one simply misses and cold-spawns — the
|
|
51
51
|
// pool serves the common case of returning to the same working directory.
|
|
52
52
|
//
|
|
53
|
-
// Nothing ever
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
53
|
+
// Nothing ever resumes a spare: the daemon's row query hides the pool, so a
|
|
54
|
+
// spare whose broker dies (every daemon handover tears down every broker of the
|
|
55
|
+
// epoch) is garbage, not a parked node. It is also invisible to the count-based
|
|
56
|
+
// prune for the same reason. So the pool owns
|
|
57
57
|
// its own garbage collection — `reapStaleSpares`, below, run at daemon start
|
|
58
58
|
// and on every supervision tick.
|
|
59
59
|
import { createHash } from 'node:crypto';
|
|
@@ -78,6 +78,7 @@ test('node create stores caller description and automatic naming leaves it and n
|
|
|
78
78
|
const generated = await generatedNameRoute.handler(context('POST', `/v1/nodes/${nodeId}/broker/generated-name`, nodeId, {
|
|
79
79
|
kind: 'initial',
|
|
80
80
|
description: 'Generated replacement',
|
|
81
|
+
title: 'Generated replacement',
|
|
81
82
|
icon: '',
|
|
82
83
|
}));
|
|
83
84
|
assert.deepEqual(generated.body, { applied: false });
|
|
@@ -88,6 +88,7 @@ function extensionNode(node) {
|
|
|
88
88
|
node_id: node.node_id,
|
|
89
89
|
name: node.name,
|
|
90
90
|
...(node.description === undefined ? {} : { description: node.description }),
|
|
91
|
+
...(node.title === undefined ? {} : { title: node.title }),
|
|
91
92
|
...(node.icon === undefined ? {} : { icon: node.icon }),
|
|
92
93
|
kind: node.kind,
|
|
93
94
|
mode: node.mode,
|
|
@@ -153,17 +154,18 @@ function parseGeneratedNameBody(body) {
|
|
|
153
154
|
const value = objectBody(body, 'broker generated-name');
|
|
154
155
|
if (value['kind'] !== 'initial' && value['kind'] !== 'recap')
|
|
155
156
|
throw usage('kind must be initial or recap');
|
|
156
|
-
if (typeof value['description'] !== 'string' || typeof value['icon'] !== 'string') {
|
|
157
|
-
throw usage('description and icon must be strings');
|
|
157
|
+
if (typeof value['description'] !== 'string' || typeof value['title'] !== 'string' || typeof value['icon'] !== 'string') {
|
|
158
|
+
throw usage('description, title, and icon must be strings');
|
|
159
|
+
}
|
|
160
|
+
if (value['kind'] === 'initial') {
|
|
161
|
+
return { kind: 'initial', description: value['description'], title: value['title'], icon: value['icon'] };
|
|
158
162
|
}
|
|
159
|
-
if (value['kind'] === 'initial')
|
|
160
|
-
return { kind: 'initial', description: value['description'], icon: value['icon'] };
|
|
161
163
|
const expected = value['expected'];
|
|
162
164
|
if (typeof expected !== 'object' || expected === null || Array.isArray(expected)
|
|
163
|
-
|| ['name', 'description', 'icon', 'kind'].some((key) => typeof expected[key] !== 'string')) {
|
|
164
|
-
throw usage('recap expected must contain name, description, icon, and kind strings');
|
|
165
|
+
|| ['name', 'description', 'title', 'icon', 'kind'].some((key) => typeof expected[key] !== 'string')) {
|
|
166
|
+
throw usage('recap expected must contain name, description, title, icon, and kind strings');
|
|
165
167
|
}
|
|
166
|
-
return { kind: 'recap', description: value['description'], icon: value['icon'], expected: expected };
|
|
168
|
+
return { kind: 'recap', description: value['description'], title: value['title'], icon: value['icon'], expected: expected };
|
|
167
169
|
}
|
|
168
170
|
function parsePersonaAckBody(body) {
|
|
169
171
|
const value = objectBody(body, 'broker persona-ack');
|
|
@@ -371,27 +373,32 @@ function handleGeneratedName(ctx) {
|
|
|
371
373
|
return { applied: false };
|
|
372
374
|
const updated = updateNode(nodeId, {
|
|
373
375
|
description: request.description,
|
|
376
|
+
...(request.title.trim() === '' ? {} : { title: request.title }),
|
|
374
377
|
...(request.icon.trim() === '' ? {} : { icon: request.icon }),
|
|
375
378
|
});
|
|
376
379
|
return {
|
|
377
380
|
applied: true,
|
|
378
381
|
editorLabel: editorLabel(updated),
|
|
379
382
|
description: updated.description ?? '',
|
|
383
|
+
title: updated.title ?? '',
|
|
380
384
|
icon: updated.icon ?? '',
|
|
381
385
|
};
|
|
382
386
|
}
|
|
383
387
|
const expected = request.expected;
|
|
384
388
|
if (node.name !== expected.name || (node.description ?? '') !== expected.description
|
|
389
|
+
|| (node.title ?? '') !== expected.title
|
|
385
390
|
|| (node.icon ?? '') !== expected.icon || node.kind !== expected.kind)
|
|
386
391
|
return { applied: false };
|
|
387
392
|
const updated = updateNode(nodeId, {
|
|
388
393
|
description: request.description,
|
|
394
|
+
title: request.title.trim() === '' ? undefined : request.title,
|
|
389
395
|
icon: request.icon.trim() === '' ? undefined : request.icon,
|
|
390
396
|
});
|
|
391
397
|
return {
|
|
392
398
|
applied: true,
|
|
393
399
|
editorLabel: editorLabel(updated),
|
|
394
400
|
description: updated.description ?? '',
|
|
401
|
+
title: updated.title ?? '',
|
|
395
402
|
icon: updated.icon ?? '',
|
|
396
403
|
};
|
|
397
404
|
});
|
|
@@ -67,7 +67,7 @@ async function deliverCommentTurn(args) {
|
|
|
67
67
|
mode: 'interactive',
|
|
68
68
|
data: { page_id: basename(args.dir), comment_id: args.mutation.comment.id, verb: 'create' },
|
|
69
69
|
});
|
|
70
|
-
return { target_node_id: target, status: 'delivered', via: delivery.via, woke: delivery.revived };
|
|
70
|
+
return { target_node_id: target, status: 'delivered', via: delivery.via, woke: delivery.revived, ...(delivery.reason === undefined ? {} : { reason: delivery.reason }) };
|
|
71
71
|
}
|
|
72
72
|
catch (error) {
|
|
73
73
|
// State was committed before delivery; a delivery failure remains a
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -124,6 +124,8 @@ export function toNodeDetailDTO(meta, edges) {
|
|
|
124
124
|
};
|
|
125
125
|
if (meta.description !== undefined)
|
|
126
126
|
dto.description = meta.description;
|
|
127
|
+
if (meta.title !== undefined)
|
|
128
|
+
dto.title = meta.title;
|
|
127
129
|
if (meta.icon !== undefined)
|
|
128
130
|
dto.icon = meta.icon;
|
|
129
131
|
if (meta.cycles !== undefined)
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
// clock. A broker's real `exit` event enqueues an exit-policy job on the
|
|
10
10
|
// daemon's serial work lane (never inline on the Node event callback), where
|
|
11
11
|
// classifyDeadNode — one pure 8-row policy table over durable row state —
|
|
12
|
-
// decides forget / dormant / respawn / boot-failure. The
|
|
13
|
-
//
|
|
14
|
-
// recovery and startup recovery can never diverge. Every broker's argv is
|
|
12
|
+
// decides forget / dormant / respawn / boot-failure. The row-driven tick
|
|
13
|
+
// classifies disconnected rows through the same table. Every broker's argv is
|
|
15
14
|
// stamped `--epoch <id>`: at boot the census reaps every prior-epoch broker
|
|
16
15
|
// (no adoption), and at shutdown the daemon tears its whole fleet down, so no
|
|
17
16
|
// broker ever outlives the daemon epoch that launched it. A tmux pane, when
|
|
@@ -94,8 +93,8 @@ export function brokerCommandDeclaresCanvasHome(command, home) {
|
|
|
94
93
|
// readiness mirror, reap every broker of a PRIOR epoch on its own canvas.
|
|
95
94
|
// Ownership is never adopted across epochs — a prior daemon's ChildProcess
|
|
96
95
|
// handles died with it, so its brokers are unsupervisable; tear them down and
|
|
97
|
-
// let the
|
|
98
|
-
//
|
|
96
|
+
// let the next supervision tick classify their rows through the policy table.
|
|
97
|
+
// A broker whose argv carries NO `--epoch` tag is a legacy-generation
|
|
99
98
|
// broker and gets the same verdict (reap): it predates epoch ownership.
|
|
100
99
|
/** The `--epoch <id>` tag host.ts stamps into every broker's argv, or null
|
|
101
100
|
* for a legacy/untagged broker (which the census treats as prior-epoch). */
|
|
@@ -110,9 +109,9 @@ const CENSUS_POLL_MS = 150;
|
|
|
110
109
|
async function reapPriorEpochBrokers(epoch) {
|
|
111
110
|
const snapshot = captureLivenessSnapshot();
|
|
112
111
|
if (snapshot === null) {
|
|
113
|
-
// No process table — the census cannot run. The
|
|
114
|
-
//
|
|
115
|
-
//
|
|
112
|
+
// No process table — the census cannot run. The supervision tick still
|
|
113
|
+
// classifies rows; a surviving prior-epoch broker is at worst a duplicate
|
|
114
|
+
// engine risk the next daemon boot's census retries.
|
|
116
115
|
emitEvent({
|
|
117
116
|
level: 'error',
|
|
118
117
|
event: 'daemon.census.snapshot_failed',
|
|
@@ -184,10 +183,9 @@ async function reapPriorEpochBrokers(epoch) {
|
|
|
184
183
|
// Daemon-shutdown fleet teardown (invariant 2: no broker outlives the daemon
|
|
185
184
|
// epoch that launched it). SIGTERM every live entry, race each handle's real
|
|
186
185
|
// exit against ONE shared bounded grace, then SIGKILL each survivor's captured
|
|
187
|
-
// process tree. Rows are deliberately NOT rewritten here — the
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// recovery path.
|
|
186
|
+
// process tree. Rows are deliberately NOT rewritten here — the next daemon's
|
|
187
|
+
// supervision tick classifies them through the policy table, so clean shutdown
|
|
188
|
+
// and crash converge on one recovery path.
|
|
191
189
|
const FLEET_TEARDOWN_GRACE_MS = 5_000;
|
|
192
190
|
async function teardownFleet(fleet) {
|
|
193
191
|
const entries = Array.from(fleet.entries());
|
|
@@ -292,7 +290,7 @@ export async function superviseTick(now = Date.now(), lifecycle = directTickLife
|
|
|
292
290
|
// Human bridge rows have no broker engine and participate in none of the
|
|
293
291
|
// recurring row-driven reconciler passes.
|
|
294
292
|
rows = listNodes({ status: ['active', 'idle'] }).filter((row) => row.kind !== 'human');
|
|
295
|
-
terminating = listNodes({ status: ['done', 'canceled', 'dead'],
|
|
293
|
+
terminating = listNodes({ status: ['done', 'canceled', 'dead'], withRecordedPidOrFrozen: true })
|
|
296
294
|
.filter((row) => row.kind !== 'human');
|
|
297
295
|
}
|
|
298
296
|
catch (err) {
|
|
@@ -427,7 +425,7 @@ export async function runDaemon(opts = {}) {
|
|
|
427
425
|
// The grace covers: response flush → the CLI process printing and exiting →
|
|
428
426
|
// pi persisting the tool result. It is NOT a correctness fallback; a broker
|
|
429
427
|
// SIGTERMed mid-turn still gets `abortAndExit`'s own bounded persistence
|
|
430
|
-
// window, and the successor's
|
|
428
|
+
// window, and the successor's supervision tick resolves it either way.
|
|
431
429
|
const HANDOVER_ACK_GRACE_MS = 1_000;
|
|
432
430
|
let handoverArmed = false;
|
|
433
431
|
/** Arm the handover. Idempotent: a retry while one is already scheduled
|
|
@@ -484,7 +482,7 @@ export async function runDaemon(opts = {}) {
|
|
|
484
482
|
catch (error) {
|
|
485
483
|
// The canvas is now daemonless. Loud, and recoverable: the next front-door
|
|
486
484
|
// command's ensureDaemon() starts one, and every torn-down node is
|
|
487
|
-
// reclassified by that daemon's
|
|
485
|
+
// reclassified by that daemon's supervision tick.
|
|
488
486
|
operationIdContext.fresh(() => {
|
|
489
487
|
emitEvent({ level: 'error', event: 'daemon.handover.successor_failed', error });
|
|
490
488
|
});
|
|
@@ -530,9 +528,8 @@ export async function runDaemon(opts = {}) {
|
|
|
530
528
|
void requestCleanup().catch(() => { });
|
|
531
529
|
}
|
|
532
530
|
/** The serial work lane (design D-3): supervision ticks, exit-policy jobs,
|
|
533
|
-
* matured backoff respawns
|
|
534
|
-
*
|
|
535
|
-
* row reads. `laneTail` never rejects — every append attaches the rejection
|
|
531
|
+
* and matured backoff respawns run strictly one at a time, so policy never
|
|
532
|
+
* interleaves with a tick's row reads. `laneTail` never rejects — every append attaches the rejection
|
|
536
533
|
* handler. Once admission closes, new jobs are DROPPED (not queued): a
|
|
537
534
|
* shutdown SIGTERM fans exit events across the whole fleet, and running
|
|
538
535
|
* their policy jobs would respawn brokers mid-teardown. */
|
|
@@ -574,8 +571,8 @@ export async function runDaemon(opts = {}) {
|
|
|
574
571
|
bindDaemonEventSource();
|
|
575
572
|
interval = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
576
573
|
// Mint this daemon's epoch and bind the ONE fleet registry for the process
|
|
577
|
-
// BEFORE anything can launch a broker (the API server
|
|
578
|
-
//
|
|
574
|
+
// BEFORE anything can launch a broker (the API server or any tick): every
|
|
575
|
+
// launch funnels through headlessBrokerHost.launch, which
|
|
579
576
|
// registers with the bound fleet — an unbound launch throws (design D-2).
|
|
580
577
|
const epoch = randomBytes(8).toString('hex');
|
|
581
578
|
fleet = new DaemonFleet({ epoch, enqueue });
|
|
@@ -58,6 +58,7 @@ export async function deliverNodeMessage(args) {
|
|
|
58
58
|
data: { ...args.data, body: args.body },
|
|
59
59
|
}));
|
|
60
60
|
let revived = false;
|
|
61
|
+
let reason;
|
|
61
62
|
if (args.mode !== 'quiet') {
|
|
62
63
|
target = getNode(args.node_id);
|
|
63
64
|
if (target === null)
|
|
@@ -66,12 +67,15 @@ export async function deliverNodeMessage(args) {
|
|
|
66
67
|
try {
|
|
67
68
|
// Mail is durable, so a wake denied for capacity waits for a slot
|
|
68
69
|
// rather than being lost: the row freezes and the tick relaunches it.
|
|
69
|
-
|
|
70
|
+
const result = reviveNode(args.node_id, { resume: true, capacity: 'freeze' });
|
|
71
|
+
revived = result.launch !== undefined;
|
|
72
|
+
if (result.outcome === 'frozen')
|
|
73
|
+
reason = 'capacity_frozen';
|
|
70
74
|
}
|
|
71
75
|
catch {
|
|
72
76
|
// The durable entry remains authoritative; one wake attempt is enough.
|
|
73
77
|
}
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
|
-
return { entry_id: entry.entry_id, via: 'inbox', revived };
|
|
80
|
+
return { entry_id: entry.entry_id, via: 'inbox', revived, ...(reason === undefined ? {} : { reason }) };
|
|
77
81
|
}
|
|
@@ -12,8 +12,7 @@ export declare function hasLiveObligation(nodeId: string): boolean;
|
|
|
12
12
|
* cycle rather than causing one. */
|
|
13
13
|
export declare function hasPendingWake(nodeId: string): boolean;
|
|
14
14
|
/** What parking an unattended node actually amounts to — the one rule both the
|
|
15
|
-
* live-broker park clock
|
|
16
|
-
* reconciliation (dormant-inbox) apply.
|
|
15
|
+
* live-broker park clock and the lifecycle tick apply.
|
|
17
16
|
*
|
|
18
17
|
* `release` — something can still wake it, so parking is a pause.
|
|
19
18
|
* `park` — a resident with nothing left to wake it has completed its
|
|
@@ -43,11 +43,6 @@ export declare class FreezeLaneReconciler {
|
|
|
43
43
|
endEpisode(nodeId: string): void;
|
|
44
44
|
run(now: number, ctx: FreezeLaneContext): Promise<FrozenCounts>;
|
|
45
45
|
private enact;
|
|
46
|
-
/** A frozen row is owed exactly one thing: the launch the cap refused. Strict
|
|
47
|
-
* resume is the request, and `reviveNode` itself vetoes it for a pending
|
|
48
|
-
* refresh or an unconfirmed cycle — the same modes the dead-row table would
|
|
49
|
-
* have chosen. It is recovery, not a wake: nothing arrived for this node
|
|
50
|
-
* while it waited, so an armed deadline survives the thaw. */
|
|
51
46
|
private enactThaw;
|
|
52
47
|
private enactIdleRelease;
|
|
53
48
|
}
|
|
@@ -8,11 +8,7 @@
|
|
|
8
8
|
// needs to pre-reserve anything. A candidate that finds the ledger full freezes
|
|
9
9
|
// (`reviveNode` with `capacity: 'freeze'` is the single freeze writer) and waits
|
|
10
10
|
// for the next tick.
|
|
11
|
-
|
|
12
|
-
// The idle-release episode rules below are the former DormantInboxReconciler's,
|
|
13
|
-
// moved here whole: a relaunch that only ever tried to deliver mail is a launch
|
|
14
|
-
// like any other, and it now competes for slots through the same lane.
|
|
15
|
-
import { getRow, subscribersOf, } from '../../../core/canvas/index.js';
|
|
11
|
+
import { clearFrozen, getNode, getRow, subscribersOf, } from '../../../core/canvas/index.js';
|
|
16
12
|
import { fullName } from '../../../core/canvas/labels.js';
|
|
17
13
|
import { isSafeNodeId } from '../../../core/canvas/paths.js';
|
|
18
14
|
import { emitEvent } from '../../../core/events/emit.js';
|
|
@@ -87,7 +83,7 @@ export class FreezeLaneReconciler {
|
|
|
87
83
|
const id = entry.row.node_id;
|
|
88
84
|
try {
|
|
89
85
|
if (entry.enact === 'thaw')
|
|
90
|
-
return this.enactThaw(
|
|
86
|
+
return this.enactThaw(entry.row);
|
|
91
87
|
if (entry.enact === 'idle-release')
|
|
92
88
|
return this.enactIdleRelease(now, entry.row);
|
|
93
89
|
// The SAME policy table an exit event runs, so tick recovery and exit
|
|
@@ -110,13 +106,13 @@ export class FreezeLaneReconciler {
|
|
|
110
106
|
return 'skipped';
|
|
111
107
|
}
|
|
112
108
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return reviveNode(
|
|
109
|
+
enactThaw(row) {
|
|
110
|
+
if (row.lifecycle === 'resident' && getNode(row.node_id)?.pi_session_id != null && !hasPendingWake(row.node_id)) {
|
|
111
|
+
transition(row.node_id, 'release');
|
|
112
|
+
clearFrozen(row.node_id);
|
|
113
|
+
return 'skipped';
|
|
114
|
+
}
|
|
115
|
+
return reviveNode(row.node_id, { resume: true, capacity: 'freeze', recovery: true }).outcome === 'frozen'
|
|
120
116
|
? 'frozen'
|
|
121
117
|
: 'launched';
|
|
122
118
|
}
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
// respawn-policy.ts — the tick's Backoff gate, and P5's file to own outright.
|
|
2
|
-
//
|
|
3
|
-
// P3 only READS `respawn_not_before`: nothing writes it yet, so the gate is
|
|
4
|
-
// inert in effect. P5 replaces the in-memory throttle in `daemon/fleet.ts` with
|
|
5
|
-
// the durable counter writes (`respawn_failures` + `respawn_not_before`) that
|
|
6
|
-
// make it bite, and adds them here rather than in `tick.ts`.
|
|
7
1
|
/** Whether a durable backoff still holds this row back from a relaunch. A row
|
|
8
2
|
* in backoff holds no slot and is excluded from the saturation figure — it is
|
|
9
3
|
* waiting on a clock, not on capacity. */
|
|
@@ -1,4 +1,2 @@
|
|
|
1
1
|
import type { NodeRow } from '../../../core/canvas/index.js';
|
|
2
|
-
/** Handle a terminal row that still holds a process. Deliberately inert until
|
|
3
|
-
* P4: a row reaching here is already excluded from every other tick duty. */
|
|
4
2
|
export declare function handleTerminatingRow(row: NodeRow): void;
|