@crouton-kit/crouter 0.3.19 → 0.3.21
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/builtin-memory/crouter-development/marketplaces.md +2 -4
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +3 -5
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +3 -6
- package/dist/builtin-memory/crouter-development/personas.md +2 -6
- package/dist/builtin-memory/crouter-development/plugins.md +2 -4
- package/dist/builtin-memory/design.md +2 -4
- package/dist/builtin-memory/development.md +3 -3
- package/dist/builtin-memory/planning.md +3 -4
- package/dist/builtin-memory/spec.md +3 -10
- package/dist/builtin-personas/design/PERSONA.md +1 -0
- package/dist/builtin-personas/design/orchestrator.md +1 -0
- package/dist/builtin-personas/developer/PERSONA.md +1 -0
- package/dist/builtin-personas/developer/orchestrator.md +1 -0
- package/dist/builtin-personas/explore/PERSONA.md +1 -0
- package/dist/builtin-personas/explore/orchestrator.md +4 -0
- package/dist/builtin-personas/general/PERSONA.md +1 -0
- package/dist/builtin-personas/general/orchestrator.md +4 -0
- package/dist/builtin-personas/orchestration-kernel.md +1 -1
- package/dist/builtin-personas/plan/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/orchestrator.md +1 -0
- package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
- package/dist/builtin-personas/review/PERSONA.md +1 -0
- package/dist/builtin-personas/review/orchestrator.md +4 -0
- package/dist/builtin-personas/spec/PERSONA.md +1 -0
- package/dist/builtin-personas/spec/orchestrator.md +1 -0
- package/dist/commands/human/queue.js +13 -2
- package/dist/commands/memory/__tests__/lint-schema.test.d.ts +1 -0
- package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
- package/dist/commands/memory/find.js +10 -15
- package/dist/commands/memory/lint.d.ts +7 -0
- package/dist/commands/memory/lint.js +10 -1
- package/dist/commands/memory/shared.js +1 -2
- package/dist/commands/memory/write.js +6 -9
- package/dist/commands/node.js +51 -26
- package/dist/commands/push.js +10 -15
- package/dist/commands/tmux-spread.js +16 -1
- package/dist/commands/view-cycle.js +9 -5
- package/dist/commands/view-run.js +3 -2
- package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
- package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
- package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
- package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dialogs.test.js +126 -0
- package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +14 -604
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
- package/dist/core/__tests__/cascade-close.test.js +3 -2
- package/dist/core/__tests__/context-intro.test.js +23 -27
- package/dist/core/__tests__/detach-focus.test.js +2 -2
- package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
- package/dist/core/__tests__/helpers/broker-clients.js +178 -0
- package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
- package/dist/core/__tests__/live-mutation.test.js +4 -147
- package/dist/core/__tests__/memory.test.js +4 -4
- package/dist/core/__tests__/subscription-delivery.test.js +3 -3
- package/dist/core/bootstrap.js +18 -14
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/help.d.ts +2 -2
- package/dist/core/help.js +1 -1
- package/dist/core/render.d.ts +4 -3
- package/dist/core/render.js +38 -41
- package/dist/core/resolver.js +1 -1
- package/dist/core/runtime/bearings.d.ts +2 -2
- package/dist/core/runtime/bearings.js +2 -2
- package/dist/core/runtime/launch.d.ts +9 -2
- package/dist/core/runtime/launch.js +20 -3
- package/dist/core/runtime/nodes.d.ts +4 -0
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/promote.js +3 -0
- package/dist/core/runtime/reset.js +3 -2
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +2 -1
- package/dist/core/substrate/render.d.ts +1 -2
- package/dist/core/substrate/render.js +9 -12
- package/dist/core/substrate/schema.d.ts +18 -13
- package/dist/core/substrate/schema.js +12 -11
- package/dist/pi-extensions/canvas-context-intro.js +5 -3
- package/package.json +1 -1
package/dist/commands/node.js
CHANGED
|
@@ -119,6 +119,7 @@ const nodeNew = defineLeaf({
|
|
|
119
119
|
{ kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with).' },
|
|
120
120
|
{ kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
|
|
121
121
|
{ kind: 'flag', name: 'headless', type: 'bool', required: false, constraint: 'Spawn the node on the HEADLESS broker host (no tmux pane) instead of a tmux window. Overrides the `headless` config default for this spawn; omit to use that default (which itself defaults to a tmux pane).' },
|
|
122
|
+
{ kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong', 'medium', 'light'], required: false, constraint: 'Override the model the node runs on, by capability TIER: ultra (frontier), strong (opus), medium (sonnet), light (haiku). Omit to use the kind\'s persona default (explore=light, every other kind=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
|
|
122
123
|
],
|
|
123
124
|
output: [
|
|
124
125
|
{ name: 'node_id', type: 'string', required: true, constraint: 'The new node id.' },
|
|
@@ -148,9 +149,10 @@ const nodeNew = defineLeaf({
|
|
|
148
149
|
const parent = input['parent'];
|
|
149
150
|
const root = input['root'] === true;
|
|
150
151
|
const forkFrom = input['forkFrom'];
|
|
152
|
+
const model = input['model'];
|
|
151
153
|
// Host precedence: explicit --headless flag > config `headless` default > tmux.
|
|
152
154
|
const hostKind = input['headless'] === true ? 'broker' : readConfig('user').headless === true ? 'broker' : 'tmux';
|
|
153
|
-
const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom, hostKind });
|
|
155
|
+
const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom, hostKind, model });
|
|
154
156
|
return {
|
|
155
157
|
node_id: res.node.node_id,
|
|
156
158
|
name: res.node.name,
|
|
@@ -162,7 +164,14 @@ const nodeNew = defineLeaf({
|
|
|
162
164
|
: childFollowUp(parent ?? process.env['CRTR_NODE_ID']),
|
|
163
165
|
};
|
|
164
166
|
},
|
|
165
|
-
render: (r) =>
|
|
167
|
+
render: (r) => {
|
|
168
|
+
const win = r['window'] ? ` in window ${r['window']}` : '';
|
|
169
|
+
const lines = [`Spawned "${r['name']}" (${r['node_id']}) — ${r['status']}${win}.`];
|
|
170
|
+
if (r['session'])
|
|
171
|
+
lines.push(`- session: ${r['session']}`);
|
|
172
|
+
lines.push('', String(r['follow_up']));
|
|
173
|
+
return lines.join('\n');
|
|
174
|
+
},
|
|
166
175
|
});
|
|
167
176
|
// ---------------------------------------------------------------------------
|
|
168
177
|
// node list — the active canvas (or a status slice)
|
|
@@ -350,9 +359,16 @@ const nodeRecycle = defineLeaf({
|
|
|
350
359
|
const res = await recycleNode(id, pane);
|
|
351
360
|
return { recycled: res.recycled, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
|
|
352
361
|
},
|
|
353
|
-
render: (r) =>
|
|
354
|
-
|
|
355
|
-
|
|
362
|
+
render: (r) => {
|
|
363
|
+
if (r['recycled'] !== true)
|
|
364
|
+
return `Recycle failed for ${r['node_id'] ?? '?'} — not in tmux, or no agent in this pane.`;
|
|
365
|
+
const lines = [`Recycled the pane — finished ${r['node_id']}.`, `- finalized: ${r['finalized']}`];
|
|
366
|
+
if (r['finalized'] === true)
|
|
367
|
+
lines.push(`- delivered: ${r['delivered']}`);
|
|
368
|
+
if (r['new_root'])
|
|
369
|
+
lines.push(`- new root: ${r['new_root']}`);
|
|
370
|
+
return lines.join('\n');
|
|
371
|
+
},
|
|
356
372
|
});
|
|
357
373
|
// ---------------------------------------------------------------------------
|
|
358
374
|
// node close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
|
|
@@ -396,9 +412,12 @@ const nodeClose = defineLeaf({
|
|
|
396
412
|
const res = closeNode(id);
|
|
397
413
|
return { closed: true, node_id: res.root, count: res.closed.length, closed_ids: res.closed, spared: res.spared };
|
|
398
414
|
},
|
|
399
|
-
render: (r) =>
|
|
400
|
-
|
|
401
|
-
|
|
415
|
+
render: (r) => {
|
|
416
|
+
if (r['closed'] !== true)
|
|
417
|
+
return 'Close failed — no node found in this pane to close.';
|
|
418
|
+
const spared = r['spared']?.length ?? 0;
|
|
419
|
+
return `Closed ${r['node_id']} and its exclusive subtree — ${r['count']} node(s) closed${spared > 0 ? `, ${spared} spared (still managed from outside the subtree)` : ''}.`;
|
|
420
|
+
},
|
|
402
421
|
});
|
|
403
422
|
// ---------------------------------------------------------------------------
|
|
404
423
|
// node cycle — DFS-walk the canvas one window at a time (Alt+] / Alt+[)
|
|
@@ -484,8 +503,8 @@ const nodeCycle = defineLeaf({
|
|
|
484
503
|
return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
|
|
485
504
|
},
|
|
486
505
|
render: (r) => r['focused'] === true
|
|
487
|
-
?
|
|
488
|
-
:
|
|
506
|
+
? `Cycled to "${r['name'] ?? ''}" (${r['node_id']}) — from ${r['from'] ?? '?'}.`
|
|
507
|
+
: 'No other live node to focus.',
|
|
489
508
|
});
|
|
490
509
|
// ---------------------------------------------------------------------------
|
|
491
510
|
// node msg — direct-address any node at a wake tier (wakes a dormant target)
|
|
@@ -580,7 +599,7 @@ const nodeSubscribe = defineLeaf({
|
|
|
580
599
|
subscribe(subscriber, publisher, !passive);
|
|
581
600
|
return { subscribed: true, subscriber, publisher, mode: passive ? 'passive' : 'active' };
|
|
582
601
|
},
|
|
583
|
-
render: (r) =>
|
|
602
|
+
render: (r) => `Subscribed ${r['subscriber']} → ${r['publisher']} (${r['mode']}).`,
|
|
584
603
|
});
|
|
585
604
|
const nodeUnsubscribe = defineLeaf({
|
|
586
605
|
name: 'unsubscribe',
|
|
@@ -607,7 +626,7 @@ const nodeUnsubscribe = defineLeaf({
|
|
|
607
626
|
unsubscribe(subscriber, publisher);
|
|
608
627
|
return { unsubscribed: true, subscriber, publisher };
|
|
609
628
|
},
|
|
610
|
-
render: (r) =>
|
|
629
|
+
render: (r) => `Unsubscribed ${r['subscriber']} from ${r['publisher']}.`,
|
|
611
630
|
});
|
|
612
631
|
// ---------------------------------------------------------------------------
|
|
613
632
|
// node promote — become an orchestrator (the worker→orchestrator polymorph)
|
|
@@ -682,6 +701,7 @@ function setLifecycle(id, value, opts) {
|
|
|
682
701
|
const { launch } = buildLaunchSpec(target.kind, target.mode, {
|
|
683
702
|
lifecycle: value,
|
|
684
703
|
hasManager: target.parent !== null,
|
|
704
|
+
model: target.model_override ?? undefined,
|
|
685
705
|
});
|
|
686
706
|
const meta = updateNode(id, { lifecycle: value, launch });
|
|
687
707
|
// --detach: shove the still-running agent into the background crtr session,
|
|
@@ -725,7 +745,7 @@ const nodeDemote = defineLeaf({
|
|
|
725
745
|
const res = setLifecycle(id, 'terminal', { pane, detach: input['detach'] === true });
|
|
726
746
|
return { node_id: res.node_id, lifecycle: res.lifecycle, detached: res.detached };
|
|
727
747
|
},
|
|
728
|
-
render: (r) =>
|
|
748
|
+
render: (r) => `Demoted ${r['node_id']} — lifecycle now ${r['lifecycle']} (in place)${r['detached'] === true ? ', relocated to the background crtr session' : ''}.`,
|
|
729
749
|
});
|
|
730
750
|
// ---------------------------------------------------------------------------
|
|
731
751
|
// node lifecycle — flip the lifecycle axis (terminal ↔ resident), independent
|
|
@@ -762,7 +782,7 @@ const nodeLifecycle = defineLeaf({
|
|
|
762
782
|
const res = setLifecycle(id, value, { pane, detach: input['detach'] === true });
|
|
763
783
|
return { node_id: res.node_id, lifecycle: res.lifecycle, detached: res.detached };
|
|
764
784
|
},
|
|
765
|
-
render: (r) =>
|
|
785
|
+
render: (r) => `Set ${r['node_id']} lifecycle → ${r['lifecycle']}${r['detached'] === true ? ', relocated to the background crtr session' : ''}.`,
|
|
766
786
|
});
|
|
767
787
|
// ---------------------------------------------------------------------------
|
|
768
788
|
// node yield — refresh: discard context, revive fresh against the roadmap
|
|
@@ -905,10 +925,6 @@ function etaHint(fireAt, now) {
|
|
|
905
925
|
return ` (~${hrs}h)`;
|
|
906
926
|
return ` (~${Math.round(ms / 86_400_000)}d)`;
|
|
907
927
|
}
|
|
908
|
-
/** Escape a value for a rendered XML attribute (list rows carry free-text notes). */
|
|
909
|
-
function xmlAttr(s) {
|
|
910
|
-
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
911
|
-
}
|
|
912
928
|
// node wake at ---------------------------------------------------------------
|
|
913
929
|
const nodeWakeAt = defineLeaf({
|
|
914
930
|
name: 'at',
|
|
@@ -1008,7 +1024,10 @@ const nodeWakeAt = defineLeaf({
|
|
|
1008
1024
|
: `Bare self-alarm armed. You wake in a fresh window at ${fireAt}${eta}. End your turn now to go dormant; do not push final. The wake re-reads your roadmap.`;
|
|
1009
1025
|
return { id, kind, fires_at: fireAt, recur: cadenceDisplay(recur), target, guidance };
|
|
1010
1026
|
},
|
|
1011
|
-
render: (r) =>
|
|
1027
|
+
render: (r) => {
|
|
1028
|
+
const recur = r['recur'] !== 'none' ? `, recurs ${r['recur']}` : '';
|
|
1029
|
+
return `Armed ${r['kind']} wake ${r['id']} — fires ${r['fires_at']}${recur}, target ${r['target']}.\n\n${r['guidance']}`;
|
|
1030
|
+
},
|
|
1012
1031
|
});
|
|
1013
1032
|
// node wake until ------------------------------------------------------------
|
|
1014
1033
|
const nodeWakeUntil = defineLeaf({
|
|
@@ -1060,7 +1079,7 @@ const nodeWakeUntil = defineLeaf({
|
|
|
1060
1079
|
const guidance = `Deadline armed. You wake on the first inbox message, or at ${w.fireAt}${eta} if none arrives. Now delegate / end your turn to go dormant — whichever fires first cancels the other.`;
|
|
1061
1080
|
return { id, fires_at: w.fireAt, target: 'self', guidance };
|
|
1062
1081
|
},
|
|
1063
|
-
render: (r) =>
|
|
1082
|
+
render: (r) => `Armed deadline ${r['id']} — fires ${r['fires_at']}, target ${r['target']}.\n\n${r['guidance']}`,
|
|
1064
1083
|
});
|
|
1065
1084
|
// node wake spawn -----------------------------------------------------------
|
|
1066
1085
|
const nodeWakeSpawn = defineLeaf({
|
|
@@ -1161,7 +1180,10 @@ const nodeWakeSpawn = defineLeaf({
|
|
|
1161
1180
|
: `Deferred spawn armed: a fresh ${kind} node is born at ${fireAt}${eta}. No node exists yet — inspect/cancel via \`crtr node wake list\` / \`crtr node wake cancel ${id}\`. Pick up other work or end your turn.`;
|
|
1162
1181
|
return { id, kind, fires_at: fireAt, recur: cadenceDisplay(recur), guidance };
|
|
1163
1182
|
},
|
|
1164
|
-
render: (r) =>
|
|
1183
|
+
render: (r) => {
|
|
1184
|
+
const recur = r['recur'] !== 'none' ? `, recurs ${r['recur']}` : '';
|
|
1185
|
+
return `Armed deferred spawn ${r['id']} — a ${r['kind']} node, fires ${r['fires_at']}${recur}.\n\n${r['guidance']}`;
|
|
1186
|
+
},
|
|
1165
1187
|
});
|
|
1166
1188
|
// node wake list -------------------------------------------------------------
|
|
1167
1189
|
const nodeWakeList = defineLeaf({
|
|
@@ -1243,10 +1265,13 @@ const nodeWakeList = defineLeaf({
|
|
|
1243
1265
|
},
|
|
1244
1266
|
render: (r) => {
|
|
1245
1267
|
const wakes = r['wakes'];
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1268
|
+
const follow = 'Cancel one with `crtr node wake cancel <id>`. Fired one-shots are gone; a recurrence shows its NEXT fire, not past slots.';
|
|
1269
|
+
if (wakes.length === 0)
|
|
1270
|
+
return `No pending wakes in scope ${r['scope']}.\n\n${follow}`;
|
|
1271
|
+
const cell = (v) => String(v).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
1272
|
+
const head = '| id | kind | next | recur | target | owner | note |\n| --- | --- | --- | --- | --- | --- | --- |';
|
|
1273
|
+
const rows = wakes.map((w) => `| ${cell(w['id'])} | ${cell(w['kind'])} | ${cell(w['next'])} | ${cell(w['recur'])} | ${cell(w['target'])} | ${cell(w['owner'])} | ${cell(w['note'])} |`);
|
|
1274
|
+
return `${wakes.length} pending wake(s) in scope ${r['scope']}.\n\n${head}\n${rows.join('\n')}\n\n${follow}`;
|
|
1250
1275
|
},
|
|
1251
1276
|
});
|
|
1252
1277
|
// node wake cancel -----------------------------------------------------------
|
|
@@ -1273,7 +1298,7 @@ const nodeWakeCancel = defineLeaf({
|
|
|
1273
1298
|
cancelWake(id);
|
|
1274
1299
|
return { id, was_pending: wasPending };
|
|
1275
1300
|
},
|
|
1276
|
-
render: (r) =>
|
|
1301
|
+
render: (r) => `Canceled wake ${r['id']}${r['was_pending'] === true ? '' : ' (already gone — no-op)'}.`,
|
|
1277
1302
|
});
|
|
1278
1303
|
// node wake (branch) ---------------------------------------------------------
|
|
1279
1304
|
const nodeWake = defineBranch({
|
package/dist/commands/push.js
CHANGED
|
@@ -112,7 +112,7 @@ function makeTierLeaf(tier) {
|
|
|
112
112
|
: tier === 'urgent'
|
|
113
113
|
? `Urgent report fanned to ${n} subscriber(s) — they are force-woken.`
|
|
114
114
|
: `Progress report fanned to ${n} subscriber(s).`;
|
|
115
|
-
return
|
|
115
|
+
return `${line}\n\n- report: ${r['report_path']}`;
|
|
116
116
|
},
|
|
117
117
|
});
|
|
118
118
|
}
|
|
@@ -161,16 +161,16 @@ const feedReadLeaf = defineLeaf({
|
|
|
161
161
|
const n = Array.isArray(r['entries']) ? r['entries'].length : 0;
|
|
162
162
|
const rawDigest = typeof r['digest'] === 'string' ? r['digest'] : '';
|
|
163
163
|
if (n > 0 && rawDigest.trim() !== '') {
|
|
164
|
-
return
|
|
164
|
+
return `${n} unread pointer${n === 1 ? '' : 's'} drained from ${r['node_id']}'s inbox.\n\n${rawDigest.trim()}`;
|
|
165
165
|
}
|
|
166
166
|
// Empty drain has two distinct causes — say which, honestly. An inbox that
|
|
167
167
|
// already holds entries was drained when the watcher woke you (the wake
|
|
168
168
|
// message WAS that digest); a never-used inbox simply has nothing yet.
|
|
169
169
|
const total = typeof r['inbox_total'] === 'number' ? r['inbox_total'] : 0;
|
|
170
|
-
const
|
|
171
|
-
? '
|
|
172
|
-
: '
|
|
173
|
-
return
|
|
170
|
+
const explanation = total > 0
|
|
171
|
+
? '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.'
|
|
172
|
+
: '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.)';
|
|
173
|
+
return `No unread pointers for ${r['node_id']} (unread=0).\n\n${explanation}`;
|
|
174
174
|
},
|
|
175
175
|
});
|
|
176
176
|
// ---------------------------------------------------------------------------
|
|
@@ -249,28 +249,23 @@ const feedPeekLeaf = defineLeaf({
|
|
|
249
249
|
const tail = unread > 0
|
|
250
250
|
? `${unread} unread report${unread === 1 ? '' : 's'} sit in your inbox — run \`crtr feed read\` to absorb ${unread === 1 ? 'it' : 'them'}.`
|
|
251
251
|
: 'If you spawned workers, they have finished and detached, or you never subscribed. Nothing will wake you.';
|
|
252
|
-
return
|
|
252
|
+
return `No nodes below you (0 subscriptions, ${unread} unread). ${tail}`;
|
|
253
253
|
}
|
|
254
254
|
const working = kids.filter((k) => k.status === 'active' || k.status === 'idle');
|
|
255
255
|
const liveWaking = working.filter((k) => k.active); // active sub to a live node = it will wake me
|
|
256
256
|
const done = kids.filter((k) => k.status === 'done' || k.status === 'canceled');
|
|
257
257
|
const dead = kids.filter((k) => k.status === 'dead');
|
|
258
|
-
let verdict;
|
|
259
258
|
let line;
|
|
260
259
|
if (dead.length > 0) {
|
|
261
|
-
verdict = 'attention';
|
|
262
260
|
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'}.`;
|
|
263
261
|
}
|
|
264
262
|
else if (liveWaking.length > 0) {
|
|
265
|
-
verdict = 'working';
|
|
266
263
|
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.`;
|
|
267
264
|
}
|
|
268
265
|
else if (unread > 0) {
|
|
269
|
-
verdict = 'ready';
|
|
270
266
|
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.`;
|
|
271
267
|
}
|
|
272
268
|
else {
|
|
273
|
-
verdict = 'idle';
|
|
274
269
|
line = 'Everything below you has finished and been drained \u2014 nothing is running. Continue your own work, or `crtr push final` to finish.';
|
|
275
270
|
}
|
|
276
271
|
const rows = kids.map((k) => {
|
|
@@ -279,10 +274,10 @@ const feedPeekLeaf = defineLeaf({
|
|
|
279
274
|
? `pushed ${fmtAge(k.last_push.ts)} [${k.last_push.kind}]${k.last_push.ref !== null ? ` ref:${k.last_push.ref}` : ''}`
|
|
280
275
|
: 'no push yet';
|
|
281
276
|
const glyph = STATUS_GLYPH[k.status] ?? '?';
|
|
282
|
-
return
|
|
277
|
+
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}`;
|
|
283
278
|
}).join('\n');
|
|
284
|
-
const
|
|
285
|
-
return
|
|
279
|
+
const summary = `${kids.length} node${kids.length === 1 ? '' : 's'} below you — ${working.length} working, ${done.length} done, ${dead.length} dead, ${unread} unread:`;
|
|
280
|
+
return `${line}\n\n${summary}\n${rows}`;
|
|
286
281
|
},
|
|
287
282
|
});
|
|
288
283
|
// ---------------------------------------------------------------------------
|
|
@@ -125,5 +125,20 @@ export const tmuxSpreadLeaf = defineLeaf({
|
|
|
125
125
|
focused: spread.focused,
|
|
126
126
|
};
|
|
127
127
|
},
|
|
128
|
-
render: (r) =>
|
|
128
|
+
render: (r) => {
|
|
129
|
+
const joined = r['children_joined'] ?? [];
|
|
130
|
+
const overflow = r['overflow'] ?? [];
|
|
131
|
+
const where = r['window'] !== undefined && r['window'] !== ''
|
|
132
|
+
? ` into window ${r['window']}${r['session'] !== undefined ? ` (session ${r['session']})` : ''}`
|
|
133
|
+
: '';
|
|
134
|
+
const lead = `Spread node${where} — ${joined.length} child pane(s) joined, ${overflow.length} overflow, ${r['focused'] === true ? 'window focused' : 'window not focused'}.`;
|
|
135
|
+
const blocks = [lead];
|
|
136
|
+
if (joined.length > 0) {
|
|
137
|
+
blocks.push(['Children joined:', ...joined.map((c) => `- ${c}`)].join('\n'));
|
|
138
|
+
}
|
|
139
|
+
if (overflow.length > 0) {
|
|
140
|
+
blocks.push(['Overflow (max_panes_per_window reached):', ...overflow.map((c) => `- ${c}`)].join('\n'));
|
|
141
|
+
}
|
|
142
|
+
return blocks.join('\n\n');
|
|
143
|
+
},
|
|
129
144
|
});
|
|
@@ -117,9 +117,13 @@ export const viewCycleLeaf = defineLeaf({
|
|
|
117
117
|
catch { /* best-effort */ }
|
|
118
118
|
return { cycled: true, view: targetId, from: current };
|
|
119
119
|
},
|
|
120
|
-
render: (r) =>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
render: (r) => {
|
|
121
|
+
if (r['cycled'] === true) {
|
|
122
|
+
return `Cycled the monitor to view "${r['view']}" (from "${r['from']}").`;
|
|
123
|
+
}
|
|
124
|
+
if (r['from'] !== undefined) {
|
|
125
|
+
return `No other view to switch to — the pane is still on "${r['from']}".`;
|
|
126
|
+
}
|
|
127
|
+
return 'Nothing to cycle — this pane is not hosting a view.';
|
|
128
|
+
},
|
|
125
129
|
});
|
|
@@ -181,11 +181,12 @@ export const viewRunLeaf = defineLeaf({
|
|
|
181
181
|
render: (result) => {
|
|
182
182
|
const hosted = result['hosted'];
|
|
183
183
|
if (hosted === 'window') {
|
|
184
|
-
return
|
|
184
|
+
return `Opened view "${result['view']}" as a window monitor — window ${result['window']}, pane ${result['pane']}.`;
|
|
185
185
|
}
|
|
186
186
|
if (hosted === 'split') {
|
|
187
|
-
return
|
|
187
|
+
return `Opened view "${result['view']}" as a split monitor — pane ${result['pane']}.`;
|
|
188
188
|
}
|
|
189
|
+
// In-pane host: the terminal was held until quit; nothing to report.
|
|
189
190
|
return '';
|
|
190
191
|
},
|
|
191
192
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/broker-attach-limits.test.ts
|
|
2
|
+
//
|
|
3
|
+
// T8 — the `crtr attach` acceptance gate, gates G4/G7/G8/G9 (controller
|
|
4
|
+
// arbitration, decoder overflow caps, backpressure shedding, and the load-bearing
|
|
5
|
+
// ONE-WRITER proof). Split out of broker-lifecycle.test.ts (see its header for
|
|
6
|
+
// the full file map); the tests are the original acceptance gate, unchanged, on
|
|
7
|
+
// their own isolated harness. Each test drives the REAL detached broker process +
|
|
8
|
+
// REAL view.sock with the enriched fake engine, using the PRODUCTION
|
|
9
|
+
// ViewSocketClient (helpers/broker-clients.ts) as the controller/observer — raw
|
|
10
|
+
// node:net only where the client lifecycle is awkward (G7 oversized line, G8
|
|
11
|
+
// stalled viewer). The engine is hosted IN-PROCESS by the broker, so engine pid
|
|
12
|
+
// == broker pid == node.pi_pid == boot.pid.
|
|
13
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
17
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
18
|
+
import { createAttachKit, delay, tok, frameHas, brokerLogText, lsofHolders, } from './helpers/broker-clients.js';
|
|
19
|
+
let h;
|
|
20
|
+
let root;
|
|
21
|
+
const kit = createAttachKit(() => h);
|
|
22
|
+
const { attach, attachUntil, connectRaw } = kit;
|
|
23
|
+
before(async () => {
|
|
24
|
+
if (!hasTmux())
|
|
25
|
+
return;
|
|
26
|
+
h = await createHarness({ sessionPrefix: 'crtr-brklim' });
|
|
27
|
+
root = h.spawnRoot('broker-attach-limits suite root');
|
|
28
|
+
});
|
|
29
|
+
after(async () => {
|
|
30
|
+
if (h !== undefined)
|
|
31
|
+
await h.dispose();
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
kit.closeAll();
|
|
35
|
+
});
|
|
36
|
+
const brokerPid = (id) => h.node(id).pi_pid;
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// G4 — arbitration + observer read. Guards: 2nd client is admitted observer, an
|
|
39
|
+
// observer prompt is rejected not_controller, BOTH clients receive the relay.
|
|
40
|
+
// Failure mode: two controllers, or an observer driving the engine, or fan-out
|
|
41
|
+
// that misses a viewer.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
test('G4 — second client is observer; observer prompt → error{not_controller}; both receive the stream', { skip: !hasTmux() }, async () => {
|
|
44
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G4');
|
|
45
|
+
const c1 = await attach(id, 'controller', 'g4-ctrl');
|
|
46
|
+
assert.equal(c1.welcome.role, 'controller', 'first client holds control');
|
|
47
|
+
const c2 = await attach(id, 'controller', 'g4-second'); // requests control; held → observer
|
|
48
|
+
assert.equal(c2.welcome.role, 'observer', 'second client is admitted read-only observer (first-attach-wins)');
|
|
49
|
+
c2.send({ type: 'prompt', text: 'observer must not drive' });
|
|
50
|
+
const err = await c2.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
|
|
51
|
+
assert.equal(err.code, 'not_controller', 'G4: observer prompt → error{not_controller}');
|
|
52
|
+
const token = tok('G4-BROADCAST');
|
|
53
|
+
c1.send({ type: 'prompt', text: token });
|
|
54
|
+
await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 controller received the stream');
|
|
55
|
+
await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 observer ALSO received the stream');
|
|
56
|
+
});
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// G7 — decoder overflow (guards C5 OOM). A client line over BROKER_READ_CAPS is
|
|
59
|
+
// cap-and-dropped; the broker survives and other clients are unaffected. Failure
|
|
60
|
+
// mode: an unbounded decoder buffer growing the broker to OOM.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
test('G7 — an oversized client line is dropped (frame_overflow), the broker survives, other clients unaffected', { skip: !hasTmux() }, async () => {
|
|
63
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G7');
|
|
64
|
+
const pid = brokerPid(id);
|
|
65
|
+
const boots = h.bootCount(id);
|
|
66
|
+
const survivor = await attach(id, 'observer', 'g7-survivor');
|
|
67
|
+
// 26 MiB with NO newline > BROKER_READ_CAPS.maxLineBytes (24 MiB) → the bounded
|
|
68
|
+
// FrameDecoder throws FrameOverflowError; the broker drops the peer.
|
|
69
|
+
const bad = await connectRaw(id, { read: true });
|
|
70
|
+
bad.writeRaw(Buffer.alloc(26 * 1024 * 1024, 0x78));
|
|
71
|
+
await bad.waitClosed('G7 oversized peer dropped by the broker');
|
|
72
|
+
await h.waitFor(() => /frame overflow/.test(brokerLogText(h, id)) || null, { label: 'G7 broker logged the frame-overflow drop' });
|
|
73
|
+
// The broker survives and the other client is unaffected: a fresh turn relays.
|
|
74
|
+
const token = tok('G7-AFTER');
|
|
75
|
+
h.fakeCmd(id, { cmd: 'stream', text: token });
|
|
76
|
+
await survivor.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G7 survivor still receives live frames');
|
|
77
|
+
assert.equal(isPidAlive(pid), true, 'G7: the broker survived the overflow');
|
|
78
|
+
assert.equal(brokerPid(id), pid, 'G7: broker pid unchanged');
|
|
79
|
+
assert.equal(h.bootCount(id), boots, 'G7: no reboot');
|
|
80
|
+
});
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// G8 — backpressure leak (guards M1). A stalled (non-reading) viewer is shed at
|
|
83
|
+
// the HWM (32 MiB byte cap) while the broker + other viewers are unaffected.
|
|
84
|
+
// Failure mode: an indefinitely-growing per-viewer queue (broker OOM) or a slow
|
|
85
|
+
// viewer back-pressuring the shared engine.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
test('G8 — a stalled viewer is dropped at the backpressure HWM; the broker + fast viewers are unaffected', { skip: !hasTmux() }, async () => {
|
|
88
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G8');
|
|
89
|
+
const pid = brokerPid(id);
|
|
90
|
+
const fast = await attach(id, 'observer', 'g8-fast'); // reads normally
|
|
91
|
+
// A helloed viewer that NEVER reads (paused socket). It must be in the broadcast
|
|
92
|
+
// set before the flood; hello, then a short beat for the broker to process it.
|
|
93
|
+
const stalled = await connectRaw(id, { read: false });
|
|
94
|
+
stalled.send({ type: 'hello', role: 'observer', client_id: 'g8-stalled' });
|
|
95
|
+
await delay(400);
|
|
96
|
+
// A fast event stream: ~60 MiB across 240 message_update frames. The stalled
|
|
97
|
+
// viewer's per-viewer backlog crosses the 32 MiB byte cap → dropped; the fast
|
|
98
|
+
// viewer drains between frames (per-update yield) and survives.
|
|
99
|
+
//
|
|
100
|
+
// The DROP SIGNAL is the broker's own log line, NOT the stalled socket's 'close':
|
|
101
|
+
// a PAUSED node socket does not surface the peer FIN/close until it is resumed,
|
|
102
|
+
// so the broker-side `backpressure high-water mark exceeded` line is the
|
|
103
|
+
// deterministic, race-free proof that the slow viewer was shed at the HWM.
|
|
104
|
+
const token = tok('G8-FLOOD');
|
|
105
|
+
h.fakeCmd(id, { cmd: 'stream', text: token, updates: 240, padBytes: 256 * 1024, tool: false });
|
|
106
|
+
await h.waitFor(() => /backpressure high-water mark exceeded/.test(brokerLogText(h, id)) || null, { label: 'G8 broker shed the stalled viewer at the HWM', timeoutMs: 30_000 });
|
|
107
|
+
// The fast viewer drained the WHOLE stream (agent_end is the terminal frame) —
|
|
108
|
+
// proving only the stalled viewer was shed, while the fast one was unaffected.
|
|
109
|
+
await fast.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G8 fast viewer received the whole stream', 30_000);
|
|
110
|
+
assert.equal(isPidAlive(pid), true, 'G8: the broker survived the slow-viewer flood');
|
|
111
|
+
assert.equal(brokerPid(id), pid, 'G8: broker pid unchanged (the engine was not back-pressured into a restart)');
|
|
112
|
+
});
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// G9 — the load-bearing ONE-WRITER assertion. Across attach→detach→reattach the
|
|
115
|
+
// broker pid AND engine pid (the same, in-process) are UNCHANGED and no second
|
|
116
|
+
// engine is ever spawned; the viewer holds ONLY the socket (never the .jsonl).
|
|
117
|
+
// Failure mode: a viewer that spawns/forks a second engine or opens the session
|
|
118
|
+
// .jsonl — the corruption the headless design exists to prevent.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
test('G9 — one-writer: broker/engine pid stable across attach→detach→reattach; no second engine; viewer holds only the socket', { skip: !hasTmux() }, async () => {
|
|
121
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G9');
|
|
122
|
+
const pid0 = brokerPid(id);
|
|
123
|
+
const boots0 = h.bootCount(id);
|
|
124
|
+
const c1 = await attach(id, 'controller', 'g9-a');
|
|
125
|
+
const a = tok('G9-A');
|
|
126
|
+
c1.send({ type: 'prompt', text: a });
|
|
127
|
+
await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, a), 'G9 first turn relayed');
|
|
128
|
+
c1.send({ type: 'bye' });
|
|
129
|
+
c1.close();
|
|
130
|
+
await delay(300);
|
|
131
|
+
assert.equal(brokerPid(id), pid0, 'G9: broker/engine pid unchanged after detach');
|
|
132
|
+
assert.equal(isPidAlive(pid0), true, 'G9: the one engine is still alive after detach');
|
|
133
|
+
assert.equal(h.bootCount(id), boots0, 'G9: no second engine spawned across detach');
|
|
134
|
+
// attachUntil (not a fixed sleep) synchronizes on the controller handoff: the
|
|
135
|
+
// detach's controllerId=null lands a beat after close, so retry until the
|
|
136
|
+
// reattach is admitted controller before driving the second prompt.
|
|
137
|
+
const c2 = await attachUntil(id, 'controller', 'g9-b', (x) => x.welcome.role === 'controller', 'G9 reattach re-takes control');
|
|
138
|
+
assert.equal(c2.welcome.role, 'controller', 'G9: the reattached client drives the SAME engine as controller');
|
|
139
|
+
const b = tok('G9-B');
|
|
140
|
+
c2.send({ type: 'prompt', text: b });
|
|
141
|
+
await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, b), 'G9 reattached controller drives the SAME engine');
|
|
142
|
+
assert.equal(brokerPid(id), pid0, 'G9: STILL the same broker/engine pid after reattach (one writer, never two)');
|
|
143
|
+
assert.equal(isPidAlive(pid0), true, 'G9: the single engine is alive across the full cycle');
|
|
144
|
+
assert.equal(h.bootCount(id), boots0, 'G9: exactly one engine boot across attach→detach→reattach');
|
|
145
|
+
// fd-check (best-effort): the viewer host (this test process) must NOT hold the
|
|
146
|
+
// session .jsonl — only the broker may. With the fake engine the .jsonl is not
|
|
147
|
+
// held open continuously, so the meaningful assertion is that the VIEWER never
|
|
148
|
+
// appears among its holders (and any holder that exists is the broker).
|
|
149
|
+
const jsonl = join(h.home, 'nodes', id, 'fake-session.jsonl');
|
|
150
|
+
const holders = lsofHolders(jsonl);
|
|
151
|
+
if (holders !== null) {
|
|
152
|
+
assert.ok(!holders.includes(process.pid), 'G9: the attach client (this process) does NOT hold the session .jsonl');
|
|
153
|
+
for (const holder of holders) {
|
|
154
|
+
assert.equal(holder, pid0, 'G9: the ONLY holder of the session .jsonl is the broker/engine');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|