@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.
@@ -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 $2 job.log $3 job.exit $4 job.bg $5 jobId $6 nodeId
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 [ -f "$4" ]; then',
62
- ' crtr node message send "Background bash job $5 finished (exit $ec). Log: $2 Command: $1" --to "$6" --tier urgent || true',
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 background(auto = false) {
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.jobExit)) {
256
- offset = tailOnce(paths.jobLog, offset, onData);
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
- background();
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
- writeFileSync(paths.jobBg, '');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.168",
3
+ "version": "0.3.170",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.168",
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.168",
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})(?:\/|$)/g)].map((match) => match[1]);
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
- let uncertain = false;
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
- if (/(?:^|\s)\S*\/bin\/(?:crtr|crtrd|crouter)(?:\s|$)/.test(command)) uncertain = true;
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
- // An opaque stable shim means GC will skip regardless, so do not spend
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
- // argv identifies entry modules; cwd covers a node process launched from a
170
- // generation directory. Ask lsof for every candidate in ONE invocation:
171
- // starting lsof once per process costs ~0.5s on macOS, which turned hundreds
172
- // of live agent/tool processes into a multi-minute post-install stall.
173
- const cwd = await run('lsof', ['-n', '-P', '-a', '-p', nodePids.join(','), '-d', 'cwd', '-Fn'], RUNTIME_HOME, true);
174
- for (const id of generationIdsIn(cwd)) live.add(id);
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 path\n');
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 });