@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
|
@@ -57,7 +57,6 @@ export const HELP_ICON = String.fromCodePoint(HELP_ICON_CP);
|
|
|
57
57
|
* private-use-area icons chosen per family concept (see command-icons.md). */
|
|
58
58
|
const FAMILY_ICON_CP = {
|
|
59
59
|
canvas: 0xf0e8,
|
|
60
|
-
feed: 0xf09e,
|
|
61
60
|
human: 0xf007,
|
|
62
61
|
memory: 0xf02d,
|
|
63
62
|
node: 0xf192,
|
|
@@ -80,17 +79,14 @@ const LEAF_ICON_CP = {
|
|
|
80
79
|
'canvas history search': 0xf002,
|
|
81
80
|
'canvas history read': 0xf02d,
|
|
82
81
|
'canvas history show': 0xf1da,
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
'
|
|
82
|
+
'sys daemon start': 0xf04b,
|
|
83
|
+
'sys daemon status': 0xf111,
|
|
84
|
+
'sys daemon stop': 0xf04d,
|
|
86
85
|
'canvas chord': 0xf001,
|
|
87
|
-
'
|
|
86
|
+
'surface tmux-spread': 0xf009,
|
|
88
87
|
'canvas issue new': 0xf188,
|
|
89
88
|
'canvas prune': 0xf0c4,
|
|
90
89
|
'canvas rebuild-index': 0xf0ad,
|
|
91
|
-
'feed read': 0xf02d,
|
|
92
|
-
'feed message': 0xf0e0,
|
|
93
|
-
'feed peek': 0xf06e,
|
|
94
90
|
'human ask': 0xf059,
|
|
95
91
|
'human review': 0xf044,
|
|
96
92
|
'human notify': 0xf0f3,
|
|
@@ -262,7 +258,7 @@ function summarizeMemoryLint(text) {
|
|
|
262
258
|
return null;
|
|
263
259
|
}
|
|
264
260
|
// ---------------------------------------------------------------------------
|
|
265
|
-
// Shared table/count parsing helpers for the canvas/node
|
|
261
|
+
// Shared table/count parsing helpers for the canvas/node runtime cockpit
|
|
266
262
|
// batch below. These parse the exact markdown shapes core/render.ts's generic
|
|
267
263
|
// renderArray/renderResult produce (a `- field: N` bullet for a scalar count,
|
|
268
264
|
// an `N <label>:` count lead-in followed by a GFM table for an array of
|
|
@@ -738,7 +734,7 @@ function summarizeCanvasHistoryShow(text) {
|
|
|
738
734
|
const digest = summarizeTopCounts(typeCounts, 5, (name, count) => `${name}s×${count}`);
|
|
739
735
|
return [{ text: `${countMatch[1]} artifacts` }, { text: digest, tone: 'muted' }];
|
|
740
736
|
}
|
|
741
|
-
/** `crtr
|
|
737
|
+
/** `crtr sys daemon start` (src/commands/sys/daemon.ts:40, `spawnDaemon()` in
|
|
742
738
|
* src/daemon/manage.ts:89) has no custom `render`; the generic renderer
|
|
743
739
|
* bullets `{started:true,pid}` or `{started:false,existing_pid}`. `pid` is
|
|
744
740
|
* unconditionally set alongside `started:true` (manage.ts:125) — a
|
|
@@ -747,7 +743,7 @@ function summarizeCanvasHistoryShow(text) {
|
|
|
747
743
|
* false branch (a `readPidfile() ?? undefined` race), so that one stays
|
|
748
744
|
* optional. Returns null when `started` isn't a plain boolean bullet, or
|
|
749
745
|
* is `true` without a `pid` bullet. */
|
|
750
|
-
function
|
|
746
|
+
function summarizeSysDaemonStart(text) {
|
|
751
747
|
const m = /^- started:\s*(true|false)\s*$/m.exec(text);
|
|
752
748
|
if (m === null)
|
|
753
749
|
return null;
|
|
@@ -760,14 +756,14 @@ function summarizeCanvasDaemonStart(text) {
|
|
|
760
756
|
const pid = /^- existing_pid:\s*(\d+)\s*$/m.exec(text)?.[1];
|
|
761
757
|
return [{ text: `already running${pid !== undefined ? ` · pid ${pid}` : ''}`, tone: 'muted' }];
|
|
762
758
|
}
|
|
763
|
-
/** `crtr
|
|
759
|
+
/** `crtr sys daemon status` (src/commands/sys/daemon.ts:65) has no custom
|
|
764
760
|
* `render`; the generic renderer bullets `{running:true,pid}` or
|
|
765
761
|
* `{running:false}`. `pid` is unconditionally set alongside `running:true`
|
|
766
762
|
* (daemon.ts:70 — the true branch only fires once `readPidfile()` already
|
|
767
763
|
* returned a pid), so a true-with-no-pid shape is unrecognized. Returns
|
|
768
764
|
* null when `running` isn't a plain boolean bullet, or is `true` without a
|
|
769
765
|
* `pid` bullet. */
|
|
770
|
-
function
|
|
766
|
+
function summarizeSysDaemonStatus(text) {
|
|
771
767
|
const m = /^- running:\s*(true|false)\s*$/m.exec(text);
|
|
772
768
|
if (m === null)
|
|
773
769
|
return null;
|
|
@@ -779,13 +775,13 @@ function summarizeCanvasDaemonStatus(text) {
|
|
|
779
775
|
}
|
|
780
776
|
return [{ text: 'not running', tone: 'error' }];
|
|
781
777
|
}
|
|
782
|
-
/** `crtr
|
|
778
|
+
/** `crtr sys daemon stop` (src/commands/sys/daemon.ts:93) has no custom
|
|
783
779
|
* `render`; the generic renderer bullets `{stopped:true,pid}` or
|
|
784
780
|
* `{stopped:false}`. `pid` is unconditionally set alongside `stopped:true`
|
|
785
781
|
* (daemon.ts:98 — the signaled pid is echoed back in the same return), so a
|
|
786
782
|
* true-with-no-pid shape is unrecognized. Returns null when `stopped` isn't
|
|
787
783
|
* a plain boolean bullet, or is `true` without a `pid` bullet. */
|
|
788
|
-
function
|
|
784
|
+
function summarizeSysDaemonStop(text) {
|
|
789
785
|
const m = /^- stopped:\s*(true|false)\s*$/m.exec(text);
|
|
790
786
|
if (m === null)
|
|
791
787
|
return null;
|
|
@@ -982,97 +978,6 @@ function summarizeNodeTriggersList(text) {
|
|
|
982
978
|
out.push({ text: `… ${remaining} more`, tone: 'muted' });
|
|
983
979
|
return out;
|
|
984
980
|
}
|
|
985
|
-
/** `crtr feed read` (src/commands/push.ts:209) has two shapes: drained
|
|
986
|
-
* (`${n} unread pointer(s) drained from ${node_id}'s inbox.\n\n${digest}`,
|
|
987
|
-
* where the digest — `coalesce()`, src/core/feed/inbox.ts:225 — groups by
|
|
988
|
-
* sender as `From ${sender} — ${N} update(s):` sections) or empty (`No
|
|
989
|
-
* unread pointers for ${node_id} (unread=0).\n\n${explanation}`).
|
|
990
|
-
* `coalesce()` always groups drained entries under at least one
|
|
991
|
-
* From-section, so a positive drained count with zero parsed senders is a
|
|
992
|
-
* shape mismatch, not a 0-sender success. Returns null on any other
|
|
993
|
-
* shape. */
|
|
994
|
-
function summarizeFeedRead(text) {
|
|
995
|
-
const lines = text.split('\n');
|
|
996
|
-
const first = lines[0] ?? '';
|
|
997
|
-
const drained = /^(\d+) unread pointers? drained from (.+)'s inbox\.$/.exec(first);
|
|
998
|
-
if (drained !== null) {
|
|
999
|
-
const n = Number(drained[1]);
|
|
1000
|
-
const senders = [...new Set([...text.matchAll(/^From (.+?) — \d+ update/gm)].map((m) => m[1]))];
|
|
1001
|
-
if (n > 0 && senders.length === 0)
|
|
1002
|
-
return null;
|
|
1003
|
-
const out = [{ text: `${n} pointer${n === 1 ? '' : 's'} drained from ${senders.length} node${senders.length === 1 ? '' : 's'}` }];
|
|
1004
|
-
if (senders.length > 0) {
|
|
1005
|
-
const shown = senders.slice(0, 5);
|
|
1006
|
-
const remaining = senders.length - shown.length;
|
|
1007
|
-
out.push({ text: remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', '), tone: 'muted' });
|
|
1008
|
-
}
|
|
1009
|
-
return out;
|
|
1010
|
-
}
|
|
1011
|
-
if (/^No unread pointers for .+ \(unread=0\)\.$/.test(first)) {
|
|
1012
|
-
return [{ text: 'nothing unread', tone: 'muted' }];
|
|
1013
|
-
}
|
|
1014
|
-
return null;
|
|
1015
|
-
}
|
|
1016
|
-
/** Teaser cap for `feed message`'s body preview — mirrors
|
|
1017
|
-
* MEMORY_READ_CONTENT_LINES's role for a different leaf. */
|
|
1018
|
-
const FEED_MESSAGE_TEASER_LINES = 3;
|
|
1019
|
-
/** `crtr feed message` (src/commands/push.ts:264) has two shapes: found
|
|
1020
|
-
* (`Full message ${id} (from ${from}):\n\n${body}`) or not-found (a short
|
|
1021
|
-
* line — left as null, the generic fallback is fine for one line). */
|
|
1022
|
-
function summarizeFeedMessage(text) {
|
|
1023
|
-
const lines = text.split('\n');
|
|
1024
|
-
const head = /^Full message (\S+) \(from (.+)\):$/.exec(lines[0] ?? '');
|
|
1025
|
-
if (head === null)
|
|
1026
|
-
return null;
|
|
1027
|
-
const [, id, from] = head;
|
|
1028
|
-
const bodyLines = lines.slice(1);
|
|
1029
|
-
let idx = 0;
|
|
1030
|
-
while (idx < bodyLines.length && bodyLines[idx].trim() === '')
|
|
1031
|
-
idx++;
|
|
1032
|
-
const content = bodyLines.slice(idx);
|
|
1033
|
-
const teaser = content.slice(0, FEED_MESSAGE_TEASER_LINES);
|
|
1034
|
-
const hasMore = content.slice(FEED_MESSAGE_TEASER_LINES).some((l) => l.trim() !== '');
|
|
1035
|
-
if (hasMore)
|
|
1036
|
-
teaser.push('…');
|
|
1037
|
-
const out = [{ text: `${id} · from ${from}` }];
|
|
1038
|
-
if (teaser.some((l) => l.trim() !== '')) {
|
|
1039
|
-
out.push({ text: '' });
|
|
1040
|
-
for (const l of teaser)
|
|
1041
|
-
out.push({ text: l });
|
|
1042
|
-
}
|
|
1043
|
-
return out;
|
|
1044
|
-
}
|
|
1045
|
-
/** `crtr feed peek` (src/commands/push.ts:351) has two shapes: zero-kids
|
|
1046
|
-
* (`No nodes below you (0 subscriptions, ${unread} unread). ${tail}` — the
|
|
1047
|
-
* "0" is a hardcoded literal, not a variable) or nonzero (`${verdictLine}
|
|
1048
|
-
* \n\n${N} node(s) below you — ${working} working, ${done} done, ${dead}
|
|
1049
|
-
* dead, ${unread} unread:\n${rows}`). Returns null on any other shape. */
|
|
1050
|
-
function summarizeFeedPeek(text) {
|
|
1051
|
-
const zero = /^No nodes below you \(0 subscriptions, (\d+) unread\)\./.exec(text.split('\n')[0] ?? '');
|
|
1052
|
-
if (zero !== null) {
|
|
1053
|
-
const unread = Number(zero[1]);
|
|
1054
|
-
return unread > 0
|
|
1055
|
-
? [{ text: `0 below · ${unread} unread`, tone: 'warning' }]
|
|
1056
|
-
: [{ text: 'PASS · nothing below', tone: 'success' }];
|
|
1057
|
-
}
|
|
1058
|
-
const m = /(\d+) nodes? below you — (\d+) working, (\d+) done, (\d+) dead, (\d+) unread:/.exec(text);
|
|
1059
|
-
if (m === null)
|
|
1060
|
-
return null;
|
|
1061
|
-
const [, , workingStr, doneStr, deadStr, unreadStr] = m;
|
|
1062
|
-
const working = Number(workingStr);
|
|
1063
|
-
const done = Number(doneStr);
|
|
1064
|
-
const dead = Number(deadStr);
|
|
1065
|
-
const unread = Number(unreadStr);
|
|
1066
|
-
const parts = [`${working} working`, `${done} done`];
|
|
1067
|
-
if (dead > 0)
|
|
1068
|
-
parts.push(`${dead} dead`);
|
|
1069
|
-
const lines = [{ text: parts.join(' · ') }];
|
|
1070
|
-
if (dead > 0)
|
|
1071
|
-
lines.push({ text: `${dead} dead — will not wake you`, tone: 'warning' });
|
|
1072
|
-
else if (unread > 0)
|
|
1073
|
-
lines.push({ text: `${unread} unread`, tone: 'warning' });
|
|
1074
|
-
return lines;
|
|
1075
|
-
}
|
|
1076
981
|
/** `crtr push final` (makeTierLeaf's shared render, src/commands/push.ts:161)
|
|
1077
982
|
* leads with a fixed sentence ('Result recorded — node finished; ...')
|
|
1078
983
|
* followed unconditionally by `\n\n- report: ${report_path}` (`report_path`
|
|
@@ -1996,18 +1901,15 @@ const SUMMARIZERS = [
|
|
|
1996
1901
|
{ path: 'canvas history search', run: summarizeCanvasHistorySearch },
|
|
1997
1902
|
{ path: 'canvas history read', run: summarizeCanvasHistoryRead },
|
|
1998
1903
|
{ path: 'canvas history show', run: summarizeCanvasHistoryShow },
|
|
1999
|
-
{ path: '
|
|
2000
|
-
{ path: '
|
|
2001
|
-
{ path: '
|
|
1904
|
+
{ path: 'sys daemon start', run: summarizeSysDaemonStart },
|
|
1905
|
+
{ path: 'sys daemon status', run: summarizeSysDaemonStatus },
|
|
1906
|
+
{ path: 'sys daemon stop', run: summarizeSysDaemonStop },
|
|
2002
1907
|
{ path: 'canvas issue new', run: summarizeCanvasIssueNew },
|
|
2003
1908
|
{ path: 'node new', run: summarizeNodeNew },
|
|
2004
1909
|
{ path: 'node msg', run: summarizeNodeMsg },
|
|
2005
1910
|
{ path: 'node yield', run: summarizeNodeYield },
|
|
2006
1911
|
{ path: 'node lifecycle close', run: summarizeNodeLifecycleClose },
|
|
2007
1912
|
{ path: 'node triggers list', run: summarizeNodeTriggersList },
|
|
2008
|
-
{ path: 'feed read', run: summarizeFeedRead },
|
|
2009
|
-
{ path: 'feed message', run: summarizeFeedMessage },
|
|
2010
|
-
{ path: 'feed peek', run: summarizeFeedPeek },
|
|
2011
1913
|
{ path: 'push urgent', run: summarizePushUrgent },
|
|
2012
1914
|
{ path: 'push final', run: summarizePushFinal },
|
|
2013
1915
|
{ path: 'human ask', run: summarizeHumanAsk },
|
package/dist/core/render.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The raw JSON object is still available behind the `--json` global for tooling.
|
|
7
7
|
//
|
|
8
8
|
// A leaf may hand `defineLeaf` a bespoke `render(result)` for instruction-shaped
|
|
9
|
-
// output that leads with the outcome (see `node new`, `push
|
|
9
|
+
// output that leads with the outcome (see `node new`, `push`).
|
|
10
10
|
// Everything else falls through to the schema-driven generic renderer here, so
|
|
11
11
|
// every command obeys the markdown paradigm without a hand-written renderer.
|
|
12
12
|
// `renderError` is the one sanctioned exception: a failure is a different domain
|
|
@@ -6,3 +6,18 @@ export declare function clearBusy(nodeId: string): void;
|
|
|
6
6
|
* site — a stale marker from a crashed pi is harmless because the dead pid
|
|
7
7
|
* fails the AND. */
|
|
8
8
|
export declare function isBusy(nodeId: string): boolean;
|
|
9
|
+
/** Re-touch the mid-turn marker's mtime — the daemon's wedge-detection
|
|
10
|
+
* heartbeat (crtrd.ts `handleWedgeDetection`). Called from canvas-stophook on
|
|
11
|
+
* every engine-level sign of life WITHIN a turn (a tool starting/streaming/
|
|
12
|
+
* ending, a streamed assistant token, a turn ending) so the marker's mtime
|
|
13
|
+
* always reflects the last moment the engine visibly did something, not just
|
|
14
|
+
* when the turn began. No-ops when the node isn't currently busy — a touch
|
|
15
|
+
* arriving after `clearBusy` (e.g. a late/queued event racing agent_end) must
|
|
16
|
+
* never resurrect a stale marker. Best-effort. */
|
|
17
|
+
export declare function touchBusy(nodeId: string): void;
|
|
18
|
+
/** The mid-turn marker's mtime: the last moment ANY engine-level progress was
|
|
19
|
+
* observed while the node was busy (see `touchBusy`). Null when the node isn't
|
|
20
|
+
* busy or the marker is unreadable. This is the sole heartbeat the daemon's
|
|
21
|
+
* wedge detector reads — it needs no new instrumentation beyond re-touching an
|
|
22
|
+
* already-existing file. */
|
|
23
|
+
export declare function busySince(nodeId: string): number | null;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// read site, so a stale marker (process crashed mid-turn without firing
|
|
14
14
|
// agent_end) is harmless — the dead pid fails the AND and the node is reaped.
|
|
15
15
|
// No db migration, atomic touch/unlink, best-effort (never throws).
|
|
16
|
-
import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { existsSync, writeFileSync, rmSync, mkdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { jobDir } from '../canvas/index.js';
|
|
19
19
|
function busyPath(nodeId) {
|
|
@@ -44,3 +44,34 @@ export function clearBusy(nodeId) {
|
|
|
44
44
|
export function isBusy(nodeId) {
|
|
45
45
|
return existsSync(busyPath(nodeId));
|
|
46
46
|
}
|
|
47
|
+
/** Re-touch the mid-turn marker's mtime — the daemon's wedge-detection
|
|
48
|
+
* heartbeat (crtrd.ts `handleWedgeDetection`). Called from canvas-stophook on
|
|
49
|
+
* every engine-level sign of life WITHIN a turn (a tool starting/streaming/
|
|
50
|
+
* ending, a streamed assistant token, a turn ending) so the marker's mtime
|
|
51
|
+
* always reflects the last moment the engine visibly did something, not just
|
|
52
|
+
* when the turn began. No-ops when the node isn't currently busy — a touch
|
|
53
|
+
* arriving after `clearBusy` (e.g. a late/queued event racing agent_end) must
|
|
54
|
+
* never resurrect a stale marker. Best-effort. */
|
|
55
|
+
export function touchBusy(nodeId) {
|
|
56
|
+
try {
|
|
57
|
+
if (!existsSync(busyPath(nodeId)))
|
|
58
|
+
return;
|
|
59
|
+
writeFileSync(busyPath(nodeId), '');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* best-effort */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The mid-turn marker's mtime: the last moment ANY engine-level progress was
|
|
66
|
+
* observed while the node was busy (see `touchBusy`). Null when the node isn't
|
|
67
|
+
* busy or the marker is unreadable. This is the sole heartbeat the daemon's
|
|
68
|
+
* wedge detector reads — it needs no new instrumentation beyond re-touching an
|
|
69
|
+
* already-existing file. */
|
|
70
|
+
export function busySince(nodeId) {
|
|
71
|
+
try {
|
|
72
|
+
return statSync(busyPath(nodeId)).mtimeMs;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
import { type SubscriptionRef } from '../canvas/index.js';
|
|
2
|
+
/** The doctrine wake: fan a system notice to every given subscriber of a node
|
|
3
|
+
* whose lifecycle just ended out from under it (closed, or crashed to dead) —
|
|
4
|
+
* the runtime guarantee that a manager waiting dormant on a child always
|
|
5
|
+
* learns of its terminal outcome instead of hanging forever. Active subscriber
|
|
6
|
+
* → inbox (wakes a dormant manager via its live watcher / the daemon's
|
|
7
|
+
* dormant-revive pass); passive → the passive accumulator (delivered, not
|
|
8
|
+
* woken). Shared by closeNode (step 4, below) and reviveNode's launch-refusal
|
|
9
|
+
* crash path (revive.ts). */
|
|
10
|
+
export declare function fanDoctrineWake(fromId: string, subscribers: readonly SubscriptionRef[], label: string, data: Record<string, unknown>): void;
|
|
1
11
|
export interface CloseNodeResult {
|
|
2
12
|
/** The focused node that was closed — the cascade root. */
|
|
3
13
|
root: string;
|
|
@@ -36,6 +36,23 @@ import { headlessBrokerHost } from './host.js';
|
|
|
36
36
|
import { tearDownNode, reapIfEmpty } from './placement.js';
|
|
37
37
|
import { appendInbox } from '../feed/inbox.js';
|
|
38
38
|
import { appendPassive } from '../feed/passive.js';
|
|
39
|
+
/** The doctrine wake: fan a system notice to every given subscriber of a node
|
|
40
|
+
* whose lifecycle just ended out from under it (closed, or crashed to dead) —
|
|
41
|
+
* the runtime guarantee that a manager waiting dormant on a child always
|
|
42
|
+
* learns of its terminal outcome instead of hanging forever. Active subscriber
|
|
43
|
+
* → inbox (wakes a dormant manager via its live watcher / the daemon's
|
|
44
|
+
* dormant-revive pass); passive → the passive accumulator (delivered, not
|
|
45
|
+
* woken). Shared by closeNode (step 4, below) and reviveNode's launch-refusal
|
|
46
|
+
* crash path (revive.ts). */
|
|
47
|
+
export function fanDoctrineWake(fromId, subscribers, label, data) {
|
|
48
|
+
for (const sub of subscribers) {
|
|
49
|
+
const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
|
|
50
|
+
if (sub.active)
|
|
51
|
+
appendInbox(sub.node_id, notice);
|
|
52
|
+
else
|
|
53
|
+
appendPassive(sub.node_id, notice);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
39
56
|
/** The set of nodes to close: the root plus every descendant reachable down the
|
|
40
57
|
* subscriptions spine, all of whose managers are themselves in the set. Grown
|
|
41
58
|
* to a fixpoint — a node added this pass can qualify its own children next
|
|
@@ -186,19 +203,7 @@ export function closeNode(rootId, opts = {}) {
|
|
|
186
203
|
// never fires inside a self-contained cascade. Active → inbox (wakes a
|
|
187
204
|
// dormant manager via its live watcher / the daemon's dormant-revive
|
|
188
205
|
// second pass); passive → passive accumulator (delivered, not woken).
|
|
189
|
-
|
|
190
|
-
const notice = {
|
|
191
|
-
from: id,
|
|
192
|
-
tier: 'normal',
|
|
193
|
-
kind: 'message',
|
|
194
|
-
label: `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`,
|
|
195
|
-
data: { reason: 'child-closed', child: id },
|
|
196
|
-
};
|
|
197
|
-
if (sub.active)
|
|
198
|
-
appendInbox(sub.node_id, notice);
|
|
199
|
-
else
|
|
200
|
-
appendPassive(sub.node_id, notice);
|
|
201
|
-
}
|
|
206
|
+
fanDoctrineWake(id, survivors, `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
|
|
202
207
|
closed.push(id);
|
|
203
208
|
}
|
|
204
209
|
catch {
|
|
@@ -5,6 +5,10 @@ const POLICIES = {
|
|
|
5
5
|
auth: { kind: 'auth', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
|
|
6
6
|
protocol: { kind: 'protocol', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
|
|
7
7
|
other: { kind: 'other', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
|
|
8
|
+
// A wedge is a daemon-detected stall, never auto-retried — recovery is a
|
|
9
|
+
// deliberate agent/operator decision (kill the runaway subprocess), not a
|
|
10
|
+
// provider back-off.
|
|
11
|
+
wedged: { kind: 'wedged', gate: 'none', max: 0, baseDelayMs: 0, capDelayMs: 0, fatal: true },
|
|
8
12
|
};
|
|
9
13
|
export function policyFor(kind) {
|
|
10
14
|
return POLICIES[kind];
|
|
@@ -107,8 +107,8 @@ export function drainBearings(meta) {
|
|
|
107
107
|
// Consume the one-shot yield note (deleted on read) BEFORE the kickoff lists
|
|
108
108
|
// the context dir, so it never shows up there.
|
|
109
109
|
const yieldMsg = consumeYieldMessage(nodeId);
|
|
110
|
-
// Drain the feed: read unread since the cursor and advance it past them, so
|
|
111
|
-
//
|
|
110
|
+
// Drain the feed: read unread since the cursor and advance it past them, so
|
|
111
|
+
// only entries that arrive after this point still count as unread.
|
|
112
112
|
const cursor = readCursor(nodeId);
|
|
113
113
|
const entries = readInboxSince(nodeId, cursor);
|
|
114
114
|
let unreadDigest = null;
|
|
@@ -178,10 +178,8 @@ function feedBlock(nodeId, unreadDigest) {
|
|
|
178
178
|
const subj = n === 1 ? 'it is' : 'they are';
|
|
179
179
|
const pron = n === 1 ? 'it' : 'they';
|
|
180
180
|
const verb = n === 1 ? 'pushes' : 'push';
|
|
181
|
-
// State aliveness + the automatic wake at the source
|
|
182
|
-
//
|
|
183
|
-
// burned a turn on `feed read`/`feed peek` to confirm. Asserting it here
|
|
184
|
-
// removes the reason to check.
|
|
181
|
+
// State aliveness + the automatic wake at the source explicitly, so there is
|
|
182
|
+
// no reason to check, poll, or verify separately.
|
|
185
183
|
lines.push(`Awaiting ${n} node${n === 1 ? '' : 's'} — ${subj} alive and running right now, and will wake you the moment ${pron} ${verb}. The wake is automatic; nothing to check, poll, or verify.`);
|
|
186
184
|
for (const m of awaiting)
|
|
187
185
|
lines.push(` - ${m.name} (${m.node_id}) — ${m.status}`);
|
|
@@ -203,7 +201,7 @@ function feedBlock(nodeId, unreadDigest) {
|
|
|
203
201
|
lines.push('', `Report history on disk (collapsed): ${total} report${total === 1 ? '' : 's'} across ${history.length} subscribed node${history.length === 1 ? '' : 's'}. This is a catch-up index for reports your cursor already passed; read only what matters.`, ...visible.map((h) => {
|
|
204
202
|
const ref = `${h.nodeId}:reports/${h.latest}`;
|
|
205
203
|
return ` - ${h.name} (${h.nodeId}): ${h.count} report${h.count === 1 ? '' : 's'}; latest ${reportKind(h.latest)}: ${ref}`;
|
|
206
|
-
}), ...(hidden > 0 ? [` - … ${hidden} more subscribed node${hidden === 1 ? '' : 's'} with reports hidden`] : []), 'Expand: `crtr canvas history read <ref>` for one listed report; `crtr canvas history show <node-id> --type report` for that node\'s full report list; `crtr
|
|
204
|
+
}), ...(hidden > 0 ? [` - … ${hidden} more subscribed node${hidden === 1 ? '' : 's'} with reports hidden`] : []), 'Expand: `crtr canvas history read <ref>` for one listed report; `crtr canvas history show <node-id> --type report` for that node\'s full report list; `crtr canvas history show <node-id> --type inbox` for cursor-independent inbox history including full message bodies.');
|
|
207
205
|
}
|
|
208
206
|
return `<feed>\n${lines.join('\n')}\n</feed>`;
|
|
209
207
|
}
|
|
@@ -38,7 +38,7 @@ import { isPidAlive } from '../canvas/pid.js';
|
|
|
38
38
|
// verbs — respawnPane, respawnPaneDetached, swapPaneInPlace, breakPaneToSession,
|
|
39
39
|
// joinPane, setWindowOption, setRemainOnExit — are deleted). `breakPane` +
|
|
40
40
|
// `selectLayout` are FRESH minimal viewer-chrome helpers (not the deleted
|
|
41
|
-
// engine-in-pane `breakPaneToSession`): `
|
|
41
|
+
// engine-in-pane `breakPaneToSession`): `surface tmux-spread` breaks the caller's
|
|
42
42
|
// viewer pane into its own window and tiles sibling viewers beside it.
|
|
43
43
|
// `piCommand` + `paneCurrentPath` are KEPT: the sessionless surface-view system
|
|
44
44
|
// (view-cycle.ts / view-run.ts) builds its pane commands with them, and the §5.1
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
//
|
|
18
18
|
// reviveNode remains the ONLY sanctioned launcher of the node engine.
|
|
19
19
|
import { existsSync } from 'node:fs';
|
|
20
|
-
import { getNode, updateNode, clearPid, fullName, cancelDeadlinesFor, } from '../canvas/index.js';
|
|
20
|
+
import { getNode, updateNode, clearPid, fullName, cancelDeadlinesFor, subscribersOf, } from '../canvas/index.js';
|
|
21
21
|
import { transition } from './lifecycle.js';
|
|
22
|
+
import { fanDoctrineWake } from './close.js';
|
|
22
23
|
import { buildPiArgv } from './launch.js';
|
|
23
24
|
import { buildReviveKickoff, drainBearings } from './kickoff.js';
|
|
24
25
|
import { headlessBrokerHost } from './host.js';
|
|
@@ -163,6 +164,13 @@ export function reviveNode(nodeId, opts) {
|
|
|
163
164
|
catch (error) {
|
|
164
165
|
try {
|
|
165
166
|
transition(nodeId, 'crash');
|
|
167
|
+
// Doctrine wake: a launch-refusal here marks the node dead with NOTHING
|
|
168
|
+
// else fanning that out — the daemon's own auto-revive is the most common
|
|
169
|
+
// caller (superviseTick, best-effort try/catch, stderr-only on failure), so
|
|
170
|
+
// without this a manager waiting dormant on this child hangs forever,
|
|
171
|
+
// never told it will not come back on its own. Mirrors closeNode's step-4
|
|
172
|
+
// fan-out (same subscribers table, same active/passive split).
|
|
173
|
+
fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead. It will NOT resume on its own.`, { reason: 'child-crashed', child: nodeId });
|
|
166
174
|
}
|
|
167
175
|
catch {
|
|
168
176
|
/* best-effort cleanup */
|
|
@@ -78,7 +78,7 @@ export declare function renameWindow(window: string, name: string): boolean;
|
|
|
78
78
|
* the break never falls back to tmux's global current pane. The pane keeps its
|
|
79
79
|
* durable `%id` across the move (only its window changes), so callers can keep
|
|
80
80
|
* using the same pane handle. Returns the new `{window, pane}` location, or null
|
|
81
|
-
* if tmux fails. Used by `
|
|
81
|
+
* if tmux fails. Used by `surface tmux-spread` to lift the caller's viewer into a
|
|
82
82
|
* fresh window before tiling sibling viewers beside it. */
|
|
83
83
|
export declare function breakPane(pane: string): {
|
|
84
84
|
window: string;
|
|
@@ -187,7 +187,7 @@ export declare function windowAlive(session: string | null | undefined, window:
|
|
|
187
187
|
* to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
|
|
188
188
|
export declare function selectWindow(session: string, window: string): boolean;
|
|
189
189
|
/** Apply a named tmux layout to a window (`tmux select-layout -t <window>
|
|
190
|
-
* <layout>`). `
|
|
190
|
+
* <layout>`). `surface tmux-spread` calls it with `tiled` to evenly grid every
|
|
191
191
|
* viewer pane in the spread window (and between splits, to redistribute space so
|
|
192
192
|
* the next split has room). Best-effort; false if tmux fails. */
|
|
193
193
|
export declare function selectLayout(window: string, layout: string): boolean;
|
|
@@ -176,7 +176,7 @@ export function renameWindow(window, name) {
|
|
|
176
176
|
* the break never falls back to tmux's global current pane. The pane keeps its
|
|
177
177
|
* durable `%id` across the move (only its window changes), so callers can keep
|
|
178
178
|
* using the same pane handle. Returns the new `{window, pane}` location, or null
|
|
179
|
-
* if tmux fails. Used by `
|
|
179
|
+
* if tmux fails. Used by `surface tmux-spread` to lift the caller's viewer into a
|
|
180
180
|
* fresh window before tiling sibling viewers beside it. */
|
|
181
181
|
export function breakPane(pane) {
|
|
182
182
|
const r = tmux([
|
|
@@ -369,7 +369,7 @@ export function selectWindow(session, window) {
|
|
|
369
369
|
return tmux(['select-window', '-t', `${session}:${window}`]).ok;
|
|
370
370
|
}
|
|
371
371
|
/** Apply a named tmux layout to a window (`tmux select-layout -t <window>
|
|
372
|
-
* <layout>`). `
|
|
372
|
+
* <layout>`). `surface tmux-spread` calls it with `tiled` to evenly grid every
|
|
373
373
|
* viewer pane in the spread window (and between splits, to redistribute space so
|
|
374
374
|
* the next split has room). Best-effort; false if tmux fails. */
|
|
375
375
|
export function selectLayout(window, layout) {
|
package/dist/daemon/crtrd-cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// crtrd entry point — spawned detached by `crtr
|
|
1
|
+
// crtrd entry point — spawned detached by `crtr sys daemon start` and by bin/crtrd.
|
|
2
2
|
// Calls runDaemon() and never returns (the loop drives via setTimeout).
|
|
3
3
|
import { runDaemon } from './crtrd.js';
|
|
4
4
|
runDaemon();
|
package/dist/daemon/crtrd.d.ts
CHANGED
|
@@ -19,6 +19,30 @@ export declare const YIELD_STALL_GRACE_MS: number;
|
|
|
19
19
|
* after running `node yield` mid-turn, and a working engine must never be
|
|
20
20
|
* killed), and the state has persisted past YIELD_STALL_GRACE_MS. */
|
|
21
21
|
export declare function yieldStallVerdict(piPidAlive: boolean | null, intent: NodeRow['intent'], busy: boolean, stalledFor: number | null): 'leave' | 'pending' | 'kill';
|
|
22
|
+
export declare const WEDGE_QUIET_MS: number;
|
|
23
|
+
export declare const WEDGE_CPU_MAX_PERCENT = 2;
|
|
24
|
+
export type WedgeVerdict = 'leave' | 'pending' | 'wedged';
|
|
25
|
+
/** Pure wedge decision — mirrors livenessVerdict/yieldStallVerdict: a total
|
|
26
|
+
* function of the observed signals, no clock or process access of its own.
|
|
27
|
+
* `busy` false — the turn already ended (however it routed) — is never a
|
|
28
|
+
* wedge, regardless of how stale the marker looks. `quietForMs` null means
|
|
29
|
+
* the heartbeat marker couldn't be read (treated as “not enough evidence”, not
|
|
30
|
+
* “wedged”). `cpuPercent` null (sample failed/unavailable) or above the
|
|
31
|
+
* ceiling — EITHER keeps the verdict at 'pending' (keep watching next tick)
|
|
32
|
+
* rather than firing on an unconfirmed or contradicted signal. */
|
|
33
|
+
export declare function wedgeVerdict(input: {
|
|
34
|
+
busy: boolean;
|
|
35
|
+
quietForMs: number | null;
|
|
36
|
+
cpuPercent: number | null;
|
|
37
|
+
}): WedgeVerdict;
|
|
38
|
+
/** Pure: sum CPU% across a process's entire tree (itself + every descendant).
|
|
39
|
+
* Parses `ps -ax -o pid=,ppid=,pcpu=` output, builds a ppid→children map, and
|
|
40
|
+
* DFS's from `rootPid` collecting root + all descendants; malformed/blank
|
|
41
|
+
* lines are skipped. Returns `null` if `rootPid` never appears in the output
|
|
42
|
+
* (can't measure at all — treated as UNKNOWN by wedgeVerdict, never as
|
|
43
|
+
* “low”) or the output has no parsable rows. Exported for direct unit testing
|
|
44
|
+
* — no process access of its own, mirroring wedgeVerdict's purity. */
|
|
45
|
+
export declare function sumTreeCpu(psOutput: string, rootPid: number): number | null;
|
|
22
46
|
export type LivenessVerdict = 'leave' | 'pending' | 'revive';
|
|
23
47
|
/** Decide what to do with a node whose engine pid is DEAD, from how long it's
|
|
24
48
|
* been dead. Pure — the time/revive side effects live in handleNodeLiveness;
|