@north-light/crouter 0.3.168 → 0.3.170
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/cli.js +4 -0
- package/dist/clients/attach/viewer.js +314 -314
- package/dist/commands/human/prompts.js +15 -6
- package/dist/commands/sys/daemon.js +1 -0
- package/dist/core/__tests__/human-deliver.test.js +62 -2
- package/dist/core/__tests__/revive.test.js +48 -0
- package/dist/core/bash-jobs.d.ts +16 -17
- package/dist/core/bash-jobs.js +39 -38
- package/dist/core/canvas/__tests__/render-remote.test.js +25 -0
- package/dist/core/canvas/crons.d.ts +3 -2
- package/dist/core/canvas/crons.js +3 -2
- package/dist/core/canvas/render-source.js +6 -2
- package/dist/core/command.d.ts +8 -0
- package/dist/core/command.js +11 -1
- package/dist/core/runtime/revive-all.js +3 -1
- package/dist/core/runtime/revive.d.ts +7 -0
- package/dist/core/runtime/revive.js +15 -3
- package/dist/daemon/fleet.js +4 -0
- package/dist/pi-extensions/canvas-bash-valve.js +76 -18
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/install-runtime.mjs +36 -13
|
@@ -8,12 +8,12 @@ import { InputError } from '../../core/io.js';
|
|
|
8
8
|
import { cliClient } from '../api-client.js';
|
|
9
9
|
import { nodeDir } from '../../core/canvas/paths.js';
|
|
10
10
|
import { mkdirSync, existsSync } from 'node:fs';
|
|
11
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
11
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
12
12
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
13
13
|
import { tmuxServerReachable } from '../../core/spawn.js';
|
|
14
14
|
import { BrokerClient, BrokerUnavailableError } from '../../core/broker-client/index.js';
|
|
15
15
|
import { markStopSignal } from '../../core/runtime/stop-signals.js';
|
|
16
|
-
import { validateDeck, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
|
|
16
|
+
import { validateDeck, resolveDeckBodyPaths, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
|
|
17
17
|
import { BODY_PATH_CONTRACT, DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
|
|
18
18
|
import { inboxOpenInstruction, inboxPopupHint } from '../../core/keybindings/index.js';
|
|
19
19
|
/** The asking node's id, or null when run from a bare shell (no parent to route to). */
|
|
@@ -54,14 +54,22 @@ function requireSubtitle(subtitle, field = 'subtitle') {
|
|
|
54
54
|
next: 'Write one plain-English sentence stating the decision, recommendation or status, and stakes.',
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
|
-
function validateTitledDeck(input) {
|
|
57
|
+
function validateTitledDeck(input, deckDirectory) {
|
|
58
58
|
const title = typeof input === 'object' && input !== null
|
|
59
59
|
? input.title
|
|
60
60
|
: undefined;
|
|
61
61
|
if (typeof title !== 'string' || title.trim() === '') {
|
|
62
62
|
throw new Error('deck title is required');
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
const deck = validateDeck(input);
|
|
65
|
+
if (deckDirectory === undefined)
|
|
66
|
+
return deck;
|
|
67
|
+
for (const interaction of deck.interactions) {
|
|
68
|
+
if (interaction.bodyPath !== undefined && isAbsolute(interaction.bodyPath)) {
|
|
69
|
+
throw new Error('bodyPath must be relative to the deck JSON; absolute paths are unsupported');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return resolveDeckBodyPaths(deck, deckDirectory);
|
|
65
73
|
}
|
|
66
74
|
function buildHumanAskDeck(title, subtitle, question) {
|
|
67
75
|
return validateTitledDeck({
|
|
@@ -172,7 +180,7 @@ export const humanAsk = defineLeaf({
|
|
|
172
180
|
`Returns immediately; nothing opens on screen. The human answers from the inbox popup (${inboxPopupHint()}) on their own time.`,
|
|
173
181
|
],
|
|
174
182
|
},
|
|
175
|
-
run: async (input) => {
|
|
183
|
+
run: async (input, context) => {
|
|
176
184
|
// Optional stdin reaches the leaf as an empty string when omitted, including
|
|
177
185
|
// when --context-file is supplied. Keep the raw non-empty bytes untouched.
|
|
178
186
|
const titleRaw = input['title'];
|
|
@@ -182,6 +190,7 @@ export const humanAsk = defineLeaf({
|
|
|
182
190
|
const subtitleRaw = input['subtitle'];
|
|
183
191
|
const subtitle = typeof subtitleRaw === 'string' && subtitleRaw.trim() !== '' ? subtitleRaw.trim() : undefined;
|
|
184
192
|
const deckInput = input['deck'];
|
|
193
|
+
const deckPath = context?.contextFilePaths.get('deck');
|
|
185
194
|
if (deckInput !== undefined && (question !== undefined || titleRaw !== undefined || subtitleRaw !== undefined)) {
|
|
186
195
|
throw new InputError({
|
|
187
196
|
error: 'bad_invocation',
|
|
@@ -205,7 +214,7 @@ export const humanAsk = defineLeaf({
|
|
|
205
214
|
deck = buildHumanAskDeck(simpleAsk.title, simpleAsk.subtitle, simpleAsk.question);
|
|
206
215
|
}
|
|
207
216
|
else {
|
|
208
|
-
deck = validateTitledDeck(deckInput);
|
|
217
|
+
deck = validateTitledDeck(deckInput, deckPath === undefined ? undefined : dirname(deckPath));
|
|
209
218
|
}
|
|
210
219
|
deck = canonicalizeAskKinds(deck);
|
|
211
220
|
}
|
|
@@ -85,6 +85,7 @@ const daemonRestart = defineLeaf({
|
|
|
85
85
|
outputKind: 'object',
|
|
86
86
|
effects: [
|
|
87
87
|
'The daemon acknowledges immediately, then after a short grace tears its whole broker fleet down, releases its claim, spawns a successor on the runtime generation currently selected, and exits.',
|
|
88
|
+
'Each torn-down broker hands its foreground bash commands to the file-backed background job system; they keep running and report completion by urgent inbox message.',
|
|
88
89
|
'Every node it tore down — including the caller — is resumed by the successor through the ordinary startup recovery sweep.',
|
|
89
90
|
'The calling process is killed as part of that teardown, AFTER this result is returned.',
|
|
90
91
|
],
|
|
@@ -16,17 +16,20 @@ import { test, describe, before, after, beforeEach, afterEach } from 'node:test'
|
|
|
16
16
|
import assert from 'node:assert/strict';
|
|
17
17
|
import { mkdtempSync, mkdirSync, writeFileSync, realpathSync, readFileSync, existsSync, symlinkSync } from 'node:fs';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
|
-
import { dirname, join, basename } from 'node:path';
|
|
19
|
+
import { dirname, join, resolve, basename } from 'node:path';
|
|
20
|
+
import { Readable } from 'node:stream';
|
|
20
21
|
import { registerInboxRoot, submitDeck, validateDeck, claimTicket, completeDeck, cancelTicket, readTicketResult, atomicWriteJson, } from '@crouton-kit/humanloop';
|
|
21
22
|
import { spawnNode } from '../runtime/nodes.js';
|
|
22
23
|
import { getNode, listNodes, closeDb } from '../canvas/index.js';
|
|
23
24
|
import { readInboxSince } from '../feed/inbox.js';
|
|
24
25
|
import { humanAsk, humanReview, humanNotify } from '../../commands/human/prompts.js';
|
|
26
|
+
import { runCli } from '../command.js';
|
|
25
27
|
import { resolveRef } from '../canvas/history.js';
|
|
26
28
|
import { humanDeliver } from '../../commands/human/queue.js';
|
|
27
29
|
import { createApiServer } from '../../daemon/api/server.js';
|
|
28
30
|
import { trustedCrouterVisualHandler } from '../../daemon/api/handlers/human.js';
|
|
29
|
-
import { apiSocketPath } from '../canvas/paths.js';
|
|
31
|
+
import { apiSocketPath, nodeDir } from '../canvas/paths.js';
|
|
32
|
+
import { resolveRoot } from '../../build-root.js';
|
|
30
33
|
// ---------------------------------------------------------------------------
|
|
31
34
|
// Isolated environment
|
|
32
35
|
// ---------------------------------------------------------------------------
|
|
@@ -57,6 +60,28 @@ const ISO = '2026-07-10T12:00:00.000Z';
|
|
|
57
60
|
function benignHandler() {
|
|
58
61
|
return { command: process.execPath, args: ['-e', ''] };
|
|
59
62
|
}
|
|
63
|
+
async function withPipedStdin(body, fn) {
|
|
64
|
+
const original = process.stdin;
|
|
65
|
+
const stdin = Readable.from([Buffer.from(body)]);
|
|
66
|
+
Object.defineProperty(stdin, 'isTTY', { value: false, configurable: true });
|
|
67
|
+
Object.defineProperty(process, 'stdin', { value: stdin, configurable: true });
|
|
68
|
+
try {
|
|
69
|
+
return await fn();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
Object.defineProperty(process, 'stdin', { value: original, configurable: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function withMutedStdout(fn) {
|
|
76
|
+
const originalWrite = process.stdout.write;
|
|
77
|
+
process.stdout.write = (() => true);
|
|
78
|
+
try {
|
|
79
|
+
return await fn();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
process.stdout.write = originalWrite;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
60
85
|
/** Register `dir` (created fresh) as an owner-'crouter' root and return its
|
|
61
86
|
* canonical path. */
|
|
62
87
|
function registerRoot(dir) {
|
|
@@ -172,6 +197,41 @@ describe('enqueue is producer-only', () => {
|
|
|
172
197
|
}));
|
|
173
198
|
const structuredDeck = JSON.parse(readFileSync(join(structured['dir'], 'deck.json'), 'utf8'));
|
|
174
199
|
assert.deepEqual(structuredDeck.interactions.map((interaction) => interaction.kind), ['decision', 'context']);
|
|
200
|
+
const deckDir = mkdtempSync(join(tmpdir(), 'crtr-context-file-deck-'));
|
|
201
|
+
assert.notEqual(resolve(process.cwd()), deckDir, 'the CLI caller cwd must differ from the deck directory');
|
|
202
|
+
const deckPath = join(deckDir, 'deck.json');
|
|
203
|
+
writeFileSync(join(deckDir, 'body.md'), 'The body came from beside the deck JSON.');
|
|
204
|
+
writeFileSync(deckPath, JSON.stringify({
|
|
205
|
+
title: 'Context file body',
|
|
206
|
+
interactions: [{
|
|
207
|
+
id: 'decision',
|
|
208
|
+
title: 'Use the deck',
|
|
209
|
+
subtitle: 'Use the deck body so the person can decide with its context.',
|
|
210
|
+
bodyPath: 'body.md',
|
|
211
|
+
options: [{ id: 'yes', label: 'Yes' }],
|
|
212
|
+
}],
|
|
213
|
+
}));
|
|
214
|
+
const nodesBeforeContextFile = new Set(listNodes().map((node) => node.node_id));
|
|
215
|
+
await withMutedStdout(() => withPipedStdin('', async () => {
|
|
216
|
+
await runCli(await resolveRoot('human'), ['node', 'crtr', 'human', 'ask', '--context-file', deckPath]);
|
|
217
|
+
}));
|
|
218
|
+
const contextFileBridge = listNodes().find((node) => !nodesBeforeContextFile.has(node.node_id));
|
|
219
|
+
assert.ok(contextFileBridge, 'the real CLI invocation created a human bridge');
|
|
220
|
+
const contextFileDeck = JSON.parse(readFileSync(join(nodeDir(contextFileBridge.node_id), 'deck.json'), 'utf8'));
|
|
221
|
+
assert.equal(contextFileDeck.interactions[0]?.body, 'The body came from beside the deck JSON.');
|
|
222
|
+
assert.equal(contextFileDeck.interactions[0]?.bodyPath, undefined);
|
|
223
|
+
await assert.rejects(() => humanAsk.run({
|
|
224
|
+
deck: {
|
|
225
|
+
title: 'Absolute path',
|
|
226
|
+
interactions: [{
|
|
227
|
+
id: 'decision',
|
|
228
|
+
title: 'Reject absolute path',
|
|
229
|
+
subtitle: 'Reject the invalid path so the documented deck boundary remains intact.',
|
|
230
|
+
bodyPath: join(deckDir, 'body.md'),
|
|
231
|
+
options: [{ id: 'yes', label: 'Yes' }],
|
|
232
|
+
}],
|
|
233
|
+
},
|
|
234
|
+
}, { providedParams: new Set(['deck']), contextFilePaths: new Map([['deck', deckPath]]) }), /absolute paths are unsupported/);
|
|
175
235
|
const rc = JSON.parse(readFileSync(join(r['dir'], 'run.json'), 'utf8'));
|
|
176
236
|
assert.equal(rc['mode'], 'ask');
|
|
177
237
|
assert.equal(rc['job_id'], r['job_id']);
|
|
@@ -35,6 +35,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
35
35
|
import { spawn, spawnSync } from 'node:child_process';
|
|
36
36
|
import { createNode, getNode, subscribe, updateNode, clearPid } from '../canvas/canvas.js';
|
|
37
37
|
import { armFollowUpWork } from '../canvas/human-work-outbox.js';
|
|
38
|
+
import { armCron, hasPendingCancelOnWakeCron } from '../canvas/crons.js';
|
|
38
39
|
import { readInboxSince } from '../feed/inbox.js';
|
|
39
40
|
import { closeDb } from '../canvas/db.js';
|
|
40
41
|
import { buildPiArgv, CANVAS_EXTENSIONS } from '../runtime/launch.js';
|
|
@@ -346,6 +347,53 @@ test('reviveNode PROCEEDS when the node has NO fleet entry — cycle counter bum
|
|
|
346
347
|
await h.dispose();
|
|
347
348
|
}
|
|
348
349
|
});
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// BUG LOCKED — a recovery relaunch must not eat the node's armed deadline.
|
|
352
|
+
// A node armed `crtr node wait deadline` and dormed; its broker then exited and
|
|
353
|
+
// the daemon respawned it (fleet.#respawn → reviveNode). reviveNode used to
|
|
354
|
+
// cancel every cancel-on-wake cron unconditionally, so the deadline vanished
|
|
355
|
+
// with nothing having woken the node and the wait could never settle. A wake
|
|
356
|
+
// (inbox delivery, attach, explicit revive) still consumes it.
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
function armDeadline(anchor, cronId) {
|
|
359
|
+
armCron({
|
|
360
|
+
cron_id: cronId,
|
|
361
|
+
name: `deadline:${anchor}`,
|
|
362
|
+
created_by: anchor,
|
|
363
|
+
command: 'true',
|
|
364
|
+
fire_at: new Date(Date.now() + 3_600_000).toISOString(),
|
|
365
|
+
recur: null,
|
|
366
|
+
tz: null,
|
|
367
|
+
expires_at: null,
|
|
368
|
+
anchor_node: anchor,
|
|
369
|
+
cancel_on_wake: true,
|
|
370
|
+
cwd: home,
|
|
371
|
+
env_json: null,
|
|
372
|
+
profile: null,
|
|
373
|
+
scope: 'profile',
|
|
374
|
+
run_timeout_s: 300,
|
|
375
|
+
overlap: 'skip',
|
|
376
|
+
on_output: 'on-failure',
|
|
377
|
+
sink: '',
|
|
378
|
+
tier: 'normal',
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
test('a recovery relaunch keeps the armed deadline; a wake revive consumes it', async () => {
|
|
382
|
+
const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-deadline' });
|
|
383
|
+
try {
|
|
384
|
+
const recovered = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-1' });
|
|
385
|
+
armDeadline(recovered, 'cron-recovery');
|
|
386
|
+
reviveNode(recovered, { resume: true, recovery: true });
|
|
387
|
+
assert.equal(hasPendingCancelOnWakeCron(recovered), true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
|
|
388
|
+
const woken = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-2' });
|
|
389
|
+
armDeadline(woken, 'cron-wake');
|
|
390
|
+
reviveNode(woken, { resume: true });
|
|
391
|
+
assert.equal(hasPendingCancelOnWakeCron(woken), false, 'a wake revive won the race and cancels the deadline');
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
await h.dispose();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
349
397
|
test('reviveNode fresh-fallback clears the stale session identity (Major-3: no phantom stranded-relaunch)', async () => {
|
|
350
398
|
const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-freshfb' });
|
|
351
399
|
try {
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ export interface BashJobPaths {
|
|
|
4
4
|
cmdSh: string;
|
|
5
5
|
jobLog: string;
|
|
6
6
|
jobExit: string;
|
|
7
|
+
jobRun: string;
|
|
7
8
|
jobBg: string;
|
|
9
|
+
jobDone: string;
|
|
8
10
|
/** Process-group id of the job's detached supervisor, written at spawn. It is
|
|
9
11
|
* what makes a job stoppable from outside the agent that started it (the
|
|
10
12
|
* Inspector's cancel) — without it on disk, only the agent's own handoff
|
|
@@ -42,25 +44,22 @@ export interface BashJobStatus {
|
|
|
42
44
|
logPath: string;
|
|
43
45
|
}
|
|
44
46
|
/** Every live backgrounded bash job for this node — job.bg present, job.exit
|
|
45
|
-
* absent — whether handed off by the menu
|
|
46
|
-
*
|
|
47
|
-
* with) the foreground-only `runningBashJobs` above: that scanner deliberately
|
|
48
|
-
* EXCLUDES job.bg, this one REQUIRES it, so reusing one for the other would
|
|
49
|
-
* corrupt foreground-admission semantics. Skips malformed/partially-written
|
|
47
|
+
* absent — whether handed off by the menu, the valve deadline, or broker
|
|
48
|
+
* termination. Read-only; never mutates. Skips malformed/partially-written
|
|
50
49
|
* directories. Returns oldest-first by startedAtMs. */
|
|
51
50
|
export declare function activeBackgroundBashJobs(contextDir: string): BashJobStatus[];
|
|
52
|
-
|
|
51
|
+
export type BackgroundBashJobResult = 'backgrounded' | 'already-backgrounded' | 'finished' | 'missing';
|
|
52
|
+
/** Atomically hand one foreground job to its detached supervisor. The rename
|
|
53
|
+
* is the ownership decision: exactly one caller can move job.run to job.bg,
|
|
54
|
+
* while the supervisor races to move the same source to job.done. */
|
|
55
|
+
export declare function backgroundBashJob(paths: BashJobPaths): BackgroundBashJobResult;
|
|
56
|
+
/** The start time (ms epoch) of the oldest still-foreground bash command for
|
|
53
57
|
* this node, or undefined when none is running. This is the authoritative
|
|
54
|
-
* source for the attach viewer's
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* and the valve deletes the directory the moment the command finishes or is
|
|
58
|
-
* aborted — see canvas-bash-valve.ts), so a fresh or reconnected attach (which
|
|
59
|
-
* has no memory of a broker-relayed tool-execution event) sees exactly what's
|
|
60
|
-
* really running, and a viewer never needs its own call-id tracking that could
|
|
61
|
-
* go stale across a broker/session replacement. */
|
|
58
|
+
* source for the attach viewer's background hint: a fresh or reconnected
|
|
59
|
+
* attach reads the same job.run state that backgrounding and completion race
|
|
60
|
+
* to claim, without viewer-local call tracking that can go stale. */
|
|
62
61
|
export declare function oldestRunningBashJobStartedAtMs(contextDir: string): number | undefined;
|
|
63
|
-
/** Request immediate handoff for every bash command still waiting on pi.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
/** Request immediate handoff for every bash command still waiting on pi. Only
|
|
63
|
+
* the caller that wins the job.run → job.bg rename reports the job as handed
|
|
64
|
+
* off; another backgrounder or the foreground-completion path may win the race. */
|
|
66
65
|
export declare function backgroundRunningBashJobs(contextDir: string): BashJobPaths[];
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// File-backed control plane for bash commands
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// File-backed control plane for bash commands started by the canvas bash valve.
|
|
2
|
+
// Every foreground command owns job.run; backgrounding atomically renames it to
|
|
3
|
+
// job.bg, while foreground completion renames it to job.done. The detached
|
|
4
|
+
// supervisor can therefore outlive pi and still decide whether to report exit.
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
|
-
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync,
|
|
6
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
export function bashJobsDir(contextDir) {
|
|
9
9
|
return join(contextDir, 'jobs');
|
|
@@ -16,7 +16,9 @@ export function bashJobPaths(contextDir, jobId) {
|
|
|
16
16
|
cmdSh: join(dir, 'cmd.sh'),
|
|
17
17
|
jobLog: join(dir, 'job.log'),
|
|
18
18
|
jobExit: join(dir, 'job.exit'),
|
|
19
|
+
jobRun: join(dir, 'job.run'),
|
|
19
20
|
jobBg: join(dir, 'job.bg'),
|
|
21
|
+
jobDone: join(dir, 'job.done'),
|
|
20
22
|
jobPgid: join(dir, 'job.pgid'),
|
|
21
23
|
};
|
|
22
24
|
}
|
|
@@ -94,11 +96,8 @@ export function formatBashElapsed(ms) {
|
|
|
94
96
|
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
|
|
95
97
|
}
|
|
96
98
|
/** Every live backgrounded bash job for this node — job.bg present, job.exit
|
|
97
|
-
* absent — whether handed off by the menu
|
|
98
|
-
*
|
|
99
|
-
* with) the foreground-only `runningBashJobs` above: that scanner deliberately
|
|
100
|
-
* EXCLUDES job.bg, this one REQUIRES it, so reusing one for the other would
|
|
101
|
-
* corrupt foreground-admission semantics. Skips malformed/partially-written
|
|
99
|
+
* absent — whether handed off by the menu, the valve deadline, or broker
|
|
100
|
+
* termination. Read-only; never mutates. Skips malformed/partially-written
|
|
102
101
|
* directories. Returns oldest-first by startedAtMs. */
|
|
103
102
|
export function activeBackgroundBashJobs(contextDir) {
|
|
104
103
|
let entries;
|
|
@@ -127,9 +126,27 @@ export function activeBackgroundBashJobs(contextDir) {
|
|
|
127
126
|
}
|
|
128
127
|
return jobs.sort((a, b) => a.startedAtMs - b.startedAtMs);
|
|
129
128
|
}
|
|
130
|
-
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
129
|
+
/** Atomically hand one foreground job to its detached supervisor. The rename
|
|
130
|
+
* is the ownership decision: exactly one caller can move job.run to job.bg,
|
|
131
|
+
* while the supervisor races to move the same source to job.done. */
|
|
132
|
+
export function backgroundBashJob(paths) {
|
|
133
|
+
try {
|
|
134
|
+
renameSync(paths.jobRun, paths.jobBg);
|
|
135
|
+
return 'backgrounded';
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err.code !== 'ENOENT')
|
|
139
|
+
throw err;
|
|
140
|
+
if (existsSync(paths.jobBg))
|
|
141
|
+
return 'already-backgrounded';
|
|
142
|
+
if (existsSync(paths.jobDone))
|
|
143
|
+
return 'finished';
|
|
144
|
+
return 'missing';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Every still-foreground bash command for this node. job.run is the sole live
|
|
148
|
+
* foreground state; a handoff or foreground completion atomically removes it.
|
|
149
|
+
* Invalid or concurrently-removed directories are ignored. */
|
|
133
150
|
function runningBashJobs(contextDir) {
|
|
134
151
|
let entries;
|
|
135
152
|
try {
|
|
@@ -140,21 +157,16 @@ function runningBashJobs(contextDir) {
|
|
|
140
157
|
}
|
|
141
158
|
return entries.flatMap((jobId) => {
|
|
142
159
|
const paths = bashJobPaths(contextDir, jobId);
|
|
143
|
-
return existsSync(paths.cmdSh) && existsSync(paths.jobLog) &&
|
|
160
|
+
return existsSync(paths.cmdSh) && existsSync(paths.jobLog) && existsSync(paths.jobRun)
|
|
144
161
|
? [paths]
|
|
145
162
|
: [];
|
|
146
163
|
});
|
|
147
164
|
}
|
|
148
|
-
/** The start time (ms epoch) of the
|
|
165
|
+
/** The start time (ms epoch) of the oldest still-foreground bash command for
|
|
149
166
|
* this node, or undefined when none is running. This is the authoritative
|
|
150
|
-
* source for the attach viewer's
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* and the valve deletes the directory the moment the command finishes or is
|
|
154
|
-
* aborted — see canvas-bash-valve.ts), so a fresh or reconnected attach (which
|
|
155
|
-
* has no memory of a broker-relayed tool-execution event) sees exactly what's
|
|
156
|
-
* really running, and a viewer never needs its own call-id tracking that could
|
|
157
|
-
* go stale across a broker/session replacement. */
|
|
167
|
+
* source for the attach viewer's background hint: a fresh or reconnected
|
|
168
|
+
* attach reads the same job.run state that backgrounding and completion race
|
|
169
|
+
* to claim, without viewer-local call tracking that can go stale. */
|
|
158
170
|
export function oldestRunningBashJobStartedAtMs(contextDir) {
|
|
159
171
|
let oldest;
|
|
160
172
|
for (const paths of runningBashJobs(contextDir)) {
|
|
@@ -166,25 +178,14 @@ export function oldestRunningBashJobStartedAtMs(contextDir) {
|
|
|
166
178
|
}
|
|
167
179
|
return oldest;
|
|
168
180
|
}
|
|
169
|
-
/** Request immediate handoff for every bash command still waiting on pi.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
181
|
+
/** Request immediate handoff for every bash command still waiting on pi. Only
|
|
182
|
+
* the caller that wins the job.run → job.bg rename reports the job as handed
|
|
183
|
+
* off; another backgrounder or the foreground-completion path may win the race. */
|
|
172
184
|
export function backgroundRunningBashJobs(contextDir) {
|
|
173
185
|
const backgrounded = [];
|
|
174
186
|
for (const paths of runningBashJobs(contextDir)) {
|
|
175
|
-
|
|
176
|
-
// A job may exit or be claimed by another prefix press between the scan
|
|
177
|
-
// and this write. Recheck the two terminal/control files so that stale
|
|
178
|
-
// candidates never masquerade as a successful handoff.
|
|
179
|
-
if (existsSync(paths.jobExit) || existsSync(paths.jobBg))
|
|
180
|
-
continue;
|
|
181
|
-
writeFileSync(paths.jobBg, '');
|
|
187
|
+
if (backgroundBashJob(paths) === 'backgrounded')
|
|
182
188
|
backgrounded.push(paths);
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
// The valve owns cleanup; a concurrently removed directory is simply no
|
|
186
|
-
// longer a running command to hand off.
|
|
187
|
-
}
|
|
188
189
|
}
|
|
189
190
|
return backgrounded;
|
|
190
191
|
}
|
|
@@ -102,6 +102,31 @@ test('dashboardRowsAllFromSource shows a node watched through its live broker',
|
|
|
102
102
|
const rows = await dashboardRowsAllFromSource(source);
|
|
103
103
|
assert.equal(rows[0]?.viewed, true);
|
|
104
104
|
});
|
|
105
|
+
test('enrichRowsFromSource serializes a full dashboard source read', async () => {
|
|
106
|
+
const nodeRows = ['a', 'b', 'c'].map(row);
|
|
107
|
+
let inFlight = 0;
|
|
108
|
+
let maxInFlight = 0;
|
|
109
|
+
const source = {
|
|
110
|
+
getNode: async (id) => {
|
|
111
|
+
inFlight++;
|
|
112
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
113
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
114
|
+
inFlight--;
|
|
115
|
+
return meta(id);
|
|
116
|
+
},
|
|
117
|
+
getRow: async () => null,
|
|
118
|
+
listNodes: async () => nodeRows,
|
|
119
|
+
subscriptionsOf: async () => [],
|
|
120
|
+
subscribersOf: async () => [],
|
|
121
|
+
view: async () => [],
|
|
122
|
+
ticketCountsForView: async () => ({}),
|
|
123
|
+
hasActiveLiveSubscription: async () => false,
|
|
124
|
+
};
|
|
125
|
+
const rows = await dashboardRowsAllFromSource(source);
|
|
126
|
+
await enrichRowsFromSource(source, rows);
|
|
127
|
+
assert.equal(maxInFlight, 1);
|
|
128
|
+
assert.deepEqual(rows.map((entry) => entry.name), ['a', 'b', 'c']);
|
|
129
|
+
});
|
|
105
130
|
let home;
|
|
106
131
|
let localCwd;
|
|
107
132
|
let sessionFile;
|
|
@@ -160,8 +160,9 @@ export declare function setCronLastOutputHash(cron_id: string, hash: string): vo
|
|
|
160
160
|
* terminal node that stopped without finishing. */
|
|
161
161
|
export declare function hasPendingCancelOnWakeCron(anchor_node: string): boolean;
|
|
162
162
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
163
|
-
* Two seams call it: reviveNode on every revive
|
|
164
|
-
*
|
|
163
|
+
* Two seams call it: reviveNode on a WAKE — every revive except a recovery
|
|
164
|
+
* relaunch, which only replaces a dead broker instance and leaves the wait
|
|
165
|
+
* open (the wake won the race), and the `finish`/`cancel` lifecycle transitions (the wait ended
|
|
165
166
|
* with the node, so its deadline must not fire against a finalized or closed
|
|
166
167
|
* row). Standing declarative crons are untouched — only cancel-on-wake rows.
|
|
167
168
|
* Kills any in-flight
|
|
@@ -219,8 +219,9 @@ export function hasPendingCancelOnWakeCron(anchor_node) {
|
|
|
219
219
|
.get(anchor_node) !== undefined);
|
|
220
220
|
}
|
|
221
221
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
222
|
-
* Two seams call it: reviveNode on every revive
|
|
223
|
-
*
|
|
222
|
+
* Two seams call it: reviveNode on a WAKE — every revive except a recovery
|
|
223
|
+
* relaunch, which only replaces a dead broker instance and leaves the wait
|
|
224
|
+
* open (the wake won the race), and the `finish`/`cancel` lifecycle transitions (the wait ended
|
|
224
225
|
* with the node, so its deadline must not fire against a finalized or closed
|
|
225
226
|
* row). Standing declarative crons are untouched — only cancel-on-wake rows.
|
|
226
227
|
* Kills any in-flight
|
|
@@ -430,14 +430,18 @@ export async function enrichRowsFromSource(source, rows, asks) {
|
|
|
430
430
|
return;
|
|
431
431
|
const remote = source instanceof RemoteCanvasSource;
|
|
432
432
|
const askMap = asks ?? {};
|
|
433
|
-
|
|
433
|
+
// A full dashboard may contain thousands of rows. Keep source reads serial:
|
|
434
|
+
// ApiCanvasSource turns each into a unix-socket request, and an unbounded
|
|
435
|
+
// Promise.all can overflow crtrd's accept backlog then spuriously trigger
|
|
436
|
+
// the client's cold-daemon path even though the daemon is still running.
|
|
437
|
+
for (const row of todo) {
|
|
434
438
|
const meta = await source.getNode(row.node_id);
|
|
435
439
|
if (meta !== null)
|
|
436
440
|
row.name = fullName(meta);
|
|
437
441
|
row.ctx_tokens = remote ? 0 : (readNodeTelemetry(row.node_id).tokens_in ?? 0);
|
|
438
442
|
row.asks = askMap[row.node_id] ?? 0;
|
|
439
443
|
row.enriched = true;
|
|
440
|
-
}
|
|
444
|
+
}
|
|
441
445
|
}
|
|
442
446
|
/** goal (initial-prompt.md) and session parts are local disk reads — suppressed
|
|
443
447
|
* to undefined for a remote source. */
|
package/dist/core/command.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface LeafRunContext {
|
|
|
12
12
|
* as a positional argument, or explicitly passed; absence means TTY with no
|
|
13
13
|
* pipe (truly absent). */
|
|
14
14
|
readonly providedParams: ReadonlySet<string>;
|
|
15
|
+
/** Absolute source paths for JSON values supplied through `--context-file`,
|
|
16
|
+
* keyed by their camel-cased parameter name. The parsed JSON remains the
|
|
17
|
+
* leaf input; this metadata preserves its source directory for values that
|
|
18
|
+
* reference adjacent files. */
|
|
19
|
+
readonly contextFilePaths: ReadonlyMap<string, string>;
|
|
15
20
|
}
|
|
16
21
|
/** Opt-in flag that surfaces a node as an editor slash command (a pi prompt
|
|
17
22
|
* template / Claude Code command). When set, the bootstrap auto-writes a
|
|
@@ -149,6 +154,9 @@ export interface ParseArgvOptions {
|
|
|
149
154
|
* Called once with a ReadonlySet of parameter names that were supplied by
|
|
150
155
|
* the caller (distinct from defaulted or missing values). */
|
|
151
156
|
onProvidedParams?: (provided: ReadonlySet<string>) => void;
|
|
157
|
+
/** Optional collector for absolute source paths of JSON values supplied via
|
|
158
|
+
* `--context-file`, keyed by their camel-cased parameter name. */
|
|
159
|
+
onContextFilePaths?: (paths: ReadonlyMap<string, string>) => void;
|
|
152
160
|
/** The walked command path (e.g. `['push','final']`), supplied by the
|
|
153
161
|
* dispatcher. Used only to name the exact leaf in a schema-class error's
|
|
154
162
|
* `Next:` line, which mandates reading that leaf's `-h`. */
|
package/dist/core/command.js
CHANGED
|
@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs';
|
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
15
|
import { constants as osConstants } from 'node:os';
|
|
16
16
|
import { isatty } from 'node:tty';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Factory functions
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
@@ -268,6 +269,7 @@ function flagNameToKey(name) {
|
|
|
268
269
|
export async function parseArgv(params, tokens, options) {
|
|
269
270
|
const result = {};
|
|
270
271
|
const provided = new Set(); // Track explicitly-provided parameter names
|
|
272
|
+
const contextFilePaths = new Map();
|
|
271
273
|
// Index params by kind for quick lookup
|
|
272
274
|
const positionalParam = params.find((p) => p.kind === 'positional');
|
|
273
275
|
const stdinParam = params.find((p) => p.kind === 'stdin');
|
|
@@ -332,6 +334,7 @@ export async function parseArgv(params, tokens, options) {
|
|
|
332
334
|
}
|
|
333
335
|
const contextFileKey = flagNameToKey(contextFileParam.name);
|
|
334
336
|
result[contextFileKey] = parsed;
|
|
337
|
+
contextFilePaths.set(contextFileKey, resolve(pathVal));
|
|
335
338
|
provided.add(contextFileKey);
|
|
336
339
|
i++;
|
|
337
340
|
continue;
|
|
@@ -539,6 +542,9 @@ export async function parseArgv(params, tokens, options) {
|
|
|
539
542
|
if (options?.onProvidedParams !== undefined) {
|
|
540
543
|
options.onProvidedParams(provided);
|
|
541
544
|
}
|
|
545
|
+
if (options?.onContextFilePaths !== undefined) {
|
|
546
|
+
options.onContextFilePaths(contextFilePaths);
|
|
547
|
+
}
|
|
542
548
|
return result;
|
|
543
549
|
}
|
|
544
550
|
export async function runCli(root, argv, options) {
|
|
@@ -618,13 +624,17 @@ export async function runCli(root, argv, options) {
|
|
|
618
624
|
const leaf = node;
|
|
619
625
|
const params = leaf.help.params !== undefined ? leaf.help.params : [];
|
|
620
626
|
let providedParams = new Set();
|
|
627
|
+
let contextFilePaths = new Map();
|
|
621
628
|
const input = await parseArgv(params, remaining, {
|
|
622
629
|
leafPath: path,
|
|
623
630
|
onProvidedParams: (provided) => {
|
|
624
631
|
providedParams = provided;
|
|
625
632
|
},
|
|
633
|
+
onContextFilePaths: (paths) => {
|
|
634
|
+
contextFilePaths = paths;
|
|
635
|
+
},
|
|
626
636
|
});
|
|
627
|
-
const context = { providedParams };
|
|
637
|
+
const context = { providedParams, contextFilePaths };
|
|
628
638
|
const result = await operationIdContext.fresh(() => leaf.run(input, context));
|
|
629
639
|
if (result !== undefined && result !== null) {
|
|
630
640
|
if (isJsonOutput()) {
|
|
@@ -95,7 +95,9 @@ export function reviveAll() {
|
|
|
95
95
|
const result = { revived: [], failed: [] };
|
|
96
96
|
for (const meta of listDisconnected()) {
|
|
97
97
|
try {
|
|
98
|
-
|
|
98
|
+
// Reconnecting a disconnected broker is recovery, not a wake — a node
|
|
99
|
+
// waiting on an armed deadline keeps it across the sweep.
|
|
100
|
+
reviveNode(meta.node_id, { resume: true, recovery: true });
|
|
99
101
|
result.revived.push(meta.node_id);
|
|
100
102
|
}
|
|
101
103
|
catch (err) {
|
|
@@ -49,10 +49,17 @@ export interface ReviveResult {
|
|
|
49
49
|
/** Relaunch `nodeId`'s broker engine from its persisted recipe and update canvas
|
|
50
50
|
* meta. Opens no viewer (engine-only).
|
|
51
51
|
*
|
|
52
|
+
* `recovery: true` marks a launch that only replaces a broker instance which
|
|
53
|
+
* died or was torn down — the daemon's respawn after a broker exit, and the
|
|
54
|
+
* `reviveAll` sweep over disconnected nodes. Nothing external arrived for the
|
|
55
|
+
* node, so such a launch is NOT a wake and does not consume the node's armed
|
|
56
|
+
* deadline (see the cancelCronsOnWake call below).
|
|
57
|
+
*
|
|
52
58
|
* Throws if the node does not exist. All other failures propagate as-is —
|
|
53
59
|
* callers (daemon, command) decide how to handle.
|
|
54
60
|
*/
|
|
55
61
|
export declare function reviveNode(nodeId: string, opts: {
|
|
56
62
|
resume: boolean;
|
|
57
63
|
wakeReason?: ReviveWakeReason;
|
|
64
|
+
recovery?: boolean;
|
|
58
65
|
}): ReviveResult;
|
|
@@ -92,6 +92,12 @@ function isUnstartedBirth(nodeId) {
|
|
|
92
92
|
/** Relaunch `nodeId`'s broker engine from its persisted recipe and update canvas
|
|
93
93
|
* meta. Opens no viewer (engine-only).
|
|
94
94
|
*
|
|
95
|
+
* `recovery: true` marks a launch that only replaces a broker instance which
|
|
96
|
+
* died or was torn down — the daemon's respawn after a broker exit, and the
|
|
97
|
+
* `reviveAll` sweep over disconnected nodes. Nothing external arrived for the
|
|
98
|
+
* node, so such a launch is NOT a wake and does not consume the node's armed
|
|
99
|
+
* deadline (see the cancelCronsOnWake call below).
|
|
100
|
+
*
|
|
95
101
|
* Throws if the node does not exist. All other failures propagate as-is —
|
|
96
102
|
* callers (daemon, command) decide how to handle.
|
|
97
103
|
*/
|
|
@@ -309,9 +315,15 @@ export function reviveNode(nodeId, opts) {
|
|
|
309
315
|
// unchecked result, is what scopes a later diagnostic read to this attempt.
|
|
310
316
|
clearFault(nodeId);
|
|
311
317
|
const bootFault = beginBootFaultAttempt(nodeId);
|
|
312
|
-
// A cron armed --cancel-on-wake belongs to the dormancy being left, so
|
|
313
|
-
//
|
|
314
|
-
|
|
318
|
+
// A cron armed --cancel-on-wake belongs to the dormancy being left, so a WAKE
|
|
319
|
+
// of its anchor deletes it — an inbox delivery, an attach, an explicit
|
|
320
|
+
// revive. A recovery relaunch is not a wake: the broker instance died or was
|
|
321
|
+
// torn down and the daemon is putting the same waiting node back on its feet,
|
|
322
|
+
// with nothing external having arrived. The wait the deadline bounds is still
|
|
323
|
+
// open, so the deadline survives — the same rule lifecycle.ts applies to the
|
|
324
|
+
// `crash` transition (a deadline MUST survive instance death).
|
|
325
|
+
if (opts.recovery !== true)
|
|
326
|
+
cancelCronsOnWake(nodeId);
|
|
315
327
|
let launched;
|
|
316
328
|
try {
|
|
317
329
|
launched = headlessBrokerHost.launch(nodeId, inv, {
|