@north-light/crouter 0.3.167 → 0.3.169

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.
@@ -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