@north-light/crouter 0.3.197 → 0.3.198
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +2 -2
- package/dist/api/dto/inbox.d.ts +46 -0
- package/dist/builtin-memory/00-runtime-base.md +3 -3
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
- package/dist/clients/attach/render/chat-view.d.ts +3 -8
- package/dist/clients/attach/render/chat-view.js +3 -24
- package/dist/clients/attach/render/markdown-source.js +0 -6
- package/dist/clients/attach/render/tool-calls.js +16 -2
- package/dist/clients/attach/viewer.js +613 -602
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +2 -2
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +11 -4
- package/dist/clients/inbox/controller.js +4 -4
- package/dist/clients/inbox/page-adapter.d.ts +2 -1
- package/dist/clients/inbox/tui/render.js +46 -25
- package/dist/clients/inbox/tui/slots.d.ts +1 -3
- package/dist/clients/inbox/tui/slots.js +8 -37
- package/dist/clients/inbox/tui/types.d.ts +4 -3
- package/dist/commands/__tests__/human.test.js +160 -23
- package/dist/commands/canvas-browse.js +1 -1
- package/dist/commands/human/doc.js +21 -74
- package/dist/commands/human/html.d.ts +2 -0
- package/dist/commands/human/html.js +81 -0
- package/dist/commands/human/prompts.d.ts +5 -4
- package/dist/commands/human/prompts.js +81 -153
- package/dist/commands/human/shared.d.ts +5 -6
- package/dist/commands/human/shared.js +13 -61
- package/dist/commands/human.js +7 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -4
- package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
- package/dist/core/__tests__/human-deliver.test.js +28 -37
- package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
- package/dist/core/__tests__/seam/dormancy-release.test.js +63 -4
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +3 -13
- package/dist/core/canvas/__tests__/attention.test.js +2 -2
- package/dist/core/canvas/__tests__/render-remote.test.js +2 -2
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +21 -3
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +5 -1
- package/dist/core/canvas/canvas.d.ts +26 -0
- package/dist/core/canvas/canvas.js +55 -0
- package/dist/core/command.js +6 -11
- package/dist/core/config.js +3 -2
- package/dist/core/help.d.ts +3 -0
- package/dist/core/human/__tests__/html-markdown.test.d.ts +1 -0
- package/dist/core/human/__tests__/html-markdown.test.js +52 -0
- package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-catalog.test.js +16 -0
- package/dist/core/human/__tests__/page-render.test.js +40 -23
- package/dist/core/human/__tests__/page-tickets.test.js +48 -27
- package/dist/core/human/__tests__/page.test.js +48 -65
- package/dist/core/human/__tests__/serial/inbox-core.test.js +5 -15
- package/dist/core/human/answer-text.d.ts +3 -0
- package/dist/core/human/answer-text.js +98 -0
- package/dist/core/human/answer.d.ts +51 -0
- package/dist/core/human/answer.js +125 -0
- package/dist/core/human/component-docs.d.ts +14 -0
- package/dist/core/human/component-docs.js +161 -0
- package/dist/core/human/html-markdown.d.ts +3 -0
- package/dist/core/human/html-markdown.js +288 -0
- package/dist/core/human/page-catalog.d.ts +19 -3
- package/dist/core/human/page-catalog.js +73 -16
- package/dist/core/human/page-render.d.ts +3 -2
- package/dist/core/human/page-render.js +19 -45
- package/dist/core/human/page-schema.d.ts +108 -8
- package/dist/core/human/page-schema.js +88 -31
- package/dist/core/human/page-synth.d.ts +2 -4
- package/dist/core/human/page-synth.js +23 -7
- package/dist/core/human/page.d.ts +14 -7
- package/dist/core/human/page.js +178 -90
- package/dist/core/human/review-schema.d.ts +1 -0
- package/dist/core/human/review-schema.js +1 -1
- package/dist/core/human/scan.d.ts +3 -2
- package/dist/core/human/scan.js +7 -8
- package/dist/core/human/summary.d.ts +4 -7
- package/dist/core/human/summary.js +4 -60
- package/dist/core/human/tickets.d.ts +10 -9
- package/dist/core/human/tickets.js +5 -3
- package/dist/core/human/types.d.ts +4 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/review/realize.js +9 -1
- package/dist/core/runtime/boot-root.js +6 -1
- package/dist/core/runtime/broker.js +8 -6
- package/dist/core/runtime/busy.d.ts +4 -3
- package/dist/core/runtime/busy.js +4 -3
- package/dist/core/runtime/spawn.js +4 -4
- package/dist/core/user-settings.d.ts +17 -2
- package/dist/core/user-settings.js +32 -11
- package/dist/daemon/api/handlers/human.js +1 -1
- package/dist/daemon/api/handlers/inbox.js +22 -7
- package/dist/daemon/cron-run.js +16 -2
- package/dist/daemon/crtrd.js +1 -1
- package/dist/daemon/fleet.d.ts +22 -10
- package/dist/daemon/fleet.js +43 -24
- package/dist/daemon/human/finish.d.ts +2 -1
- package/dist/daemon/human/finish.js +45 -28
- package/dist/daemon/human/sweep.js +4 -2
- package/dist/daemon/reconcilers/broker-supervision.js +11 -8
- package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
- package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
- package/dist/daemon/reconcilers/live-obligation.js +30 -2
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
- package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
- package/dist/daemon/review/sweep.js +12 -1
- package/dist/pages/bundle.css +1 -1
- package/dist/pages/bundle.js +1008 -577
- package/dist/pages/comments.d.ts +29 -0
- package/dist/pages/comments.js +51 -0
- package/dist/pages/controls.d.ts +98 -0
- package/dist/pages/controls.js +261 -0
- package/dist/pages/elements/cards.d.ts +15 -4
- package/dist/pages/elements/cards.js +299 -186
- package/dist/pages/elements/chart.d.ts +22 -0
- package/dist/pages/elements/chart.js +107 -91
- package/dist/pages/elements/options.d.ts +9 -0
- package/dist/pages/elements/options.js +268 -155
- package/dist/pages/elements/pages.d.ts +6 -0
- package/dist/pages/elements/pages.js +60 -38
- package/dist/pages/elements/slot.d.ts +5 -0
- package/dist/pages/elements/slot.js +17 -10
- package/dist/pages/elements/table.d.ts +17 -0
- package/dist/pages/elements/table.js +336 -228
- package/dist/pages/elements/text.d.ts +9 -3
- package/dist/pages/elements/text.js +350 -143
- package/dist/pages/host.d.ts +0 -24
- package/dist/pages/host.js +0 -17
- package/dist/pages/readonly.d.ts +24 -0
- package/dist/pages/readonly.js +31 -0
- package/dist/pages/responses.d.ts +37 -0
- package/dist/pages/responses.js +56 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
- package/dist/pi-extensions/canvas-bash-valve.js +12 -8
- package/dist/types.d.ts +19 -5
- package/dist/types.js +2 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/install-runtime.mjs +19 -24
- package/dist/clients/attach/render/html-markdown.d.ts +0 -13
- package/dist/clients/attach/render/html-markdown.js +0 -206
- package/dist/core/human/markdown-html.d.ts +0 -8
- package/dist/core/human/markdown-html.js +0 -581
package/dist/daemon/fleet.js
CHANGED
|
@@ -51,21 +51,33 @@ async function surfaceRecoveryFailure(meta, error) {
|
|
|
51
51
|
await pushUrgent(meta.node_id, body, { from: meta.node_id });
|
|
52
52
|
}
|
|
53
53
|
/** Whether a dead relaunch should resume the saved session strictly, or retry
|
|
54
|
-
* as a fresh cycle instead. A cycling attempt (cycle_pending) that dies
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
* as a fresh cycle instead. A cycling attempt (cycle_pending) that dies before
|
|
55
|
+
* session_start must retry AS a cycle: the fresh branch it was opening is not
|
|
56
|
+
* confirmed, so resuming would silently continue the old leaf the refresh was
|
|
57
|
+
* meant to leave behind.
|
|
58
|
+
*
|
|
59
|
+
* An interrupted turn is NOT a reason to cycle. A strict reopen does restore
|
|
60
|
+
* the transcript at an idle prompt without restarting the dead turn — but the
|
|
61
|
+
* fix for that is the continuation prompt recovery delivers (see `#respawn`),
|
|
62
|
+
* not throwing the conversation away. */
|
|
63
|
+
export function retryResumeMode(meta) {
|
|
64
|
+
return meta?.cycle_pending !== true;
|
|
61
65
|
}
|
|
62
66
|
/** One pure function producing the action for a node known-dead. Used at
|
|
63
67
|
* exactly two call sites — an exit-policy job and the startup recovery sweep
|
|
64
68
|
* (design D-12) — so exit recovery and startup recovery can never diverge.
|
|
65
|
-
* Evaluated in table order; `busy` is the on-disk busy marker.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
+
* Evaluated in table order; `busy` is the on-disk busy marker.
|
|
70
|
+
*
|
|
71
|
+
* `busy` no longer selects the ACTION. Whether the engine died mid-turn or
|
|
72
|
+
* parked at an idle prompt, the saved transcript is equally resumable — pi
|
|
73
|
+
* persists whole entries (`appendFileSync` per line) so a killed generation
|
|
74
|
+
* simply leaves the file ending at its last complete entry, and the loader
|
|
75
|
+
* skips a malformed line rather than rejecting the session. A mid-turn death
|
|
76
|
+
* needs the turn re-driven, which is a continuation prompt on top of the
|
|
77
|
+
* resume (see `#respawn`), not a fresh branch. `busy` therefore survives here
|
|
78
|
+
* only to route a node that died before session_start ever recorded a session
|
|
79
|
+
* to a fresh relaunch instead of terminalizing it as a boot failure. */
|
|
80
|
+
export function classifyDeadNode(row, busy) {
|
|
69
81
|
if (row === null)
|
|
70
82
|
return 'forget'; // row 1: row deleted
|
|
71
83
|
if (row.status === 'done' || row.status === 'canceled' || row.status === 'dead')
|
|
@@ -74,15 +86,13 @@ export function classifyDeadNode(row, busy, cleanAbort = false) {
|
|
|
74
86
|
return 'respawn-fresh'; // row 3
|
|
75
87
|
if (row.intent === 'idle-release')
|
|
76
88
|
return 'dormant'; // row 4
|
|
77
|
-
if (cleanAbort && row.pi_session_id != null)
|
|
78
|
-
return 'respawn-resume'; // row 5: broker-certified interrupted turn
|
|
79
|
-
if (busy)
|
|
80
|
-
return 'respawn-cycle'; // row 6: dirty interrupted turn
|
|
81
89
|
if (row.pi_session_id != null)
|
|
82
|
-
return retryResumeMode(row
|
|
83
|
-
if (
|
|
84
|
-
return 'respawn-fresh'; // row
|
|
85
|
-
|
|
90
|
+
return retryResumeMode(row) ? 'respawn-resume' : 'respawn-cycle'; // row 5: any saved session
|
|
91
|
+
if (busy)
|
|
92
|
+
return 'respawn-fresh'; // row 6: died mid-turn before a session was ever recorded
|
|
93
|
+
if (row.fork_from != null)
|
|
94
|
+
return 'respawn-fresh'; // row 7: bounded ordinary fork-birth retry
|
|
95
|
+
return 'boot-failure'; // row 8
|
|
86
96
|
}
|
|
87
97
|
// ---------------------------------------------------------------------------
|
|
88
98
|
// Respawn throttle (replaces the stranded-relaunch counters, event-driven)
|
|
@@ -248,8 +258,12 @@ export class DaemonFleet {
|
|
|
248
258
|
}
|
|
249
259
|
async #applyPolicy(nodeId, origin) {
|
|
250
260
|
const meta = getNode(nodeId);
|
|
251
|
-
|
|
252
|
-
|
|
261
|
+
// Two durable witnesses mean the dead broker had a turn in flight. A dirty
|
|
262
|
+
// death leaves the busy marker behind; a bounded graceful abort clears busy
|
|
263
|
+
// as the run settles, then writes the clean-abort certificate. Both need the
|
|
264
|
+
// same recovery: resume the saved transcript and re-drive the stopped turn.
|
|
265
|
+
const interruptedTurn = isBusy(nodeId) || hasCleanAbort(nodeId);
|
|
266
|
+
const action = classifyDeadNode(meta, interruptedTurn);
|
|
253
267
|
switch (action) {
|
|
254
268
|
case 'forget':
|
|
255
269
|
// Fanouts for terminal exits already happened at the transition site.
|
|
@@ -271,12 +285,12 @@ export class DaemonFleet {
|
|
|
271
285
|
case 'respawn-fresh':
|
|
272
286
|
case 'respawn-cycle':
|
|
273
287
|
case 'respawn-resume':
|
|
274
|
-
await this.#respawn(nodeId, meta, action, origin,
|
|
288
|
+
await this.#respawn(nodeId, meta, action, origin, interruptedTurn);
|
|
275
289
|
break;
|
|
276
290
|
}
|
|
277
291
|
return action;
|
|
278
292
|
}
|
|
279
|
-
async #respawn(nodeId, meta, action, origin,
|
|
293
|
+
async #respawn(nodeId, meta, action, origin, interruptedTurn) {
|
|
280
294
|
const state = this.#throttle.get(nodeId) ?? { failures: 0 };
|
|
281
295
|
if (state.failures >= MAX_RESPAWN_ATTEMPTS) {
|
|
282
296
|
await this.#terminalize(nodeId, meta, {
|
|
@@ -327,7 +341,12 @@ export class DaemonFleet {
|
|
|
327
341
|
try {
|
|
328
342
|
reviveNode(nodeId, {
|
|
329
343
|
resume: action === 'respawn-resume',
|
|
330
|
-
|
|
344
|
+
// A resumed session whose turn was interrupted comes back parked at an
|
|
345
|
+
// idle prompt with the dead turn unfinished. The continuation re-drives
|
|
346
|
+
// it in place, so the conversation continues instead of being rebuilt
|
|
347
|
+
// from disk on a fresh branch. Only a true resume can carry it; a cycle
|
|
348
|
+
// or fresh relaunch gets its own kickoff.
|
|
349
|
+
wakeReason: action === 'respawn-resume' && interruptedTurn ? 'runtime-restart-abort' : undefined,
|
|
331
350
|
// Replacing a dead broker instance is recovery, not a wake: nothing
|
|
332
351
|
// external arrived for this node, so an armed deadline survives the
|
|
333
352
|
// relaunch instead of being consumed by it.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ProductPageComponents } from '../../types.js';
|
|
1
2
|
import type { TicketResult } from '../../core/human/types.js';
|
|
2
3
|
/**
|
|
3
4
|
* The one completion path for a page ticket: claim takeover, exclusive result
|
|
@@ -5,7 +6,7 @@ import type { TicketResult } from '../../core/human/types.js';
|
|
|
5
6
|
* node-addressed resolve route and the ticket-id-addressed inbox respond route —
|
|
6
7
|
* finishes a page here, so an answer always reaches the node that asked.
|
|
7
8
|
*/
|
|
8
|
-
export declare function resolvePageTicket(nodeId: string, responses: unknown, productKinds?:
|
|
9
|
+
export declare function resolvePageTicket(nodeId: string, responses: unknown, productKinds?: ProductPageComponents): Promise<TicketResult>;
|
|
9
10
|
/** Cancel a ticket with the store's first-writer-wins publication. */
|
|
10
11
|
export declare function cancelHumanTicket(nodeId: string, opts: {
|
|
11
12
|
reason?: string;
|
|
@@ -1,36 +1,45 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { ApiError } from '../../api/index.js';
|
|
3
|
-
import {
|
|
3
|
+
import { getRow, subscribersOf } from '../../core/canvas/canvas.js';
|
|
4
|
+
import { readConfig } from '../../core/config.js';
|
|
4
5
|
import { ticketDir } from '../../core/human/root.js';
|
|
6
|
+
import { parsePage } from '../../core/human/page.js';
|
|
5
7
|
import { cancelTicket, projectReviewOutput, readTicketResult, takeoverAndCompletePage, } from '../../core/human/tickets.js';
|
|
8
|
+
import { describePageAnswer } from '../../core/human/answer.js';
|
|
9
|
+
import { renderAnswerText } from '../../core/human/answer-text.js';
|
|
6
10
|
import { responsePath } from '../../core/human/convention.js';
|
|
7
11
|
import { readJsonOrNull } from '../../core/fs-utils.js';
|
|
8
12
|
import { appendInbox } from '../../core/feed/inbox.js';
|
|
9
13
|
import { FinalDeliveryError, pushFinal } from '../../core/feed/feed.js';
|
|
10
14
|
import { transition } from '../../core/runtime/lifecycle.js';
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
/** A corrupt manifest must not strand the asking node: the raw response remains its escape hatch. */
|
|
16
|
+
function renderCorruptPageAnswer(result, resultPath) {
|
|
17
|
+
return [
|
|
18
|
+
'The page manifest could not be read, so the raw response is provided.',
|
|
19
|
+
'',
|
|
20
|
+
JSON.stringify(result.responses),
|
|
21
|
+
'',
|
|
22
|
+
`Response record: ${resultPath}`,
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
25
|
+
function pageTitle(dir) {
|
|
26
|
+
try {
|
|
27
|
+
return parsePage(dir, readConfig('user').page_components).title;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function renderPageAnswer(dir, result, resultPath) {
|
|
34
|
+
const productKinds = readConfig('user').page_components;
|
|
35
|
+
let manifest;
|
|
36
|
+
try {
|
|
37
|
+
manifest = parsePage(dir, productKinds);
|
|
23
38
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
for (const [slotId, response] of entries)
|
|
27
|
-
lines.push(`${slotId}: ${JSON.stringify(response)}`);
|
|
28
|
-
const summary = result.summary.trim();
|
|
29
|
-
if (summary !== '')
|
|
30
|
-
lines.push('', summary);
|
|
39
|
+
catch {
|
|
40
|
+
return renderCorruptPageAnswer(result, resultPath);
|
|
31
41
|
}
|
|
32
|
-
|
|
33
|
-
return lines.join('\n');
|
|
42
|
+
return renderAnswerText(describePageAnswer(manifest, result.responses, { completedAt: result.completedAt, productKinds }), resultPath);
|
|
34
43
|
}
|
|
35
44
|
function deliveryFailed(error) {
|
|
36
45
|
// A canonical final is committed inside its own write boundary and can never
|
|
@@ -96,12 +105,20 @@ export async function deliverTerminalResult(nodeId) {
|
|
|
96
105
|
if (result === null)
|
|
97
106
|
return false;
|
|
98
107
|
projectReviewOutput(dir);
|
|
99
|
-
|
|
108
|
+
// The ROW, not `getNode`: a ticket directory IS the bridge's node directory,
|
|
109
|
+
// so a bridge whose row has been pruned still answers `getNode` from its
|
|
110
|
+
// surviving meta.json — with no status at all, which passed every guard below
|
|
111
|
+
// and then threw out of `transition` on every sweep, re-fanning the
|
|
112
|
+
// cancellation notice each time. No row means nothing left to deliver to.
|
|
113
|
+
const node = getRow(nodeId);
|
|
100
114
|
if (node === null || node.status === 'dead' || node.status === 'canceled' || (node.status === 'done' && node.final_report !== null)) {
|
|
101
115
|
return true;
|
|
102
116
|
}
|
|
103
117
|
if (result.kind === 'canceled') {
|
|
104
|
-
const
|
|
118
|
+
const title = pageTitle(dir);
|
|
119
|
+
const reason = result.reason !== undefined && result.reason !== '' ? result.reason : 'no reason was given';
|
|
120
|
+
const subject = title === undefined ? `human interaction ${nodeId} canceled — no answer is coming` : `human interaction ${nodeId} — “${title}” canceled, no answer is coming`;
|
|
121
|
+
const body = title === undefined ? `The human interaction ${nodeId} was canceled — ${reason}. No response will arrive, and any partial answers the human had entered were discarded unsent.` : `The page “${title}” was canceled — ${reason}. No response will arrive, and any partial answers the human had entered were discarded unsent.`;
|
|
105
122
|
// One failing subscriber must not silence the rest, and the bridge stays
|
|
106
123
|
// live until every notice lands: a partial fanout leaves the ticket for the
|
|
107
124
|
// next resolve/cancel attempt or the daemon-start sweep to replay. A
|
|
@@ -114,10 +131,10 @@ export async function deliverTerminalResult(nodeId) {
|
|
|
114
131
|
try {
|
|
115
132
|
appendInbox(subscriber.node_id, {
|
|
116
133
|
from: nodeId,
|
|
117
|
-
tier: '
|
|
134
|
+
tier: 'normal',
|
|
118
135
|
kind: 'message',
|
|
119
|
-
label:
|
|
120
|
-
data: { body
|
|
136
|
+
label: subject,
|
|
137
|
+
data: { body },
|
|
121
138
|
});
|
|
122
139
|
}
|
|
123
140
|
catch {
|
|
@@ -130,7 +147,7 @@ export async function deliverTerminalResult(nodeId) {
|
|
|
130
147
|
return true;
|
|
131
148
|
}
|
|
132
149
|
if (result.kind === 'page') {
|
|
133
|
-
await pushFinal(nodeId, renderPageAnswer(result, responsePath(dir)));
|
|
150
|
+
await pushFinal(nodeId, renderPageAnswer(dir, result, responsePath(dir)));
|
|
134
151
|
}
|
|
135
152
|
return true;
|
|
136
153
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { getRow } from '../../core/canvas/canvas.js';
|
|
4
4
|
import { getReviewByBridge } from '../../core/review/store.js';
|
|
5
5
|
import { emitEvent } from '../../core/events/emit.js';
|
|
6
6
|
import { ticketsRoot } from '../../core/human/root.js';
|
|
@@ -27,7 +27,9 @@ export async function reconcileUndeliveredTickets() {
|
|
|
27
27
|
// finisher retries only when its winning operation is explicitly repeated.
|
|
28
28
|
if (getReviewByBridge(entry) !== null)
|
|
29
29
|
continue;
|
|
30
|
-
|
|
30
|
+
// The ROW is what carries status; `getNode` would answer from a pruned
|
|
31
|
+
// bridge's surviving meta.json with no status at all.
|
|
32
|
+
const bridge = getRow(entry);
|
|
31
33
|
if (bridge === null || isTerminalBridge(bridge.status))
|
|
32
34
|
continue;
|
|
33
35
|
if (await deliverTerminalResult(entry))
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { getRow, getNode, subscribersOf } from '../../core/canvas/index.js';
|
|
6
|
+
import { getRow, getNode, isWarmSpare, subscribersOf } from '../../core/canvas/index.js';
|
|
7
7
|
import { contextDir, isSafeNodeId } from '../../core/canvas/paths.js';
|
|
8
8
|
import { fullName } from '../../core/canvas/labels.js';
|
|
9
9
|
import { recordedPidLiveness } from '../../core/canvas/pid.js';
|
|
@@ -20,7 +20,7 @@ import { operationIdContext } from '../../core/events/operation-id.js';
|
|
|
20
20
|
import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
|
|
21
21
|
import { activeBackgroundBashJobs } from '../../core/bash-jobs.js';
|
|
22
22
|
import { focusOf } from '../../core/runtime/placement.js';
|
|
23
|
-
import {
|
|
23
|
+
import { parkEvent } from './live-obligation.js';
|
|
24
24
|
const execFileAsync = promisify(execFile);
|
|
25
25
|
function configuredPositiveInteger(name, fallback) {
|
|
26
26
|
const raw = process.env[name];
|
|
@@ -174,6 +174,14 @@ export class BrokerSupervisionReconciler {
|
|
|
174
174
|
return thresholds;
|
|
175
175
|
}
|
|
176
176
|
handleUnattendedParking(id, row, livePid, now) {
|
|
177
|
+
// An unclaimed warm-pool spare is not a conversation: the booted broker it
|
|
178
|
+
// holds IS its whole value, and nobody is meant to be attending it. Its
|
|
179
|
+
// lifetime belongs to `reapStaleSpares` (warm-pool.ts) and its own TTL, so
|
|
180
|
+
// parking one here would empty the pool every 15 minutes.
|
|
181
|
+
if (isWarmSpare(id)) {
|
|
182
|
+
this.unattendedSince.delete(id);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
177
185
|
if (row === null
|
|
178
186
|
|| row.pi_session_id == null
|
|
179
187
|
|| row.pi_pid !== livePid
|
|
@@ -206,12 +214,7 @@ export class BrokerSupervisionReconciler {
|
|
|
206
214
|
if (now - since < unattendedParkMsForDaemon())
|
|
207
215
|
return;
|
|
208
216
|
this.unattendedSince.delete(id);
|
|
209
|
-
|
|
210
|
-
// A resident with nothing left to wake it has completed its conversation:
|
|
211
|
-
// terminalize it so history retention can eventually prune it. A resident
|
|
212
|
-
// still awaiting a child/controller/human/deadline/output stays wakeable, as does
|
|
213
|
-
// every terminal node (whose own stop guard owns semantic completion).
|
|
214
|
-
transition(id, row.lifecycle === 'resident' && !liveObligation ? 'finish' : 'release');
|
|
217
|
+
transition(id, parkEvent(id, row.lifecycle));
|
|
215
218
|
headlessBrokerHost.teardown(id);
|
|
216
219
|
}
|
|
217
220
|
async warnOverCapInTmux(count, warning, max) {
|
|
@@ -7,7 +7,7 @@ import { appendControllerDeathNotice, readCursor, readInboxSince, } from '../../
|
|
|
7
7
|
import { fanDoctrineWake } from '../../core/runtime/close.js';
|
|
8
8
|
import { transition } from '../../core/runtime/lifecycle.js';
|
|
9
9
|
import { reviveNode } from '../../core/runtime/revive.js';
|
|
10
|
-
import {
|
|
10
|
+
import { parkEvent } from './live-obligation.js';
|
|
11
11
|
// Idle-release/inbox retry cap (2026-07-10 diagnosis, round 2): the SAME
|
|
12
12
|
// livelock shape as the stranded-relaunch case, but on the separate pass that
|
|
13
13
|
// resumes a dormant idle-released node when its inbox gains an unseen entry.
|
|
@@ -80,11 +80,16 @@ export class DormantInboxReconciler {
|
|
|
80
80
|
if (!entries.some((entry) => entry.tier !== 'deferred')) {
|
|
81
81
|
this.relaunchState.delete(id);
|
|
82
82
|
const meta = getNode(id);
|
|
83
|
-
// An idle
|
|
84
|
-
// lifecycle settlement
|
|
85
|
-
// history rather than a permanently protected
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
// An idle row with no wake-worthy inbox entry still needs the same
|
|
84
|
+
// lifecycle settlement the live park clock applies: once nothing
|
|
85
|
+
// live remains, this is history rather than a permanently protected
|
|
86
|
+
// idle row. The row is already parked, so only a terminalizing
|
|
87
|
+
// verdict is worth writing. Unclaimed warm-pool spares never reach
|
|
88
|
+
// here — `listNodes` hides them from `ctx.rows`.
|
|
89
|
+
if (meta !== null) {
|
|
90
|
+
const event = parkEvent(id, meta.lifecycle);
|
|
91
|
+
if (event !== 'release')
|
|
92
|
+
transition(id, event);
|
|
88
93
|
}
|
|
89
94
|
return;
|
|
90
95
|
}
|
|
@@ -1,2 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
import { type Lifecycle } from '../../core/canvas/index.js';
|
|
2
|
+
/** Durable work that requires a node to remain automatically wakeable. */
|
|
2
3
|
export declare function hasLiveObligation(nodeId: string): boolean;
|
|
4
|
+
/** What parking an unattended node actually amounts to — the one rule both the
|
|
5
|
+
* live-broker park clock (broker-supervision) and the already-parked
|
|
6
|
+
* reconciliation (dormant-inbox) apply.
|
|
7
|
+
*
|
|
8
|
+
* `release` — something can still wake it, so parking is a pause.
|
|
9
|
+
* `finish` — a resident with nothing left to wake it has completed its
|
|
10
|
+
* conversation, and the row becomes prunable history.
|
|
11
|
+
* `cancel` — a terminal node that never pushed a final was torn down before
|
|
12
|
+
* finishing, which is what `canceled` records; it stays revivable
|
|
13
|
+
* and becomes prunable. A live subscriber still awaiting its
|
|
14
|
+
* report is an outstanding deliverable, so such a node keeps its
|
|
15
|
+
* wakeable idle row instead of being written off.
|
|
16
|
+
*
|
|
17
|
+
* Undelivered mail of ANY tier also holds the row. A deferred-only unseen set
|
|
18
|
+
* never wakes a node on its own — it rides the next natural cycle — but a
|
|
19
|
+
* terminalized row no longer takes that cycle, so writing one off would drop
|
|
20
|
+
* content the sender already handed over.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parkEvent(nodeId: string, lifecycle: Lifecycle): 'release' | 'finish' | 'cancel';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, } from '../../core/canvas/index.js';
|
|
1
|
+
import { hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCancelOnWakeCron, } from '../../core/canvas/index.js';
|
|
2
2
|
import { countTickets } from '../../core/canvas/attention.js';
|
|
3
|
+
import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
|
|
3
4
|
import { readOutputRequest } from '../../core/runtime/structured-output.js';
|
|
4
|
-
/** Durable work that requires a
|
|
5
|
+
/** Durable work that requires a node to remain automatically wakeable. */
|
|
5
6
|
export function hasLiveObligation(nodeId) {
|
|
6
7
|
return hasActiveLiveSubscription(nodeId)
|
|
7
8
|
|| hasLiveMessageWait(nodeId)
|
|
@@ -9,3 +10,30 @@ export function hasLiveObligation(nodeId) {
|
|
|
9
10
|
|| countTickets(nodeId) > 0
|
|
10
11
|
|| readOutputRequest(nodeId).state !== 'absent';
|
|
11
12
|
}
|
|
13
|
+
/** What parking an unattended node actually amounts to — the one rule both the
|
|
14
|
+
* live-broker park clock (broker-supervision) and the already-parked
|
|
15
|
+
* reconciliation (dormant-inbox) apply.
|
|
16
|
+
*
|
|
17
|
+
* `release` — something can still wake it, so parking is a pause.
|
|
18
|
+
* `finish` — a resident with nothing left to wake it has completed its
|
|
19
|
+
* conversation, and the row becomes prunable history.
|
|
20
|
+
* `cancel` — a terminal node that never pushed a final was torn down before
|
|
21
|
+
* finishing, which is what `canceled` records; it stays revivable
|
|
22
|
+
* and becomes prunable. A live subscriber still awaiting its
|
|
23
|
+
* report is an outstanding deliverable, so such a node keeps its
|
|
24
|
+
* wakeable idle row instead of being written off.
|
|
25
|
+
*
|
|
26
|
+
* Undelivered mail of ANY tier also holds the row. A deferred-only unseen set
|
|
27
|
+
* never wakes a node on its own — it rides the next natural cycle — but a
|
|
28
|
+
* terminalized row no longer takes that cycle, so writing one off would drop
|
|
29
|
+
* content the sender already handed over.
|
|
30
|
+
*/
|
|
31
|
+
export function parkEvent(nodeId, lifecycle) {
|
|
32
|
+
if (hasLiveObligation(nodeId))
|
|
33
|
+
return 'release';
|
|
34
|
+
if (readInboxSince(nodeId, readCursor(nodeId)).length > 0)
|
|
35
|
+
return 'release';
|
|
36
|
+
if (lifecycle === 'resident')
|
|
37
|
+
return 'finish';
|
|
38
|
+
return hasLiveSubscriber(nodeId) ? 'release' : 'cancel';
|
|
39
|
+
}
|
|
@@ -4,7 +4,12 @@ export declare const DEAD_REAP_GRACE_MS: number;
|
|
|
4
4
|
export declare class StorageMaintenanceReconciler {
|
|
5
5
|
private lastPruneAt;
|
|
6
6
|
private lastSpareSweepAt;
|
|
7
|
+
private lastGhostSweepAt;
|
|
7
8
|
run(now: number): void;
|
|
9
|
+
/** Drop rows whose on-disk node dir is gone. Such a row can never be revived,
|
|
10
|
+
* focused, or closed — every one of those resolves the node through meta.json —
|
|
11
|
+
* so it sits in the roster jamming the surfaces that try. One readdir per sweep. */
|
|
12
|
+
private reapGhostRows;
|
|
8
13
|
/** Milliseconds since meta/telemetry was last written. Inbox activity is
|
|
9
14
|
* deliberately excluded: children can keep writing a dead manager's inbox. */
|
|
10
15
|
private msSinceDiskActivity;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { closeFocusRow, listFocuses, listNodes, pruneToLimit, } from '../../core/canvas/index.js';
|
|
3
|
+
import { closeFocusRow, listFocuses, listNodes, pruneToLimit, reapGhostRows, } from '../../core/canvas/index.js';
|
|
4
4
|
import { isSafeNodeId, jobDir, nodeDir } from '../../core/canvas/paths.js';
|
|
5
5
|
import { emitEvent } from '../../core/events/emit.js';
|
|
6
6
|
import { operationIdContext } from '../../core/events/operation-id.js';
|
|
@@ -12,20 +12,47 @@ export const DEAD_REAP_GRACE_MS = 10 * 60_000;
|
|
|
12
12
|
const AUTO_PRUNE_MAX = 2000;
|
|
13
13
|
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
14
14
|
const SPARE_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
15
|
+
const GHOST_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
15
16
|
/** Recurring storage cleanup. The in-memory throttle clocks intentionally reset
|
|
16
17
|
* on daemon boot, so the first tick runs both full sweeps. */
|
|
17
18
|
export class StorageMaintenanceReconciler {
|
|
18
19
|
lastPruneAt = Number.NEGATIVE_INFINITY;
|
|
19
20
|
lastSpareSweepAt = Number.NEGATIVE_INFINITY;
|
|
21
|
+
lastGhostSweepAt = Number.NEGATIVE_INFINITY;
|
|
20
22
|
run(now) {
|
|
21
23
|
// Placement reap MUST precede focus GC, so GC catches a focus row the reap
|
|
22
24
|
// strands. Both run after dormant-inbox reconciliation, so a same-tick
|
|
23
25
|
// revive has already had the chance to re-anchor its focus.
|
|
24
26
|
this.reapDeadResidue(now);
|
|
25
27
|
this.gcStaleFocuses();
|
|
28
|
+
this.reapGhostRows(now);
|
|
26
29
|
this.autoPrune(now);
|
|
27
30
|
this.gcWarmPool(now);
|
|
28
31
|
}
|
|
32
|
+
/** Drop rows whose on-disk node dir is gone. Such a row can never be revived,
|
|
33
|
+
* focused, or closed — every one of those resolves the node through meta.json —
|
|
34
|
+
* so it sits in the roster jamming the surfaces that try. One readdir per sweep. */
|
|
35
|
+
reapGhostRows(now) {
|
|
36
|
+
if (now - this.lastGhostSweepAt < GHOST_SWEEP_INTERVAL_MS)
|
|
37
|
+
return;
|
|
38
|
+
this.lastGhostSweepAt = now;
|
|
39
|
+
operationIdContext.fresh(() => {
|
|
40
|
+
try {
|
|
41
|
+
const reaped = reapGhostRows();
|
|
42
|
+
if (reaped.length > 0) {
|
|
43
|
+
emitEvent({
|
|
44
|
+
level: 'info',
|
|
45
|
+
event: 'canvas.ghost_rows.reaped',
|
|
46
|
+
outcome: 'succeeded',
|
|
47
|
+
fields: { reaped_count: reaped.length, reaped_ids: reaped },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
emitEvent({ level: 'error', event: 'canvas.ghost_rows.reap_failed', error: err });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
29
56
|
/** Milliseconds since meta/telemetry was last written. Inbox activity is
|
|
30
57
|
* deliberately excluded: children can keep writing a dead manager's inbox. */
|
|
31
58
|
msSinceDiskActivity(nodeId, now) {
|
|
@@ -4,6 +4,8 @@ import { reviewPath } from '../../core/human/convention.js';
|
|
|
4
4
|
import { ticketDir } from '../../core/human/root.js';
|
|
5
5
|
import { submitReview } from '../../core/human/tickets.js';
|
|
6
6
|
import { reviewsRoot } from '../../core/canvas/paths.js';
|
|
7
|
+
import { getNode } from '../../core/canvas/index.js';
|
|
8
|
+
import { loadProfileManifest } from '../../core/profiles/manifest.js';
|
|
7
9
|
import { projectReviewResult } from '../../core/review/project.js';
|
|
8
10
|
import { realizeReview } from '../../core/review/realize.js';
|
|
9
11
|
import { listBindingReviews, listReviews, markProjected, markProjectionError, } from '../../core/review/store.js';
|
|
@@ -23,6 +25,12 @@ function republishTicketReview(review) {
|
|
|
23
25
|
const dir = ticketDir(review.bridge_node_id);
|
|
24
26
|
if (existsSync(reviewPath(dir)))
|
|
25
27
|
return;
|
|
28
|
+
const origin = getNode(review.origin_node_id);
|
|
29
|
+
if (origin === null)
|
|
30
|
+
throw new Error(`ticket review origin is missing: ${review.origin_node_id}`);
|
|
31
|
+
const profileName = origin.profile_id == null
|
|
32
|
+
? undefined
|
|
33
|
+
: loadProfileManifest(origin.profile_id).manifest.name;
|
|
26
34
|
submitReview({
|
|
27
35
|
dir,
|
|
28
36
|
review: {
|
|
@@ -30,7 +38,10 @@ function republishTicketReview(review) {
|
|
|
30
38
|
...(review.output_path === null ? {} : { output: review.output_path }),
|
|
31
39
|
title: basename(review.file),
|
|
32
40
|
subtitle: review.subtitle,
|
|
33
|
-
source: {
|
|
41
|
+
source: {
|
|
42
|
+
nodeId: review.origin_node_id,
|
|
43
|
+
...(profileName === undefined ? {} : { profileName }),
|
|
44
|
+
},
|
|
34
45
|
blockedSince: review.created,
|
|
35
46
|
},
|
|
36
47
|
});
|
package/dist/pages/bundle.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
:where(:root,:host){color-scheme:light dark;--crtr-color-background: #ffffff;--crtr-color-surface: #f7f7f8;--crtr-color-surface-hover: #eeeeef;--crtr-color-text: #1c1c1f;--crtr-color-text-muted: #68686f;--crtr-color-border: #d9d9de;--crtr-color-accent: #5753c9;--crtr-color-accent-contrast: #ffffff;--crtr-color-danger: #c73535;--crtr-color-focus: #706bea;--crtr-chart-1: #5753c9;--crtr-chart-2: #2c8c75;--crtr-chart-3: #cf7c2a;--crtr-chart-4: #b84c84;--crtr-chart-5: #477eb8;--crtr-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--crtr-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--crtr-font-size: .875rem;--crtr-line-height: 1.5;--crtr-radius-small: .375rem;--crtr-radius-medium: .625rem;--crtr-space-1: .25rem;--crtr-space-2: .5rem;--crtr-space-3: .75rem;--crtr-space-4: 1rem;--crtr-shadow: 0 1px 2px rgb(0 0 0 / .08)}:where(crtr-options,crtr-text,crtr-table,crtr-cards,crtr-chart,crtr-pages,crtr-page,crtr-slot){color:var(--crtr-color-text);font-family:var(--crtr-font-family);font-size:var(--crtr-font-size);line-height:var(--crtr-line-height)}@media(prefers-color-scheme:dark){:where(:root,:host){--crtr-color-background: #161618;--crtr-color-surface: #222225;--crtr-color-surface-hover: #2d2d31;--crtr-color-text: #f2f2f3;--crtr-color-text-muted: #aaaab1;--crtr-color-border: #414147;--crtr-color-accent: #9692ff;--crtr-color-accent-contrast: #18181b;--crtr-color-danger: #f06a6a;--crtr-color-focus: #aaa7ff}}
|
|
1
|
+
:where(:root,:host){color-scheme:var(--crtr-color-scheme, light dark);--crtr-color-background: #ffffff;--crtr-color-surface: #f7f7f8;--crtr-color-surface-hover: #eeeeef;--crtr-color-text: #1c1c1f;--crtr-color-text-muted: #68686f;--crtr-color-border: #d9d9de;--crtr-color-accent: #5753c9;--crtr-color-accent-contrast: #ffffff;--crtr-color-danger: #c73535;--crtr-color-focus: #706bea;--crtr-chart-1: #5753c9;--crtr-chart-2: #2c8c75;--crtr-chart-3: #cf7c2a;--crtr-chart-4: #b84c84;--crtr-chart-5: #477eb8;--crtr-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--crtr-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;--crtr-font-size: .875rem;--crtr-line-height: 1.5;--crtr-radius-small: .375rem;--crtr-radius-medium: .625rem;--crtr-space-1: .25rem;--crtr-space-2: .5rem;--crtr-space-3: .75rem;--crtr-space-4: 1rem;--crtr-shadow: 0 1px 2px rgb(0 0 0 / .08)}:where(crtr-options){color:var(--crtr-options-color, var(--crtr-color-text));font-family:var(--crtr-options-font-family, var(--crtr-font-family));font-size:var(--crtr-options-font-size, var(--crtr-font-size));line-height:var(--crtr-options-line-height, var(--crtr-line-height))}:where(crtr-text){color:var(--crtr-text-color, var(--crtr-color-text));font-family:var(--crtr-text-font-family, var(--crtr-font-family));font-size:var(--crtr-text-font-size, var(--crtr-font-size));line-height:var(--crtr-text-line-height, var(--crtr-line-height))}:where(crtr-table){color:var(--crtr-table-color, var(--crtr-color-text));font-family:var(--crtr-table-font-family, var(--crtr-font-family));font-size:var(--crtr-table-font-size, var(--crtr-font-size));line-height:var(--crtr-table-line-height, var(--crtr-line-height))}:where(crtr-cards){color:var(--crtr-cards-color, var(--crtr-color-text));font-family:var(--crtr-cards-font-family, var(--crtr-font-family));font-size:var(--crtr-cards-font-size, var(--crtr-font-size));line-height:var(--crtr-cards-line-height, var(--crtr-line-height))}:where(crtr-chart){color:var(--crtr-chart-color, var(--crtr-color-text));font-family:var(--crtr-chart-font-family, var(--crtr-font-family));font-size:var(--crtr-chart-font-size, var(--crtr-font-size));line-height:var(--crtr-chart-line-height, var(--crtr-line-height))}:where(crtr-pages,crtr-page){color:var(--crtr-page-color, var(--crtr-color-text));font-family:var(--crtr-page-font-family, var(--crtr-font-family));font-size:var(--crtr-page-font-size, var(--crtr-font-size));line-height:var(--crtr-page-line-height, var(--crtr-line-height))}:where(crtr-slot){color:var(--crtr-slot-color, var(--crtr-color-text));font-family:var(--crtr-slot-font-family, var(--crtr-font-family));font-size:var(--crtr-slot-font-size, var(--crtr-font-size));line-height:var(--crtr-slot-line-height, var(--crtr-line-height))}@media(prefers-color-scheme:dark){:where(:root,:host){--crtr-color-background: #161618;--crtr-color-surface: #222225;--crtr-color-surface-hover: #2d2d31;--crtr-color-text: #f2f2f3;--crtr-color-text-muted: #aaaab1;--crtr-color-border: #414147;--crtr-color-accent: #9692ff;--crtr-color-accent-contrast: #18181b;--crtr-color-danger: #f06a6a;--crtr-color-focus: #aaa7ff;--crtr-shadow: 0 1px 2px rgb(0 0 0 / .45);--crtr-chart-1: #9692ff;--crtr-chart-2: #4fc3a4;--crtr-chart-3: #eba05a;--crtr-chart-4: #e281b4;--crtr-chart-5: #71b0e6}}
|