@crouton-kit/crouter 0.3.35 → 0.3.37
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/build-root.js +2 -6
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-personas/runtime-base.md +1 -1
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +4 -4
- package/dist/builtin-views/canvas/core.mjs +3 -2
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +21 -205
- package/dist/clients/attach/attach-cmd.js +383 -386
- package/dist/clients/web/web-cmd.js +7 -7
- package/dist/commands/__tests__/human.test.js +1 -15
- package/dist/commands/attention.js +9 -6
- package/dist/commands/canvas-history/search.js +2 -2
- package/dist/commands/canvas-history/show.js +3 -3
- package/dist/commands/canvas-history.js +1 -1
- package/dist/commands/canvas-issue.js +1 -0
- package/dist/commands/canvas-snapshot.js +1 -0
- package/dist/commands/canvas.js +10 -11
- package/dist/commands/chord.js +1 -0
- package/dist/commands/human/prompts.js +0 -1
- package/dist/commands/human/queue.js +1 -0
- package/dist/commands/node-snapshot.js +1 -0
- package/dist/commands/node.js +12 -11
- package/dist/commands/push.d.ts +0 -1
- package/dist/commands/push.js +2 -228
- package/dist/commands/revive.js +2 -2
- package/dist/commands/{canvas-tmux-spread.js → surface-tmux-spread.js} +9 -6
- package/dist/commands/surface.js +3 -2
- package/dist/commands/sys/daemon.d.ts +2 -0
- package/dist/commands/{daemon.js → sys/daemon.js} +12 -12
- package/dist/commands/sys/doctor.js +1 -0
- package/dist/commands/sys.js +4 -3
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/daemon-wedge.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-wedge.test.js +87 -0
- package/dist/core/__tests__/hearth-bootstrap.test.js +2 -2
- package/dist/core/__tests__/history-inbox.test.d.ts +1 -0
- package/dist/core/__tests__/history-inbox.test.js +105 -0
- package/dist/core/__tests__/kickoff.test.js +3 -3
- package/dist/core/__tests__/revive.test.js +39 -1
- package/dist/core/canvas/history.d.ts +1 -1
- package/dist/core/canvas/history.js +90 -3
- package/dist/core/canvas/paths.d.ts +6 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/canvas/status-glyph.js +1 -0
- package/dist/core/fault-classifier.d.ts +2 -2
- package/dist/core/feed/inbox.d.ts +7 -12
- package/dist/core/feed/inbox.js +45 -29
- package/dist/core/hearth/providers/blaxel-bootstrap.js +1 -1
- package/dist/core/preview-registry.js +14 -112
- package/dist/core/render.js +1 -1
- package/dist/core/runtime/busy.d.ts +15 -0
- package/dist/core/runtime/busy.js +32 -1
- package/dist/core/runtime/close.d.ts +10 -0
- package/dist/core/runtime/close.js +18 -13
- package/dist/core/runtime/fault-recovery.js +4 -0
- package/dist/core/runtime/kickoff.js +5 -7
- package/dist/core/runtime/placement.js +1 -1
- package/dist/core/runtime/revive.js +9 -1
- package/dist/core/runtime/tmux.d.ts +2 -2
- package/dist/core/runtime/tmux.js +2 -2
- package/dist/daemon/crtrd-cli.js +1 -1
- package/dist/daemon/crtrd.d.ts +24 -0
- package/dist/daemon/crtrd.js +213 -5
- package/dist/daemon/manage.js +2 -2
- package/dist/pi-extensions/canvas-nav.js +2 -2
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +59 -2
- package/dist/web-client/assets/{index-IAJVtuVe.js → index-MSKSWwV0.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/package.json +1 -1
- package/dist/commands/daemon.d.ts +0 -2
- /package/dist/commands/{canvas-tmux-spread.d.ts → surface-tmux-spread.d.ts} +0 -0
package/dist/commands/push.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// `crtr push`
|
|
1
|
+
// `crtr push` — the one verb up the graph.
|
|
2
2
|
//
|
|
3
3
|
// crtr push update [body] — routine progress to subscribers
|
|
4
4
|
// crtr push urgent [body] — force-wake subscribers
|
|
5
5
|
// crtr push final [body] — finish: write result, mark node done, close window
|
|
6
|
-
// crtr feed read — drain the caller's (or a named) inbox into a digest
|
|
7
|
-
// crtr feed peek — live state of the nodes below you, cursor untouched
|
|
8
6
|
//
|
|
9
7
|
// "push" is THE verb a node uses to talk to its managers; the tier is the
|
|
10
8
|
// subcommand. The caller's node is resolved from CRTR_NODE_ID (injected by the
|
|
@@ -13,8 +11,7 @@
|
|
|
13
11
|
import { defineBranch, defineLeaf } from '../core/command.js';
|
|
14
12
|
import { InputError, readStdinRaw } from '../core/io.js';
|
|
15
13
|
import { push } from '../core/feed/feed.js';
|
|
16
|
-
import { getNode, subscribersOf
|
|
17
|
-
import { readInboxSince, readInboxEntryById, readCursor, writeCursor, coalesce, } from '../core/feed/inbox.js';
|
|
14
|
+
import { getNode, subscribersOf } from '../core/canvas/index.js';
|
|
18
15
|
function requireCallerNode() {
|
|
19
16
|
const id = process.env['CRTR_NODE_ID'];
|
|
20
17
|
if (id === undefined || id.trim() === '') {
|
|
@@ -142,213 +139,6 @@ function makeTierLeaf(tier) {
|
|
|
142
139
|
});
|
|
143
140
|
}
|
|
144
141
|
// ---------------------------------------------------------------------------
|
|
145
|
-
// feed read — drain the inbox
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
const feedReadLeaf = defineLeaf({
|
|
148
|
-
name: 'read',
|
|
149
|
-
description: 'drain unread pointers into a digest',
|
|
150
|
-
whenToUse: 'you want to PROACTIVELY poll what the nodes you subscribe to — your children and anyone you follow — have reported before the watcher wakes you, draining the unread pointers in your inbox into one coalesced digest. NOTE: when a subscriber push wakes you, that wake message already IS this digest (the watcher drains your inbox to wake you), so don\'t re-run feed read to "open" it — dereference the refs in the digest you already have. Reach for it to poll before the next wake, to inspect another node\'s inbox, or to re-read the whole history after the cursor has advanced.',
|
|
151
|
-
help: {
|
|
152
|
-
name: 'feed read',
|
|
153
|
-
summary: 'drain unread inbox pointers for the caller (or a named node) into a compact digest',
|
|
154
|
-
params: [
|
|
155
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node whose inbox to read. Defaults to CRTR_NODE_ID. Use to inspect a worker\'s inbox as an orchestrator.' },
|
|
156
|
-
{ kind: 'flag', name: 'all', type: 'bool', required: false, default: false, constraint: 'Ignore the cursor and return everything from the start — use to re-read history the wake already drained.' },
|
|
157
|
-
],
|
|
158
|
-
output: [
|
|
159
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'Node whose inbox was read.' },
|
|
160
|
-
{ name: 'digest', type: 'string', required: true, constraint: 'Coalesced digest — paste directly into a prompt.' },
|
|
161
|
-
{ name: 'entries', type: 'object[]', required: true, constraint: 'Raw InboxEntry objects.' },
|
|
162
|
-
{ name: 'cursor', type: 'string', required: true, constraint: 'New cursor ISO written after draining.' },
|
|
163
|
-
{ name: 'inbox_total', type: 'number', required: true, constraint: 'Total entries in the inbox (read + unread). Distinguishes a never-used inbox from one already drained at wake.' },
|
|
164
|
-
],
|
|
165
|
-
outputKind: 'object',
|
|
166
|
-
effects: ['Advances nodes/<nodeId>/inbox.jsonl.cursor.', 'Read-only on inbox.jsonl itself.'],
|
|
167
|
-
},
|
|
168
|
-
run: async (input) => {
|
|
169
|
-
const nodeId = typeof input['node'] === 'string' && input['node'].trim() !== ''
|
|
170
|
-
? input['node'].trim()
|
|
171
|
-
: requireCallerNode();
|
|
172
|
-
const cursor = input['all'] === true ? undefined : readCursor(nodeId);
|
|
173
|
-
const entries = readInboxSince(nodeId, cursor);
|
|
174
|
-
const total = readInboxSince(nodeId, undefined).length;
|
|
175
|
-
const newCursor = entries.length > 0 ? entries[entries.length - 1].ts : cursor ?? new Date().toISOString();
|
|
176
|
-
writeCursor(nodeId, newCursor);
|
|
177
|
-
return {
|
|
178
|
-
node_id: nodeId,
|
|
179
|
-
digest: coalesce(entries),
|
|
180
|
-
entries: entries,
|
|
181
|
-
cursor: newCursor,
|
|
182
|
-
inbox_total: total,
|
|
183
|
-
};
|
|
184
|
-
},
|
|
185
|
-
render: (r) => {
|
|
186
|
-
const n = Array.isArray(r['entries']) ? r['entries'].length : 0;
|
|
187
|
-
const rawDigest = typeof r['digest'] === 'string' ? r['digest'] : '';
|
|
188
|
-
if (n > 0 && rawDigest.trim() !== '') {
|
|
189
|
-
return `${n} unread pointer${n === 1 ? '' : 's'} drained from ${r['node_id']}'s inbox.\n\n${rawDigest.trim()}`;
|
|
190
|
-
}
|
|
191
|
-
// Empty drain has two distinct causes — say which, honestly. An inbox that
|
|
192
|
-
// already holds entries was drained when the watcher woke you (the wake
|
|
193
|
-
// message WAS that digest); a never-used inbox simply has nothing yet.
|
|
194
|
-
const total = typeof r['inbox_total'] === 'number' ? r['inbox_total'] : 0;
|
|
195
|
-
const explanation = total > 0
|
|
196
|
-
? 'Your inbox is not empty, though. The watcher drains your inbox to wake you, so the entries you already saw in your wake message are the same ones you would read here; that is why this is empty. Re-read the whole history (full message bodies included) with `crtr feed read --all`, or dereference the refs from the wake digest you already have.'
|
|
197
|
-
: 'Your inbox is empty — nothing has arrived from your subscriptions yet. Expected while workers run: a worker that has not pushed yet leaves no pointer, and it will wake you the moment it does. The wake is automatic — just continue your own work or end your turn. (Reach for `crtr feed peek` only if you suspect a worker died, not to confirm a live one.)';
|
|
198
|
-
return `No unread pointers for ${r['node_id']} (unread=0).\n\n${explanation}`;
|
|
199
|
-
},
|
|
200
|
-
});
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
// feed message — read one inbox message's full body back from the jsonl
|
|
203
|
-
// ---------------------------------------------------------------------------
|
|
204
|
-
const feedMessageLeaf = defineLeaf({
|
|
205
|
-
name: 'message',
|
|
206
|
-
description: 'print one inbox entry\'s full body by id',
|
|
207
|
-
whenToUse: 'a digest line ended in `… (clipped — full message: crtr feed message <id>)` — run it to read the rest of that direct message. The full body lives in your inbox.jsonl; this prints it verbatim by the entry\'s short id. Read-only: it does NOT advance your inbox cursor.',
|
|
208
|
-
help: {
|
|
209
|
-
name: 'feed message',
|
|
210
|
-
summary: 'print the full body of one inbox entry, addressed by the short id shown in a clipped digest line',
|
|
211
|
-
params: [
|
|
212
|
-
{ kind: 'positional', name: 'id', required: true, constraint: 'The short entry id from the digest\'s `… (full message: …)` pointer.' },
|
|
213
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Whose inbox to read. Defaults to CRTR_NODE_ID; pass to read another node\'s inbox as an orchestrator.' },
|
|
214
|
-
],
|
|
215
|
-
output: [
|
|
216
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'Node whose inbox was read.' },
|
|
217
|
-
{ name: 'id', type: 'string', required: true, constraint: 'The requested entry id.' },
|
|
218
|
-
{ name: 'found', type: 'boolean', required: true, constraint: 'True when an entry with that id exists.' },
|
|
219
|
-
{ name: 'from', type: 'string', required: false, constraint: 'Sender of the message, when found.' },
|
|
220
|
-
{ name: 'body', type: 'string', required: false, constraint: 'Full message body, when found.' },
|
|
221
|
-
],
|
|
222
|
-
outputKind: 'object',
|
|
223
|
-
effects: ['Read-only: scans inbox.jsonl. Does NOT advance the cursor.'],
|
|
224
|
-
},
|
|
225
|
-
run: async (input) => {
|
|
226
|
-
const nodeId = typeof input['node'] === 'string' && input['node'].trim() !== ''
|
|
227
|
-
? input['node'].trim()
|
|
228
|
-
: requireCallerNode();
|
|
229
|
-
const id = input['id'].trim();
|
|
230
|
-
const entry = readInboxEntryById(nodeId, id);
|
|
231
|
-
if (entry === undefined) {
|
|
232
|
-
return { node_id: nodeId, id, found: false };
|
|
233
|
-
}
|
|
234
|
-
const body = typeof entry.data?.['body'] === 'string' ? entry.data['body'] : entry.label;
|
|
235
|
-
return { node_id: nodeId, id, found: true, from: entry.from ?? 'system', body };
|
|
236
|
-
},
|
|
237
|
-
render: (r) => {
|
|
238
|
-
if (r['found'] !== true) {
|
|
239
|
-
return `No inbox entry with id ${r['id']} in ${r['node_id']}'s inbox. Re-read the digest with \`crtr feed read --all\`, or list raw entries to find the right id.`;
|
|
240
|
-
}
|
|
241
|
-
return `Full message ${r['id']} (from ${r['from']}):\n\n${r['body']}`;
|
|
242
|
-
},
|
|
243
|
-
});
|
|
244
|
-
// ---------------------------------------------------------------------------
|
|
245
|
-
// feed peek — live state of the nodes below you, without draining anything
|
|
246
|
-
// ---------------------------------------------------------------------------
|
|
247
|
-
const STATUS_GLYPH = {
|
|
248
|
-
active: '●', idle: '○', done: '✓', dead: '✗', canceled: '⊘',
|
|
249
|
-
};
|
|
250
|
-
/** Coarse "Nm ago" age from an ISO timestamp — enough to read staleness at a glance. */
|
|
251
|
-
function fmtAge(iso) {
|
|
252
|
-
const ms = Date.now() - new Date(iso).getTime();
|
|
253
|
-
if (!Number.isFinite(ms) || ms < 0)
|
|
254
|
-
return 'just now';
|
|
255
|
-
const s = Math.floor(ms / 1000);
|
|
256
|
-
if (s < 60)
|
|
257
|
-
return `${s}s ago`;
|
|
258
|
-
const m = Math.floor(s / 60);
|
|
259
|
-
if (m < 60)
|
|
260
|
-
return `${m}m ago`;
|
|
261
|
-
const h = Math.floor(m / 60);
|
|
262
|
-
if (h < 24)
|
|
263
|
-
return `${h}h ago`;
|
|
264
|
-
return `${Math.floor(h / 24)}d ago`;
|
|
265
|
-
}
|
|
266
|
-
const feedPeekLeaf = defineLeaf({
|
|
267
|
-
name: 'peek',
|
|
268
|
-
description: 'live state of the nodes below you, without draining anything',
|
|
269
|
-
whenToUse: 'you are about to end a turn and want to confirm your workers are running before you chill — peek shows every node you subscribe to (the workers below you) with its live status (working/idle/done/dead), how long it has run, its cycle count, and whether it has pushed yet, plus a one-line verdict on whether it is safe to yield. Non-destructive: it never advances your inbox cursor, so a later `feed read` still delivers undrained reports. Reach for it exactly when the feed reads empty but you have outstanding children — that empty feed is EXPECTED (a worker that has not pushed yet contributes no inbox pointer); peek confirms those workers are alive and running async so you can stop and wait instead of polling.',
|
|
270
|
-
help: {
|
|
271
|
-
name: 'feed peek',
|
|
272
|
-
summary: 'show the live state of every node you subscribe to (the workers below you) with a yield-or-not verdict; never drains the inbox',
|
|
273
|
-
params: [
|
|
274
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node whose subscriptions to peek. Defaults to CRTR_NODE_ID. Use to inspect a worker\'s downstream as an orchestrator.' },
|
|
275
|
-
],
|
|
276
|
-
output: [
|
|
277
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'Node that was peeked.' },
|
|
278
|
-
{ name: 'unread', type: 'number', required: true, constraint: 'Inbox pointers not yet drained by `feed read`.' },
|
|
279
|
-
{ name: 'children', type: 'object[]', required: true, constraint: 'One row per subscription: {node_id, kind, name, status, active, spawned, cycles, last_push}.' },
|
|
280
|
-
],
|
|
281
|
-
outputKind: 'object',
|
|
282
|
-
effects: ['Read-only: reads canvas.db edges + node metas + inbox.jsonl.', 'Does NOT advance the inbox cursor — peek leaves the feed intact for a later `feed read`.'],
|
|
283
|
-
},
|
|
284
|
-
run: async (input) => {
|
|
285
|
-
const nodeId = typeof input['node'] === 'string' && input['node'].trim() !== ''
|
|
286
|
-
? input['node'].trim()
|
|
287
|
-
: requireCallerNode();
|
|
288
|
-
// Read the WHOLE inbox to find each child's last push, but with no cursor
|
|
289
|
-
// write — peek is non-destructive by contract. `unread` is computed from the
|
|
290
|
-
// persisted cursor so peek can tell you a `feed read` would deliver something.
|
|
291
|
-
const all = readInboxSince(nodeId, undefined);
|
|
292
|
-
const unread = readInboxSince(nodeId, readCursor(nodeId)).length;
|
|
293
|
-
const children = subscriptionsOf(nodeId).map((s) => {
|
|
294
|
-
const n = getNode(s.node_id);
|
|
295
|
-
const fromMe = all.filter((e) => e.from === s.node_id);
|
|
296
|
-
const last = fromMe.length > 0 ? fromMe[fromMe.length - 1] : undefined;
|
|
297
|
-
return {
|
|
298
|
-
node_id: s.node_id,
|
|
299
|
-
kind: n?.kind ?? '?',
|
|
300
|
-
name: n !== null ? fullName(n) : s.node_id,
|
|
301
|
-
status: n?.status ?? 'dead',
|
|
302
|
-
active: s.active,
|
|
303
|
-
spawned: n?.created ?? s.created,
|
|
304
|
-
cycles: n?.cycles ?? 0,
|
|
305
|
-
last_push: last !== undefined
|
|
306
|
-
? { kind: last.kind, ts: last.ts, ref: last.ref ?? null, label: last.label }
|
|
307
|
-
: null,
|
|
308
|
-
};
|
|
309
|
-
});
|
|
310
|
-
return { node_id: nodeId, unread, children };
|
|
311
|
-
},
|
|
312
|
-
render: (r) => {
|
|
313
|
-
const id = r['node_id'];
|
|
314
|
-
const unread = r['unread'] ?? 0;
|
|
315
|
-
const kids = r['children'] ?? [];
|
|
316
|
-
if (kids.length === 0) {
|
|
317
|
-
const tail = unread > 0
|
|
318
|
-
? `${unread} unread report${unread === 1 ? '' : 's'} sit in your inbox — run \`crtr feed read\` to absorb ${unread === 1 ? 'it' : 'them'}.`
|
|
319
|
-
: 'If you spawned workers, they have finished and detached, or you never subscribed. Nothing will wake you.';
|
|
320
|
-
return `No nodes below you (0 subscriptions, ${unread} unread). ${tail}`;
|
|
321
|
-
}
|
|
322
|
-
const working = kids.filter((k) => k.status === 'active' || k.status === 'idle');
|
|
323
|
-
const liveWaking = working.filter((k) => k.active); // active sub to a live node = it will wake me
|
|
324
|
-
const done = kids.filter((k) => k.status === 'done' || k.status === 'canceled');
|
|
325
|
-
const dead = kids.filter((k) => k.status === 'dead');
|
|
326
|
-
let line;
|
|
327
|
-
if (dead.length > 0) {
|
|
328
|
-
line = `\u26a0 ${dead.length} below you ${dead.length === 1 ? 'is' : 'are'} dead and will NOT wake you. Inspect with \`crtr node inspect show <id>\`, then re-delegate or proceed without ${dead.length === 1 ? 'it' : 'them'}.`;
|
|
329
|
-
}
|
|
330
|
-
else if (liveWaking.length > 0) {
|
|
331
|
-
line = `Safe to yield \u2014 ${liveWaking.length} worker${liveWaking.length === 1 ? '' : 's'} running async will wake you on the next push. Nothing to do now; end your turn and chill.`;
|
|
332
|
-
}
|
|
333
|
-
else if (unread > 0) {
|
|
334
|
-
line = `Nothing still running, but ${unread} unread report${unread === 1 ? '' : 's'} \u2014 run \`crtr feed read\` to absorb ${unread === 1 ? 'it' : 'them'}, then continue or finish.`;
|
|
335
|
-
}
|
|
336
|
-
else {
|
|
337
|
-
line = 'Everything below you has finished and been drained \u2014 nothing is running. Continue your own work, or `crtr push final` to finish.';
|
|
338
|
-
}
|
|
339
|
-
const rows = kids.map((k) => {
|
|
340
|
-
const sub = k.active ? '' : ' (passive)';
|
|
341
|
-
const push = k.last_push !== null
|
|
342
|
-
? `pushed ${fmtAge(k.last_push.ts)} [${k.last_push.kind}]${k.last_push.ref !== null ? ` ref:${k.last_push.ref}` : ''}`
|
|
343
|
-
: 'no push yet';
|
|
344
|
-
const glyph = STATUS_GLYPH[k.status] ?? '?';
|
|
345
|
-
return `- ${glyph} ${k.name}${sub} (${k.node_id}) \u00b7 ${k.kind} \u00b7 ${k.status} \u00b7 spawned ${fmtAge(k.spawned)} \u00b7 cyc ${k.cycles} \u00b7 ${push}`;
|
|
346
|
-
}).join('\n');
|
|
347
|
-
const summary = `${kids.length} node${kids.length === 1 ? '' : 's'} below you — ${working.length} working, ${done.length} done, ${dead.length} dead, ${unread} unread:`;
|
|
348
|
-
return `${line}\n\n${summary}\n${rows}`;
|
|
349
|
-
},
|
|
350
|
-
});
|
|
351
|
-
// ---------------------------------------------------------------------------
|
|
352
142
|
// Registration
|
|
353
143
|
// ---------------------------------------------------------------------------
|
|
354
144
|
export function registerPush() {
|
|
@@ -367,19 +157,3 @@ export function registerPush() {
|
|
|
367
157
|
children: [makeTierLeaf('update'), makeTierLeaf('urgent'), makeTierLeaf('final')],
|
|
368
158
|
});
|
|
369
159
|
}
|
|
370
|
-
export function registerFeed() {
|
|
371
|
-
return defineBranch({
|
|
372
|
-
name: 'feed',
|
|
373
|
-
rootEntry: {
|
|
374
|
-
concept: 'the read side of the spine — pointers your subscriptions have pushed',
|
|
375
|
-
desc: 'drain your inbox feed into a digest',
|
|
376
|
-
useWhen: 'catching up on what the nodes you subscribe to have reported',
|
|
377
|
-
},
|
|
378
|
-
help: {
|
|
379
|
-
name: 'feed',
|
|
380
|
-
summary: 'read the per-node inbox feed',
|
|
381
|
-
model: 'Each node has an inbox.jsonl that accumulates ~30-token pointers from publishers it subscribes to. The watcher drains this inbox to wake you, so the wake message you receive ALREADY IS the coalesced digest \u2014 dereference the reports that matter by reading their ref paths (a push carries a ref; a direct message inlines a bounded body preview, and when that clips it ends in `crtr feed message <id>` to read the full text back from the jsonl). `feed read` is for PROACTIVELY polling before a wake (it advances the cursor); after a wake it reads empty because the cursor already moved \u2014 use `--all` to re-read history. An empty feed is normal while workers run \u2014 a worker that has not pushed leaves no pointer \u2014 so use `feed peek` to see the live state of the nodes below you (and whether to yield) without draining anything.',
|
|
382
|
-
},
|
|
383
|
-
children: [feedReadLeaf, feedMessageLeaf, feedPeekLeaf],
|
|
384
|
-
});
|
|
385
|
-
}
|
package/dist/commands/revive.js
CHANGED
|
@@ -164,8 +164,8 @@ export const reviveLeaf = defineLeaf({
|
|
|
164
164
|
next: 'Use --now only on a node the canvas shows as hanging (⚠). For a routine relaunch, omit --now.',
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
|
-
//
|
|
168
|
-
//
|
|
167
|
+
// The daemon never kills a BUSY engine (its verdict paths all return 'leave'
|
|
168
|
+
// on a live busy marker): a node mid-turn is making progress, not wedged —
|
|
169
169
|
// refuse rather than SIGTERM it mid-flight.
|
|
170
170
|
if (isBusy(nodeId)) {
|
|
171
171
|
throw new InputError({
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// `crtr
|
|
1
|
+
// `crtr surface tmux-spread <node>` — explode the local subtree into a tiled grid.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// A genuine viewer-placement decision (tile every live viewer in a subtree into
|
|
4
|
+
// one window), so it lives under `surface` alongside view/attach/workspace/web.
|
|
5
|
+
// Also bound to the `/graph` view's `e` key (src/pi-extensions/canvas-nav.ts),
|
|
6
|
+
// which shells this leaf directly for the cursor node. It "expands" the graph
|
|
7
|
+
// you are watching: the caller's
|
|
5
8
|
// own viewer pane is broken out into a BRAND-NEW window, then a surface-attach
|
|
6
9
|
// viewer pane is tiled beside it for every broker-alive node in the local
|
|
7
10
|
// subtree (ancestry root → self → descendants), so the user lands on a single
|
|
@@ -48,16 +51,16 @@ function brokerAlive(meta) {
|
|
|
48
51
|
export const tmuxSpreadLeaf = defineLeaf({
|
|
49
52
|
name: 'tmux-spread',
|
|
50
53
|
description: 'break the caller\'s pane into a new window and tile a viewer for every broker-alive node in the local subtree',
|
|
51
|
-
whenToUse: 'you want the whole live subtree you are watching on one tiled tmux window at once — bound to the
|
|
54
|
+
whenToUse: 'you want the whole live subtree you are watching on one tiled tmux window at once, instead of hunting down each node\'s viewer by hand — also bound to the `/graph` view\'s `e` key',
|
|
52
55
|
help: {
|
|
53
|
-
name: '
|
|
56
|
+
name: 'surface tmux-spread',
|
|
54
57
|
summary: 'Explode the local subtree into a tiled grid: break the caller\'s viewer pane into a brand-new window, then tile a surface-attach viewer pane beside it for every broker-alive node (status active/idle + live pi_pid) in the ancestry-root subtree, most-recently-active first, capped at 8 panes',
|
|
55
58
|
params: [
|
|
56
59
|
{
|
|
57
60
|
kind: 'positional',
|
|
58
61
|
name: 'node',
|
|
59
62
|
required: true,
|
|
60
|
-
constraint: 'The spread anchor (self) — its ancestry root + descendants define the subtree to tile.
|
|
63
|
+
constraint: 'The spread anchor (self) — its ancestry root + descendants define the subtree to tile. Passed as {self} by the `/graph` view\'s `e` key.',
|
|
61
64
|
},
|
|
62
65
|
{
|
|
63
66
|
kind: 'flag',
|
package/dist/commands/surface.js
CHANGED
|
@@ -8,6 +8,7 @@ import { registerView } from './view.js';
|
|
|
8
8
|
import { registerWorkspace } from './workspace.js';
|
|
9
9
|
import { registerWeb } from '../clients/web/web-cmd.js';
|
|
10
10
|
import { registerAttach } from '../clients/attach/attach-cmd.js';
|
|
11
|
+
import { tmuxSpreadLeaf } from './surface-tmux-spread.js';
|
|
11
12
|
function namespaceForSurface(node) {
|
|
12
13
|
node.help.name = `surface ${node.help.name}`;
|
|
13
14
|
if (node.kind === 'branch') {
|
|
@@ -36,8 +37,8 @@ export function registerSurface() {
|
|
|
36
37
|
help: {
|
|
37
38
|
name: 'surface',
|
|
38
39
|
summary: 'open and manage screen-facing surfaces',
|
|
39
|
-
model: '
|
|
40
|
+
model: 'Five entrypoints share the screen-facing space. `view` hosts a standalone raw-ANSI surface in tmux; `attach` connects this pane to a node broker; `workspace` lays out the left rail plus chat pane in one tmux window; `web` serves the browser shell and broker relay; `tmux-spread` tiles a viewer for every live node in a local subtree into one new window. Pick the family by host and layout, then choose the leaf that matches the action.',
|
|
40
41
|
},
|
|
41
|
-
children: [view, attach, workspace, web],
|
|
42
|
+
children: [view, attach, workspace, web, tmuxSpreadLeaf],
|
|
42
43
|
});
|
|
43
44
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `crtr
|
|
1
|
+
// `crtr sys daemon` — thin supervisor daemon management.
|
|
2
2
|
//
|
|
3
3
|
// The daemon (crtrd) polls active+idle nodes and handles engine-container exit
|
|
4
4
|
// (a tmux pane or a headless broker process):
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// • refresh-yield (intent=refresh) → fresh respawn
|
|
7
7
|
//
|
|
8
8
|
// This subtree starts, checks, and stops the daemon process.
|
|
9
|
-
import { defineLeaf, defineBranch } from '
|
|
10
|
-
import { InputError } from '
|
|
11
|
-
import { spawnDaemon } from '
|
|
12
|
-
import { readPidfile, isPidAlive } from '
|
|
9
|
+
import { defineLeaf, defineBranch } from '../../core/command.js';
|
|
10
|
+
import { InputError } from '../../core/io.js';
|
|
11
|
+
import { spawnDaemon } from '../../daemon/manage.js';
|
|
12
|
+
import { readPidfile, isPidAlive } from '../../daemon/crtrd.js';
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
// daemon start
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -18,7 +18,7 @@ const daemonStart = defineLeaf({
|
|
|
18
18
|
description: 'start the daemon in the background',
|
|
19
19
|
whenToUse: 'bringing the crtrd supervisor up for the first time in a session so node window-exits get handled (no-op if it is already running)',
|
|
20
20
|
help: {
|
|
21
|
-
name: '
|
|
21
|
+
name: 'sys daemon start',
|
|
22
22
|
summary: 'start the crtrd supervisor daemon in the background (no-op if already running)',
|
|
23
23
|
params: [],
|
|
24
24
|
output: [
|
|
@@ -43,9 +43,9 @@ const daemonStart = defineLeaf({
|
|
|
43
43
|
const daemonStatus = defineLeaf({
|
|
44
44
|
name: 'status',
|
|
45
45
|
description: 'check whether the daemon is running',
|
|
46
|
-
whenToUse: '
|
|
46
|
+
whenToUse: 'operator/dev diagnostic — probing whether the crtrd supervisor process is alive while troubleshooting revive/supervision behavior',
|
|
47
47
|
help: {
|
|
48
|
-
name: '
|
|
48
|
+
name: 'sys daemon status',
|
|
49
49
|
summary: 'check whether the crtrd supervisor daemon is currently running',
|
|
50
50
|
params: [],
|
|
51
51
|
output: [
|
|
@@ -71,7 +71,7 @@ const daemonStop = defineLeaf({
|
|
|
71
71
|
description: 'stop the running daemon',
|
|
72
72
|
whenToUse: 'shutting the crtrd supervisor down, ending auto-revival of nodes on window exit',
|
|
73
73
|
help: {
|
|
74
|
-
name: '
|
|
74
|
+
name: 'sys daemon stop',
|
|
75
75
|
summary: 'send SIGTERM to the crtrd supervisor daemon',
|
|
76
76
|
params: [],
|
|
77
77
|
output: [
|
|
@@ -100,16 +100,16 @@ const daemonStop = defineLeaf({
|
|
|
100
100
|
},
|
|
101
101
|
});
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
103
|
-
// Export — mounted under `crtr
|
|
103
|
+
// Export — mounted under `crtr sys`
|
|
104
104
|
// ---------------------------------------------------------------------------
|
|
105
105
|
export const daemonBranch = defineBranch({
|
|
106
106
|
name: 'daemon',
|
|
107
107
|
description: 'manage the crtrd supervisor process',
|
|
108
108
|
whenToUse: 'managing the crtrd supervisor that auto-revives nodes on window exit — start it, check its status, or stop it',
|
|
109
109
|
help: {
|
|
110
|
-
name: '
|
|
110
|
+
name: 'sys daemon',
|
|
111
111
|
summary: 'manage the crtrd canvas supervisor daemon',
|
|
112
|
-
model: 'crtrd is a thin background daemon that polls active+idle nodes and acts on window exit: crashed windows become dead; refresh-yield windows get a fresh respawn. It holds no orchestration logic — just process supervision.',
|
|
112
|
+
model: 'crtrd is a thin background daemon that polls active+idle nodes and acts on window exit: crashed windows become dead; refresh-yield windows get a fresh respawn. It holds no orchestration logic — just process supervision. start/stop/status are operator/dev self-management, not something a normal agent turn needs to check.',
|
|
113
113
|
},
|
|
114
114
|
children: [daemonStart, daemonStatus, daemonStop],
|
|
115
115
|
});
|
|
@@ -71,6 +71,7 @@ function faultRemediation(fault) {
|
|
|
71
71
|
auth: 're-authenticate',
|
|
72
72
|
protocol: 'fix the protocol mismatch',
|
|
73
73
|
other: 'fix the underlying issue',
|
|
74
|
+
wedged: 'read `crtr memory read wedged-child-on-runaway-bash` and, if a runaway subprocess is to blame, kill the subprocess (not the node)',
|
|
74
75
|
};
|
|
75
76
|
const progress = fault.retry.disposition === 'auto'
|
|
76
77
|
? fault.retry.by === 'client'
|
package/dist/commands/sys.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Replaces old config.ts + doctor.ts + update.ts command files.
|
|
3
3
|
import { defineBranch } from '../core/command.js';
|
|
4
4
|
import { configBranch } from './sys/config.js';
|
|
5
|
+
import { daemonBranch } from './sys/daemon.js';
|
|
5
6
|
import { sysDoctorLeaf } from './sys/doctor.js';
|
|
6
7
|
import { sysFeedbackLeaf } from './sys/feedback.js';
|
|
7
8
|
import { sysSettingsLeaf } from './sys/settings.js';
|
|
@@ -16,13 +17,13 @@ export function registerSys() {
|
|
|
16
17
|
name: 'sys',
|
|
17
18
|
rootEntry: {
|
|
18
19
|
concept: 'crtr configuration, diagnostics, and self-management',
|
|
19
|
-
desc: 'config, settings, setup, doctor, sysprompt, prompt-review, promptstudio, sync, update, version, feedback',
|
|
20
|
-
useWhen: 'managing the crtr installation or opening built-in system views',
|
|
20
|
+
desc: 'config, settings, setup, doctor, sysprompt, prompt-review, promptstudio, sync, update, version, feedback, daemon',
|
|
21
|
+
useWhen: 'managing the crtr installation, the crtrd supervisor process, or opening built-in system views',
|
|
21
22
|
},
|
|
22
23
|
help: {
|
|
23
24
|
name: 'sys',
|
|
24
25
|
summary: 'crtr system configuration, settings, diagnostics, and self-management',
|
|
25
26
|
},
|
|
26
|
-
children: [configBranch, sysSettingsLeaf, sysSetupLeaf, sysDoctorLeaf, sysSyspromptLeaf, promptReviewLeaf, sysPromptStudioLeaf, sysFeedbackLeaf, sysUpdateLeaf, sysVersionLeaf, sysSyncLeaf],
|
|
27
|
+
children: [configBranch, sysSettingsLeaf, sysSetupLeaf, sysDoctorLeaf, sysSyspromptLeaf, promptReviewLeaf, sysPromptStudioLeaf, sysFeedbackLeaf, sysUpdateLeaf, sysVersionLeaf, sysSyncLeaf, daemonBranch],
|
|
27
28
|
});
|
|
28
29
|
}
|
|
@@ -262,7 +262,7 @@ test('the daemon honours an explicit CRTR_PIDFILE override', () => {
|
|
|
262
262
|
// REGRESSION (bug #102 follow-up): with NO exit promise, the readiness loop must
|
|
263
263
|
// still observe a real `connect`. A buggy `?? Promise.resolve('retry')` race lost
|
|
264
264
|
// to the probe instantly, so live-broker callers (focus on an already-alive node,
|
|
265
|
-
//
|
|
265
|
+
// surface tmux-spread) could never return true and stalled the full 30s deadline.
|
|
266
266
|
test('waitForBrokerViewSocket returns true on a live socket when given no exit promise', async () => {
|
|
267
267
|
// CRTR_SOCK_DIR override keeps the AF_UNIX path short — the temp CRTR_HOME path
|
|
268
268
|
// would blow past macOS's 104-byte sun_path limit (listen EINVAL).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/daemon-wedge.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Issue #110 — pid-alive WEDGED node detection. The pure decision core:
|
|
4
|
+
// wedgeVerdict(busy, quietForMs, cpuPercent). No wall-clock sleeps, no ps
|
|
5
|
+
// shell-outs, no fake pids — every input is injected directly, mirroring the
|
|
6
|
+
// existing livenessVerdict/yieldStallVerdict suites in daemon-liveness.test.ts.
|
|
7
|
+
//
|
|
8
|
+
// LOCKS: (1) a routine long-running BUT ACTIVE turn (heartbeat recent, or CPU
|
|
9
|
+
// non-trivial) must never verdict 'wedged' — false positives are the failure
|
|
10
|
+
// mode this feature exists to avoid. (2) only busy+quiet+near-zero-CPU, all at
|
|
11
|
+
// once, past the generous grace, fires. (3) a null/failed CPU sample never
|
|
12
|
+
// fires on its own (fails toward 'pending', not 'wedged').
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { wedgeVerdict, WEDGE_QUIET_MS, WEDGE_CPU_MAX_PERCENT, sumTreeCpu } from '../../daemon/crtrd.js';
|
|
16
|
+
test('wedgeVerdict: a node whose turn already ended is never wedged, regardless of the other signals', () => {
|
|
17
|
+
assert.equal(wedgeVerdict({ busy: false, quietForMs: null, cpuPercent: null }), 'leave');
|
|
18
|
+
assert.equal(wedgeVerdict({ busy: false, quietForMs: 10 * 60 * 60_000, cpuPercent: 0 }), 'leave');
|
|
19
|
+
});
|
|
20
|
+
test('wedgeVerdict: a busy node with an unreadable heartbeat is left alone (not enough evidence)', () => {
|
|
21
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: null, cpuPercent: null }), 'leave');
|
|
22
|
+
});
|
|
23
|
+
test('wedgeVerdict: routine turns — even long ones — never trip before the grace elapses', () => {
|
|
24
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: 0, cpuPercent: 0 }), 'leave', 'just started');
|
|
25
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: 5 * 60_000, cpuPercent: 0 }), 'leave', 'a routine multi-minute tool call');
|
|
26
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS - 1, cpuPercent: 0 }), 'leave', 'just under the grace, even with 0% cpu');
|
|
27
|
+
});
|
|
28
|
+
test('wedgeVerdict: past the grace with NO cpu corroboration only pends — never fires on time alone', () => {
|
|
29
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS, cpuPercent: null }), 'pending', 'cpu sample failed/unavailable — never treated as confirmation');
|
|
30
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 10, cpuPercent: null }), 'pending', 'no amount of extra quiet time substitutes for the corroborating signal');
|
|
31
|
+
});
|
|
32
|
+
test('wedgeVerdict: a long-quiet turn that is STILL VISIBLY WORKING (real cpu) is exonerated, not wedged', () => {
|
|
33
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 3, cpuPercent: WEDGE_CPU_MAX_PERCENT + 0.1 }), 'pending', 'cpu just above the ceiling — a genuinely churning long build — must not fire');
|
|
34
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS * 3, cpuPercent: 40 }), 'pending', 'clearly-active cpu never fires regardless of how quiet the heartbeat looks');
|
|
35
|
+
});
|
|
36
|
+
test('wedgeVerdict: fires ONLY when busy + quiet-past-grace + near-zero cpu all corroborate', () => {
|
|
37
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS, cpuPercent: 0 }), 'wedged');
|
|
38
|
+
assert.equal(wedgeVerdict({ busy: true, quietForMs: WEDGE_QUIET_MS + 60_000, cpuPercent: WEDGE_CPU_MAX_PERCENT }), 'wedged', 'at the cpu ceiling (inclusive)');
|
|
39
|
+
});
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// sumTreeCpu (pure) — process-tree CPU sampling. Review fix for issue #110:
|
|
42
|
+
// sampling only the broker pid false-fired on a broker awaiting a busy CHILD
|
|
43
|
+
// subprocess. These lock the tree-sum behavior directly, independent of any
|
|
44
|
+
// `ps` shell-out.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
test('sumTreeCpu: a busy CHILD subprocess exonerates an idle broker (the false-positive this fixes)', () => {
|
|
47
|
+
const ps = [
|
|
48
|
+
' 100 1 0.0', // broker, idle
|
|
49
|
+
' 200 100 85.0', // child of the broker, busy
|
|
50
|
+
].join('\n');
|
|
51
|
+
assert.equal(sumTreeCpu(ps, 100), 85.0);
|
|
52
|
+
});
|
|
53
|
+
test('sumTreeCpu: broker + all descendants idle sums to ~0 (the real wedge is still detected)', () => {
|
|
54
|
+
const ps = [
|
|
55
|
+
' 100 1 0.0',
|
|
56
|
+
' 200 100 0.0',
|
|
57
|
+
' 201 100 0.0',
|
|
58
|
+
].join('\n');
|
|
59
|
+
assert.equal(sumTreeCpu(ps, 100), 0);
|
|
60
|
+
});
|
|
61
|
+
test('sumTreeCpu: sums across multiple generations (grandchild busy)', () => {
|
|
62
|
+
const ps = [
|
|
63
|
+
' 100 1 0.0', // broker
|
|
64
|
+
' 200 100 0.0', // child, idle
|
|
65
|
+
' 300 200 40.0', // grandchild, busy
|
|
66
|
+
].join('\n');
|
|
67
|
+
assert.equal(sumTreeCpu(ps, 100), 40.0);
|
|
68
|
+
});
|
|
69
|
+
test('sumTreeCpu: root pid absent from the ps output returns null (can\'t measure)', () => {
|
|
70
|
+
const ps = [
|
|
71
|
+
' 200 1 0.0',
|
|
72
|
+
' 300 200 10.0',
|
|
73
|
+
].join('\n');
|
|
74
|
+
assert.equal(sumTreeCpu(ps, 100), null);
|
|
75
|
+
});
|
|
76
|
+
test('sumTreeCpu: malformed/blank lines are skipped; empty output returns null', () => {
|
|
77
|
+
const ps = [
|
|
78
|
+
'',
|
|
79
|
+
' ',
|
|
80
|
+
'garbage line not ps shaped',
|
|
81
|
+
' 100 1 5.0',
|
|
82
|
+
' bad 100 1.0',
|
|
83
|
+
].join('\n');
|
|
84
|
+
assert.equal(sumTreeCpu(ps, 100), 5.0);
|
|
85
|
+
assert.equal(sumTreeCpu('', 100), null);
|
|
86
|
+
assert.equal(sumTreeCpu(' \n \n', 100), null);
|
|
87
|
+
});
|
|
@@ -91,7 +91,7 @@ test('every crtr invocation targets the baked binary by absolute path, never a b
|
|
|
91
91
|
assert.match(script, /'\/usr\/local\/bin\/crtr' --json sys version/);
|
|
92
92
|
assert.match(script, /'\/usr\/local\/bin\/crtr' surface web -h/);
|
|
93
93
|
assert.match(script, /'\/usr\/local\/bin\/crtr' surface web serve -h/);
|
|
94
|
-
assert.match(script, /'\/usr\/local\/bin\/crtr'
|
|
94
|
+
assert.match(script, /'\/usr\/local\/bin\/crtr' sys daemon start/);
|
|
95
95
|
assert.match(script, /'\/usr\/local\/bin\/crtr' surface web serve --host 0\.0\.0\.0/);
|
|
96
96
|
// No bare `crtr <subcommand>` invocation anywhere — only the `command -v
|
|
97
97
|
// crtr` shadow-detection probe and human-readable error-message text are
|
|
@@ -123,7 +123,7 @@ test('mkdirs the tmpfs CRTR_SOCK_DIR/CRTR_PIDFILE dirs strictly before the daemo
|
|
|
123
123
|
assert.doesNotMatch(script, /CRTR_CANVAS_DB=/);
|
|
124
124
|
assert.match(script, /unset CRTR_CANVAS_DB/);
|
|
125
125
|
const mkdirIdx = indexOf(script, '/run/crtr/socks');
|
|
126
|
-
const daemonStartIdx = indexOf(script, "'/usr/local/bin/crtr'
|
|
126
|
+
const daemonStartIdx = indexOf(script, "'/usr/local/bin/crtr' sys daemon start");
|
|
127
127
|
assert.ok(mkdirIdx < daemonStartIdx, 'tmpfs mkdir must precede daemon start');
|
|
128
128
|
const mkdirLine = script.split('\n').find((line) => line.startsWith('mkdir -p') && line.includes('/run/crtr/socks'));
|
|
129
129
|
assert.ok(mkdirLine, 'expected a single mkdir line covering the tmpfs dirs');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|