@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
package/dist/daemon/fleet.js
CHANGED
|
@@ -352,6 +352,10 @@ export class DaemonFleet {
|
|
|
352
352
|
reviveNode(nodeId, {
|
|
353
353
|
resume: action === 'respawn-resume',
|
|
354
354
|
wakeReason: cleanAbort ? 'runtime-restart-abort' : undefined,
|
|
355
|
+
// Replacing a dead broker instance is recovery, not a wake: nothing
|
|
356
|
+
// external arrived for this node, so an armed deadline survives the
|
|
357
|
+
// relaunch instead of being consumed by it.
|
|
358
|
+
recovery: true,
|
|
355
359
|
});
|
|
356
360
|
}
|
|
357
361
|
catch (err) {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { spawn } from 'node:child_process';
|
|
28
28
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
29
29
|
import { homedir } from 'node:os';
|
|
30
|
-
import { bashJobPaths, formatBashElapsed, newBashJobId } from '../core/bash-jobs.js';
|
|
30
|
+
import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId } from '../core/bash-jobs.js';
|
|
31
31
|
import { createBashToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
33
33
|
// Deadline — 5 minutes by default, overridable via CRTR_BASH_VALVE_MS
|
|
@@ -49,7 +49,8 @@ function resolveValveMs() {
|
|
|
49
49
|
//
|
|
50
50
|
// Positional argv (never interpolated into the script — the command text
|
|
51
51
|
// lives only in cmd.sh):
|
|
52
|
-
// $1 cmd.sh
|
|
52
|
+
// $1 cmd.sh $2 job.log $3 job.exit $4 job.run
|
|
53
|
+
// $5 job.done $6 job.bg $7 jobId $8 nodeId
|
|
53
54
|
//
|
|
54
55
|
// `--` before the paths is a dummy $0 (a standard `bash -c script -- args`
|
|
55
56
|
// idiom) so the real payload lands at $1 onward.
|
|
@@ -58,13 +59,38 @@ const SUPERVISOR_SCRIPT = [
|
|
|
58
59
|
'bash "$1" > "$2" 2>&1',
|
|
59
60
|
'ec=$?',
|
|
60
61
|
'echo "$ec" > "$3"',
|
|
61
|
-
'if
|
|
62
|
-
'
|
|
62
|
+
'if mv "$4" "$5" 2>/dev/null; then',
|
|
63
|
+
' :',
|
|
64
|
+
'elif [ -f "$6" ]; then',
|
|
65
|
+
' crtr node message send "Background bash job $7 finished (exit $ec). Log: $2 Command: $1" --to "$8" --tier urgent || true',
|
|
63
66
|
'fi',
|
|
64
67
|
].join('\n');
|
|
65
68
|
function makeJobPaths(contextDir) {
|
|
66
69
|
return bashJobPaths(contextDir, newBashJobId());
|
|
67
70
|
}
|
|
71
|
+
const liveForegroundJobs = new Set();
|
|
72
|
+
let terminationHandoffInstalled = false;
|
|
73
|
+
/** The broker is about to exit. Persist every foreground handoff synchronously
|
|
74
|
+
* before pi's own termination listener aborts its active tool calls. */
|
|
75
|
+
function backgroundLiveJobsForTermination() {
|
|
76
|
+
for (const paths of [...liveForegroundJobs]) {
|
|
77
|
+
try {
|
|
78
|
+
backgroundBashJob(paths);
|
|
79
|
+
liveForegroundJobs.delete(paths);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
process.stderr.write(`crtr: failed to background bash job ${paths.jobId} during broker termination: ${err.message}\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function installTerminationHandoff() {
|
|
87
|
+
if (terminationHandoffInstalled)
|
|
88
|
+
return;
|
|
89
|
+
terminationHandoffInstalled = true;
|
|
90
|
+
process.prependListener('SIGTERM', backgroundLiveJobsForTermination);
|
|
91
|
+
if (process.platform !== 'win32')
|
|
92
|
+
process.prependListener('SIGHUP', backgroundLiveJobsForTermination);
|
|
93
|
+
}
|
|
68
94
|
function killGroup(pgid) {
|
|
69
95
|
try {
|
|
70
96
|
process.kill(-pgid, 'SIGTERM');
|
|
@@ -194,6 +220,7 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
194
220
|
mkdirSync(paths.dir, { recursive: true });
|
|
195
221
|
writeFileSync(paths.cmdSh, command);
|
|
196
222
|
writeFileSync(paths.jobLog, '');
|
|
223
|
+
writeFileSync(paths.jobRun, '');
|
|
197
224
|
let offset = 0;
|
|
198
225
|
let settled = false;
|
|
199
226
|
let pgid;
|
|
@@ -205,6 +232,7 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
205
232
|
clearInterval(interval);
|
|
206
233
|
if (signal)
|
|
207
234
|
signal.removeEventListener('abort', onAbort);
|
|
235
|
+
liveForegroundJobs.delete(paths);
|
|
208
236
|
};
|
|
209
237
|
const finish = (act) => {
|
|
210
238
|
if (settled)
|
|
@@ -213,7 +241,7 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
213
241
|
teardown();
|
|
214
242
|
act();
|
|
215
243
|
};
|
|
216
|
-
function
|
|
244
|
+
function finishBackground(auto = false) {
|
|
217
245
|
const elapsed = formatBashElapsed(Date.now() - startedAt);
|
|
218
246
|
const notice = `\n[backgrounded as job ${paths.jobId} after ${elapsed} in the foreground${auto ? ' (auto)' : ''}. ` +
|
|
219
247
|
`It will interrupt you with a message when it exits. ` +
|
|
@@ -222,8 +250,35 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
222
250
|
finish(() => resolve({ exitCode: 0 }));
|
|
223
251
|
// Backgrounded — job dir persists (inspectable); no cleanup here.
|
|
224
252
|
}
|
|
253
|
+
function finishForeground() {
|
|
254
|
+
offset = tailOnce(paths.jobLog, offset, onData);
|
|
255
|
+
const exitCode = readExitCode(paths.jobExit);
|
|
256
|
+
finish(() => resolve({ exitCode }));
|
|
257
|
+
cleanupJobDir(paths.dir);
|
|
258
|
+
}
|
|
259
|
+
function requestBackground(auto = false) {
|
|
260
|
+
const state = backgroundBashJob(paths);
|
|
261
|
+
if (state === 'backgrounded' || state === 'already-backgrounded') {
|
|
262
|
+
finishBackground(auto);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (state === 'finished') {
|
|
266
|
+
finishForeground();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
finish(() => reject(new Error(`bash job ${paths.jobId} disappeared before it could be backgrounded`)));
|
|
270
|
+
cleanupJobDir(paths.dir);
|
|
271
|
+
}
|
|
225
272
|
function onAbort() {
|
|
226
|
-
tailOnce(paths.jobLog, offset, onData);
|
|
273
|
+
offset = tailOnce(paths.jobLog, offset, onData);
|
|
274
|
+
if (existsSync(paths.jobBg)) {
|
|
275
|
+
finishBackground();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Once the command has written its exit code, let the supervisor
|
|
279
|
+
// finish the job.run → job.done transition before cleanup.
|
|
280
|
+
if (existsSync(paths.jobExit))
|
|
281
|
+
return;
|
|
227
282
|
if (pgid)
|
|
228
283
|
killGroup(pgid);
|
|
229
284
|
finish(() => reject(new Error('aborted')));
|
|
@@ -234,7 +289,7 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
234
289
|
const execution = resolveExecutionCwd(cwd);
|
|
235
290
|
if (execution.warning)
|
|
236
291
|
onData(Buffer.from(execution.warning));
|
|
237
|
-
const child = spawn('bash', ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobBg, paths.jobId, nodeId], {
|
|
292
|
+
const child = spawn('bash', ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId], {
|
|
238
293
|
cwd: execution.cwd,
|
|
239
294
|
detached: true,
|
|
240
295
|
stdio: 'ignore',
|
|
@@ -242,27 +297,31 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
242
297
|
});
|
|
243
298
|
child.on('error', (err) => {
|
|
244
299
|
finish(() => reject(err));
|
|
300
|
+
cleanupJobDir(paths.dir);
|
|
245
301
|
});
|
|
246
302
|
pgid = child.pid;
|
|
247
303
|
// The detached supervisor IS the group leader, so its pid is the pgid.
|
|
248
304
|
// Persisting it is what lets a human stop the job from the Inspector
|
|
249
305
|
// later; the agent's own handoff notice quotes the same number.
|
|
250
|
-
if (pgid !== undefined)
|
|
306
|
+
if (pgid !== undefined) {
|
|
251
307
|
writeFileSync(paths.jobPgid, String(pgid));
|
|
308
|
+
liveForegroundJobs.add(paths);
|
|
309
|
+
}
|
|
252
310
|
child.unref();
|
|
253
311
|
interval = setInterval(() => {
|
|
254
312
|
offset = tailOnce(paths.jobLog, offset, onData);
|
|
255
|
-
if (existsSync(paths.
|
|
256
|
-
|
|
257
|
-
const exitCode = readExitCode(paths.jobExit);
|
|
258
|
-
finish(() => resolve({ exitCode }));
|
|
259
|
-
cleanupJobDir(paths.dir);
|
|
313
|
+
if (existsSync(paths.jobDone)) {
|
|
314
|
+
finishForeground();
|
|
260
315
|
return;
|
|
261
316
|
}
|
|
262
317
|
if (existsSync(paths.jobBg)) {
|
|
263
|
-
|
|
318
|
+
finishBackground();
|
|
264
319
|
return;
|
|
265
320
|
}
|
|
321
|
+
// job.exit is written before the supervisor claims job.done. Do not
|
|
322
|
+
// time out or delete the directory during that in-progress exit.
|
|
323
|
+
if (existsSync(paths.jobExit))
|
|
324
|
+
return;
|
|
266
325
|
const elapsedMs = Date.now() - startedAt;
|
|
267
326
|
if (timeout !== undefined && timeout > 0) {
|
|
268
327
|
if (elapsedMs >= timeout * 1000) {
|
|
@@ -273,10 +332,8 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
273
332
|
}
|
|
274
333
|
return;
|
|
275
334
|
}
|
|
276
|
-
if (elapsedMs >= valveMs)
|
|
277
|
-
|
|
278
|
-
background(true);
|
|
279
|
-
}
|
|
335
|
+
if (elapsedMs >= valveMs)
|
|
336
|
+
requestBackground(true);
|
|
280
337
|
}, POLL_MS);
|
|
281
338
|
});
|
|
282
339
|
},
|
|
@@ -292,6 +349,7 @@ export default function (pi) {
|
|
|
292
349
|
const contextDir = process.env['CRTR_CONTEXT_DIR'];
|
|
293
350
|
if (contextDir === undefined || contextDir.trim() === '')
|
|
294
351
|
return; // defensive; always set alongside CRTR_NODE_ID
|
|
352
|
+
installTerminationHandoff();
|
|
295
353
|
// Registered on session_start (fires for startup, new, resume, fork, and
|
|
296
354
|
// /reload) so we get ctx.cwd — the same session cwd the builtin bash tool
|
|
297
355
|
// is built against. registerTool replaces the builtin by name; re-firing on
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.170",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.170",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
|
@@ -25,6 +25,17 @@ function run(command, args, cwd, capture = false, env = process.env) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Capture a probe's stdout even when a candidate process exits during the probe. */
|
|
29
|
+
function probe(command, args, cwd) {
|
|
30
|
+
return new Promise((resolveRun) => {
|
|
31
|
+
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
32
|
+
let output = '';
|
|
33
|
+
child.stdout.on('data', (chunk) => { output += chunk; });
|
|
34
|
+
child.on('error', () => resolveRun(output));
|
|
35
|
+
child.on('exit', () => resolveRun(output));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
function digest(value) {
|
|
29
40
|
return createHash('sha256').update(value).digest('hex');
|
|
30
41
|
}
|
|
@@ -146,32 +157,44 @@ async function pack(archiveDir) {
|
|
|
146
157
|
return join(archiveDir, packed[0].filename);
|
|
147
158
|
}
|
|
148
159
|
|
|
160
|
+
/** Generation ids named anywhere in a `ps` or `lsof` dump. */
|
|
149
161
|
function generationIdsIn(text) {
|
|
150
|
-
return [...text.matchAll(/generations\/([a-f0-9]{64})(
|
|
162
|
+
return [...text.matchAll(/generations\/([a-f0-9]{64})(?:[/\s]|$)/g)].map((match) => match[1]);
|
|
151
163
|
}
|
|
152
164
|
|
|
165
|
+
/** Return every generation used by a running process and whether every stable
|
|
166
|
+
* selector process exposed its generation lease. */
|
|
153
167
|
async function liveGenerationIds() {
|
|
154
168
|
const output = await run('ps', ['-axww', '-o', 'pid=,command='], RUNTIME_HOME, true);
|
|
155
169
|
const live = new Set(generationIdsIn(output));
|
|
156
170
|
const nodePids = [];
|
|
157
|
-
|
|
171
|
+
const opaqueStablePids = new Set();
|
|
158
172
|
for (const line of output.split('\n')) {
|
|
159
173
|
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
160
174
|
if (!match) continue;
|
|
161
175
|
const [, pid, command] = match;
|
|
162
|
-
|
|
176
|
+
const stableSelector = /^(?:\S*node\s+)?\S*\/bin\/(?:crtr|crtrd|crouter)(?:\s|$)/.test(command);
|
|
177
|
+
if (stableSelector && generationIdsIn(command).length === 0) opaqueStablePids.add(pid);
|
|
163
178
|
if (/(?:^|[\s/])(?:node|crtrd?|crouter)(?:[\s]|$)/.test(command)) nodePids.push(pid);
|
|
164
179
|
}
|
|
165
|
-
|
|
166
|
-
// minutes resolving process cwd values that cannot change that decision.
|
|
167
|
-
if (uncertain || nodePids.length === 0) return { live, uncertain };
|
|
180
|
+
if (nodePids.length === 0) return { live, uncertain: opaqueStablePids.size > 0 };
|
|
168
181
|
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
for (const
|
|
182
|
+
// One lsof invocation resolves cwd references and the runtime entry-file
|
|
183
|
+
// lease held by each stable CLI process without a per-process startup penalty.
|
|
184
|
+
const open = await probe('lsof', ['-n', '-P', '-a', '-p', nodePids.join(','), '-Fn'], RUNTIME_HOME);
|
|
185
|
+
let pid;
|
|
186
|
+
const exposedStablePids = new Set();
|
|
187
|
+
for (const line of open.split('\n')) {
|
|
188
|
+
const processMatch = /^p(\d+)$/.exec(line);
|
|
189
|
+
if (processMatch) {
|
|
190
|
+
pid = processMatch[1];
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const ids = generationIdsIn(line);
|
|
194
|
+
for (const id of ids) live.add(id);
|
|
195
|
+
if (pid !== undefined && ids.length > 0 && opaqueStablePids.has(pid)) exposedStablePids.add(pid);
|
|
196
|
+
}
|
|
197
|
+
const uncertain = [...opaqueStablePids].some((stablePid) => !exposedStablePids.has(stablePid));
|
|
175
198
|
return { live, uncertain };
|
|
176
199
|
}
|
|
177
200
|
|
|
@@ -186,7 +209,7 @@ async function pruneGenerations() {
|
|
|
186
209
|
const selected = await selectedGenerationId();
|
|
187
210
|
const { live, uncertain } = await liveGenerationIds();
|
|
188
211
|
if (uncertain) {
|
|
189
|
-
process.stderr.write('runtime generation GC skipped: a live stable crouter shim does not expose its generation
|
|
212
|
+
process.stderr.write('runtime generation GC skipped: a live stable crouter shim does not expose its generation lease\n');
|
|
190
213
|
return;
|
|
191
214
|
}
|
|
192
215
|
const entries = await readdir(GENERATIONS, { withFileTypes: true });
|