@north-light/crouter 0.3.302 → 0.3.304

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.
Files changed (85) hide show
  1. package/dist/api/__tests__/integration/client.test.js +37 -36
  2. package/dist/api/client.d.ts +32 -55
  3. package/dist/api/client.js +102 -98
  4. package/dist/api/dto/messages.d.ts +6 -5
  5. package/dist/api/errors.d.ts +3 -0
  6. package/dist/api/errors.js +10 -0
  7. package/dist/api/index.d.ts +1 -1
  8. package/dist/api/index.js +1 -1
  9. package/dist/builtin-memory/internal/plugins.md +1 -1
  10. package/dist/clients/attach/viewer.js +532 -532
  11. package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
  12. package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
  13. package/dist/commands/api-client.d.ts +3 -3
  14. package/dist/commands/api-client.js +3 -3
  15. package/dist/commands/memory/read.js +2 -1
  16. package/dist/commands/node/bash.js +6 -6
  17. package/dist/commands/node/message.js +3 -3
  18. package/dist/commands/sys/daemon.js +11 -13
  19. package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
  20. package/dist/core/__tests__/bash-guard.test.js +190 -0
  21. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
  22. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  23. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  24. package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
  25. package/dist/core/__tests__/helpers/harness.d.ts +2 -0
  26. package/dist/core/__tests__/helpers/harness.js +9 -0
  27. package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
  28. package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
  29. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
  30. package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
  31. package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
  32. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
  33. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
  34. package/dist/core/bash-guard.d.ts +6 -0
  35. package/dist/core/bash-guard.js +393 -0
  36. package/dist/core/bash-jobs.d.ts +20 -8
  37. package/dist/core/bash-jobs.js +40 -21
  38. package/dist/core/canvas/types.d.ts +4 -2
  39. package/dist/core/command.js +1 -1
  40. package/dist/core/fault-classifier.d.ts +1 -1
  41. package/dist/core/runtime/broker/event-projection.d.ts +0 -3
  42. package/dist/core/runtime/broker/event-projection.js +2 -15
  43. package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
  44. package/dist/core/runtime/broker/fault-retry.js +74 -6
  45. package/dist/core/runtime/broker-persona-guidance.js +12 -0
  46. package/dist/core/runtime/broker.js +0 -5
  47. package/dist/core/runtime/fault.js +1 -1
  48. package/dist/core/runtime/host.js +10 -1
  49. package/dist/core/runtime/spawn.js +5 -6
  50. package/dist/core/shell-segments.d.ts +42 -0
  51. package/dist/core/shell-segments.js +169 -0
  52. package/dist/core/substrate/surface-match.d.ts +0 -9
  53. package/dist/core/substrate/surface-match.js +4 -160
  54. package/dist/core/worktree-close.d.ts +4 -0
  55. package/dist/core/worktree-close.js +225 -0
  56. package/dist/core/worktree-containment.d.ts +14 -0
  57. package/dist/core/worktree-containment.js +48 -0
  58. package/dist/core/worktree-mutation-async.d.ts +13 -0
  59. package/dist/core/worktree-mutation-async.js +281 -0
  60. package/dist/core/worktree-sweep.js +17 -79
  61. package/dist/core/worktree.d.ts +1 -0
  62. package/dist/core/worktree.js +1 -1
  63. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
  64. package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
  65. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  66. package/dist/daemon/api/handlers/messages.js +7 -5
  67. package/dist/daemon/api/handlers/reports.js +3 -2
  68. package/dist/daemon/api/handlers/worktree.js +7 -6
  69. package/dist/daemon/fleet.d.ts +1 -1
  70. package/dist/daemon/fleet.js +30 -12
  71. package/dist/daemon/manage.d.ts +8 -5
  72. package/dist/daemon/manage.js +63 -40
  73. package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
  74. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
  75. package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
  76. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
  77. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
  78. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
  79. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
  80. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
  81. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -3
  82. package/dist/pi-extensions/canvas-bash-valve.js +75 -77
  83. package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
  84. package/package.json +1 -1
  85. package/runtime.lock.json +5 -5
@@ -0,0 +1,133 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { backgroundRunningBashJobs, bashJobsDir, bashJobPaths, readJobPgid, stopBackgroundBashJob } from '../../../core/bash-jobs.js';
8
+ import { capturePidIdentities, isPidAlive } from '../../../core/canvas/pid.js';
9
+ import { BACKGROUND_BASH_LOG_TRUNCATION_MARKER, MAX_BACKGROUND_BASH_LOG_BYTES, createValveOperations } from '../../canvas-bash-valve.js';
10
+ const POLL_MS = 20;
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+ async function waitFor(condition, message) {
15
+ const deadline = Date.now() + 4_000;
16
+ while (Date.now() < deadline) {
17
+ if (condition())
18
+ return;
19
+ await sleep(POLL_MS);
20
+ }
21
+ throw new Error(message);
22
+ }
23
+ async function backgroundOneJob(contextDir) {
24
+ await waitFor(() => backgroundRunningBashJobs(contextDir).length === 1, 'bash job did not start');
25
+ await waitFor(() => {
26
+ try {
27
+ return readdirSync(bashJobsDir(contextDir)).length === 1;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }, 'background job directory was not created');
33
+ return readdirSync(bashJobsDir(contextDir))[0];
34
+ }
35
+ function killOwnedProcess(pid) {
36
+ if (pid === undefined)
37
+ return;
38
+ try {
39
+ process.kill(pid, 'SIGKILL');
40
+ }
41
+ catch {
42
+ // The test already stopped this exact process.
43
+ }
44
+ }
45
+ test('background cancellation reaches a descendant that changed process groups', { timeout: 10_000 }, async () => {
46
+ const root = mkdtempSync(join(tmpdir(), 'crtr-bash-tree-'));
47
+ const contextDir = join(root, 'context');
48
+ const escapedPidPath = join(root, 'escaped.pid');
49
+ let escapedPid;
50
+ try {
51
+ const python = [
52
+ 'import os, pathlib, time',
53
+ 'os.setpgrp()',
54
+ `pathlib.Path(${JSON.stringify(escapedPidPath)}).write_text(f"{os.getpid()} {os.getpgrp()}")`,
55
+ 'time.sleep(30)',
56
+ ].join('; ');
57
+ const run = createValveOperations('test-node', contextDir).exec(`python3 -c ${JSON.stringify(python)}`, root, { onData: () => { } });
58
+ const jobId = await backgroundOneJob(contextDir);
59
+ await run;
60
+ await waitFor(() => existsSync(escapedPidPath), 'escaped descendant did not start');
61
+ const [pidText, pgidText] = readFileSync(escapedPidPath, 'utf8').split(' ');
62
+ escapedPid = Number(pidText);
63
+ const escapedPgid = Number(pgidText);
64
+ assert.ok(Number.isInteger(escapedPid) && escapedPid > 1);
65
+ assert.notEqual(escapedPgid, readJobPgid(contextDir, jobId));
66
+ const stopped = await stopBackgroundBashJob(contextDir, jobId);
67
+ assert.equal(stopped.kind, 'stopped');
68
+ assert.equal(stopped.signaled, true);
69
+ await waitFor(() => {
70
+ try {
71
+ process.kill(escapedPid, 0);
72
+ return false;
73
+ }
74
+ catch {
75
+ return true;
76
+ }
77
+ }, 'escaped descendant survived background cancellation');
78
+ }
79
+ finally {
80
+ killOwnedProcess(escapedPid);
81
+ rmSync(root, { recursive: true, force: true });
82
+ }
83
+ });
84
+ test('stale background metadata never signals a reused supervisor PID', async () => {
85
+ const root = mkdtempSync(join(tmpdir(), 'crtr-bash-stale-pid-'));
86
+ const contextDir = join(root, 'context');
87
+ const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30_000)']);
88
+ try {
89
+ assert.notEqual(child.pid, undefined);
90
+ const actualIdentity = capturePidIdentities([child.pid])?.get(child.pid);
91
+ assert.notEqual(actualIdentity, undefined);
92
+ const paths = bashJobPaths(contextDir, '1234567890-stale');
93
+ mkdirSync(paths.dir, { recursive: true });
94
+ writeFileSync(paths.jobBg, '');
95
+ writeFileSync(paths.jobPgid, String(child.pid));
96
+ writeFileSync(paths.jobPgidIdentity, 'stale-supervisor');
97
+ const stopped = await stopBackgroundBashJob(contextDir, paths.jobId);
98
+ assert.deepEqual(stopped, { kind: 'stopped', signaled: false, pgid: child.pid, logPath: paths.jobLog });
99
+ assert.equal(isPidAlive(child.pid), true, 'a process with a reused supervisor PID was signaled');
100
+ }
101
+ finally {
102
+ child.kill('SIGKILL');
103
+ rmSync(root, { recursive: true, force: true });
104
+ }
105
+ });
106
+ test('background log retains a capped prefix, truncation marker, and does not grow afterward', { timeout: 10_000 }, async () => {
107
+ const root = mkdtempSync(join(tmpdir(), 'crtr-bash-log-'));
108
+ const contextDir = join(root, 'context');
109
+ try {
110
+ const writer = [
111
+ 'const chunk = Buffer.alloc(1024 * 1024, 120)',
112
+ 'setTimeout(() => {',
113
+ ' for (let i = 0; i < 11; i += 1) process.stdout.write(chunk)',
114
+ ' setTimeout(() => process.stdout.write("after-cap"), 200)',
115
+ '}, 200)',
116
+ 'setTimeout(() => {}, 1_000)',
117
+ ].join('; ');
118
+ const run = createValveOperations('test-node', contextDir).exec(`${process.execPath} -e ${JSON.stringify(writer)}`, root, { onData: () => { } });
119
+ const jobId = await backgroundOneJob(contextDir);
120
+ await run;
121
+ const { jobExit, jobLog } = bashJobPaths(contextDir, jobId);
122
+ await waitFor(() => existsSync(jobLog) && readFileSync(jobLog, 'utf8').includes(BACKGROUND_BASH_LOG_TRUNCATION_MARKER), 'log did not record truncation');
123
+ const sizeAtCap = statSync(jobLog).size;
124
+ assert.ok(sizeAtCap <= MAX_BACKGROUND_BASH_LOG_BYTES);
125
+ await sleep(500);
126
+ assert.equal(statSync(jobLog).size, sizeAtCap, 'discarded output continued growing the log');
127
+ await waitFor(() => existsSync(jobExit), 'writer did not finish');
128
+ assert.equal(readFileSync(jobExit, 'utf8').trim(), '0', 'collector changed the writer exit status');
129
+ }
130
+ finally {
131
+ rmSync(root, { recursive: true, force: true });
132
+ }
133
+ });
@@ -1,8 +1,7 @@
1
1
  import { createBashToolDefinition, type BashOperations } from '@earendil-works/pi-coding-agent';
2
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
- /** Seconds a leading `sleep ...` will block for, or null when the command does
4
- * not open with a sleep (or its duration isn't statically knowable). */
5
- export declare function leadingSleepSeconds(command: string): number | null;
3
+ export declare const MAX_BACKGROUND_BASH_LOG_BYTES: number;
4
+ export declare const BACKGROUND_BASH_LOG_TRUNCATION_MARKER = "\n[background bash job output truncated at 10 MiB; remaining output discarded]\n";
6
5
  /** The valve's BashOperations backend. Each tool execution closes over its own
7
6
  * `takePurpose` and its own preview-result path, so concurrent calls cannot
8
7
  * exchange labels or result mirrors. The purpose is consumed even when the
@@ -18,8 +18,9 @@
18
18
  // SIGPIPE, and its exit code is observable by nobody. So every command is
19
19
  // file-backed from birth via a tiny detached supervisor (a `bash -c` process
20
20
  // group leader) that redirects the real command's stdout+stderr to a log
21
- // file and records its exit code to a sentinel file — both readable, and
22
- // tail-able, independent of whether this pi process is even still alive.
21
+ // file through a bounded collector and records its exit code to a sentinel
22
+ // file — both readable and tail-able, independent of whether this pi process
23
+ // is even still alive.
23
24
  //
24
25
  // Deviation from upstream: this backend pins `bash` as the shell (upstream
25
26
  // consults user shell config via non-exported helpers). Acceptable — crtr's
@@ -30,7 +31,9 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmS
30
31
  import { homedir } from 'node:os';
31
32
  import { dirname } from 'node:path';
32
33
  import { performance } from 'node:perf_hooks';
33
- import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, readBashNiceness, writeJobDeadlineMs } from '../core/bash-jobs.js';
34
+ import { backgroundBashJob, bashJobPaths, cancelBashJobProcessTree, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, readBashNiceness, writeJobDeadlineMs } from '../core/bash-jobs.js';
35
+ import { bashCommandRefusal } from '../core/bash-guard.js';
36
+ import { capturePidIdentities } from '../core/canvas/pid.js';
34
37
  import { readConfig } from '../core/config.js';
35
38
  import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
36
39
  import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
@@ -42,18 +45,44 @@ const POLL_MS = 200;
42
45
  function resolveValveMs() {
43
46
  return envBashValveMs();
44
47
  }
45
- // The detached supervisor. Spawned with `detached: true` so it becomes its
46
- // own process-group leader (pgid === its pid) — killing `-pgid` reaches the
47
- // real command and anything it forked, even after this pi process exits.
48
- //
49
- // Positional argv (never interpolated into the script — the command text
50
- // lives only in cmd.sh):
48
+ export const MAX_BACKGROUND_BASH_LOG_BYTES = 10 * 1024 * 1024;
49
+ export const BACKGROUND_BASH_LOG_TRUNCATION_MARKER = '\n[background bash job output truncated at 10 MiB; remaining output discarded]\n';
50
+ // This process owns one bounded prefix of the command stream. It keeps
51
+ // draining after the cap so the command never sees a closed pipe/SIGPIPE, and
52
+ // uses synchronous fixed-size stdin chunks plus direct file writes so neither
53
+ // the collector nor the log can grow without bound.
54
+ const LOG_COLLECTOR_SCRIPT = [
55
+ 'const fs = require("node:fs");',
56
+ `const cap = ${MAX_BACKGROUND_BASH_LOG_BYTES};`,
57
+ `const marker = Buffer.from(${JSON.stringify(BACKGROUND_BASH_LOG_TRUNCATION_MARKER)});`,
58
+ 'const prefixCap = cap - marker.length;',
59
+ 'const fd = fs.openSync(process.argv[1], "w");',
60
+ 'let written = 0;',
61
+ 'let truncated = false;',
62
+ 'process.stdin.on("data", (chunk) => {',
63
+ ' if (truncated) return;',
64
+ ' const available = prefixCap - written;',
65
+ ' if (available > 0) {',
66
+ ' const prefix = chunk.subarray(0, available);',
67
+ ' fs.writeSync(fd, prefix);',
68
+ ' written += prefix.length;',
69
+ ' if (prefix.length === chunk.length) return;',
70
+ ' }',
71
+ ' fs.writeSync(fd, marker);',
72
+ ' truncated = true;',
73
+ '});',
74
+ 'process.stdin.on("end", () => fs.closeSync(fd));',
75
+ ].join(' ');
76
+ // The detached supervisor owns the command and its bounded stream collector.
77
+ // Positional argv (never interpolated into the script — the command text lives
78
+ // only in cmd.sh):
51
79
  // $1 cmd.sh $2 job.log $3 job.exit $4 job.run
52
80
  // $5 job.done $6 job.bg $7 jobId $8 nodeId $9 shell path
53
- // ${10} niceness
81
+ // ${10} niceness ${11} node path
54
82
  //
55
83
  // `--` before the paths is a dummy $0 (a standard `bash -c script -- args`
56
84
  // idiom) so the real payload lands at $1 onward.
85
+ //
57
86
  // The elapsed rendering mirrors formatBashElapsed() in core/bash-jobs.ts
58
87
  // (`12s` / `3m 40s` / `1h 04m`) so the completion message reads the same as the
59
88
  // handoff notice; it measures TOTAL runtime, foreground portion included.
@@ -63,8 +92,8 @@ function resolveValveMs() {
63
92
  const SUPERVISOR_SCRIPT = [
64
93
  'SECONDS=0',
65
94
  'n=$(command -v nice)',
66
- '${n:+"$n" -n "${10}"} "${9}" "$1" > "$2" 2>&1',
67
- 'ec=$?',
95
+ '${n:+"$n" -n "${10}"} "${9}" "$1" 2>&1 | "${11}" -e ' + JSON.stringify(LOG_COLLECTOR_SCRIPT) + ' "$2"',
96
+ 'ec=${PIPESTATUS[0]}',
68
97
  'd=$SECONDS',
69
98
  'echo "$ec" > "$3"',
70
99
  'if [ "$d" -lt 60 ]; then',
@@ -106,25 +135,9 @@ function installTerminationHandoff() {
106
135
  if (process.platform !== 'win32')
107
136
  process.prependListener('SIGHUP', backgroundLiveJobsForTermination);
108
137
  }
109
- function killGroup(pgid) {
110
- try {
111
- process.kill(-pgid, 'SIGTERM');
112
- }
113
- catch {
114
- /* already gone */
115
- }
116
- const killer = setTimeout(() => {
117
- try {
118
- process.kill(-pgid, 'SIGKILL');
119
- }
120
- catch {
121
- /* already gone */
122
- }
123
- }, 2000);
124
- killer.unref?.();
125
- }
138
+ const TAIL_CHUNK_BYTES = 64 * 1024;
126
139
  /** Read bytes appended to `path` since `offset`, feed them to onData, return
127
- * the new offset. Best-effort — a not-yet-created log file is not an error. */
140
+ * the new offset. Best-effort — a not-yet-created log file is not an error. */
128
141
  function tailOnce(path, offset, onData) {
129
142
  let stat;
130
143
  try {
@@ -137,10 +150,16 @@ function tailOnce(path, offset, onData) {
137
150
  return offset;
138
151
  const fd = openSync(path, 'r');
139
152
  try {
140
- const buf = Buffer.alloc(stat.size - offset);
141
- readSync(fd, buf, 0, buf.length, offset);
142
- onData(buf);
143
- return stat.size;
153
+ while (offset < stat.size) {
154
+ const length = Math.min(TAIL_CHUNK_BYTES, stat.size - offset);
155
+ const buf = Buffer.allocUnsafe(length);
156
+ const bytesRead = readSync(fd, buf, 0, length, offset);
157
+ if (bytesRead === 0)
158
+ break;
159
+ onData(buf.subarray(0, bytesRead));
160
+ offset += bytesRead;
161
+ }
162
+ return offset;
144
163
  }
145
164
  finally {
146
165
  closeSync(fd);
@@ -164,39 +183,6 @@ function readExitCode(path) {
164
183
  return null;
165
184
  }
166
185
  }
167
- // Long-sleep guard. A command that opens with `sleep <long>` is a node burning
168
- // a live window to wait on a clock — the runtime already has waiting primitives
169
- // that cost nothing (`crtr cron` for a scheduled wake, `crtr node wait deadline`
170
- // for an inbox-vs-deadline race). Anything over 10s is refused before it runs.
171
- const MAX_INLINE_SLEEP_SECONDS = 10;
172
- const SLEEP_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
173
- /** Seconds a leading `sleep ...` will block for, or null when the command does
174
- * not open with a sleep (or its duration isn't statically knowable). */
175
- export function leadingSleepSeconds(command) {
176
- const match = /^\s*sleep(\s+[^;&|\n]+)/.exec(command);
177
- if (!match)
178
- return null;
179
- // `sleep 1 2m 3` sums its operands.
180
- let total = 0;
181
- for (const operand of match[1].trim().split(/\s+/)) {
182
- const parsed = /^([0-9]*\.?[0-9]+)([smhd]?)$/.exec(operand);
183
- if (!parsed)
184
- return null; // variable/expansion/flag — not statically knowable
185
- total += Number(parsed[1]) * SLEEP_UNIT_SECONDS[parsed[2] || 's'];
186
- }
187
- return total;
188
- }
189
- function longSleepRefusal(command) {
190
- const seconds = leadingSleepSeconds(command);
191
- if (seconds === null || seconds <= MAX_INLINE_SLEEP_SECONDS)
192
- return null;
193
- return (`Refused: this command starts by sleeping ${seconds}s, over the ${MAX_INLINE_SLEEP_SECONDS}s inline limit.\n` +
194
- `Holding a live window on a clock is never how you wait here. Instead:\n` +
195
- ` - schedule the wake and end your turn: crtr cron -h (one-shot at a time, or recurring)\n` +
196
- ` - race an inbox message against a deadline: crtr node wait deadline -h\n` +
197
- ` - waiting on a child, a reply from the user, or any spine event: just stop — the runtime wakes you.\n` +
198
- `If you truly need a short pause inline, sleep ${MAX_INLINE_SLEEP_SECONDS}s or less.`);
199
- }
200
186
  /** A node can legitimately remove its own merged worktree. Never recreate that
201
187
  * path: it would look like a valid checkout but no longer be one. Run later
202
188
  * commands from a durable directory instead, and make the changed semantics
@@ -228,7 +214,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
228
214
  reject(new Error('aborted'));
229
215
  return;
230
216
  }
231
- const refusal = longSleepRefusal(command);
217
+ const refusal = bashCommandRefusal(command, cwd);
232
218
  if (refusal !== null) {
233
219
  reject(new Error(refusal));
234
220
  return;
@@ -243,6 +229,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
243
229
  let offset = 0;
244
230
  let settled = false;
245
231
  let pgid;
232
+ let pgidIdentity;
246
233
  const startedAt = performance.now();
247
234
  const valveMs = resolveValveMs();
248
235
  let interval;
@@ -277,7 +264,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
277
264
  const notice = `\n[backgrounded as job ${paths.jobId} after ${elapsed} in the foreground${auto ? ' (auto)' : ''}. ` +
278
265
  deadlineNotice +
279
266
  `It will interrupt you with a message when it exits. ` +
280
- `Watch: tail -f ${paths.jobLog} ; stop: kill -- -${pgid}]`;
267
+ `Watch: tail -f ${paths.jobLog} ; stop: crtr node bash kill ${paths.jobId}]`;
281
268
  onData(Buffer.from(notice));
282
269
  finish(() => resolve({ exitCode: 0 }));
283
270
  // Backgrounded — job dir persists (inspectable); no cleanup here.
@@ -311,8 +298,8 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
311
298
  // finish the job.run → job.done transition before cleanup.
312
299
  if (existsSync(paths.jobExit))
313
300
  return;
314
- if (pgid)
315
- killGroup(pgid);
301
+ if (pgid !== undefined && pgidIdentity !== undefined)
302
+ void cancelBashJobProcessTree(pgid, pgidIdentity);
316
303
  finish(() => reject(new Error('aborted')));
317
304
  cleanupJobDir(paths.dir);
318
305
  }
@@ -334,7 +321,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
334
321
  childEnv = { ...childEnv, [PREVIEW_RESULT_PATH_ENV]: previewPath };
335
322
  }
336
323
  const shellPath = resolveAdmittedHostCommand('bash', childEnv, execution.cwd);
337
- const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath, String(readBashNiceness(contextDir))], {
324
+ const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath, String(readBashNiceness(contextDir)), process.execPath], {
338
325
  cwd: execution.cwd,
339
326
  detached: true,
340
327
  stdio: 'ignore',
@@ -345,11 +332,22 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
345
332
  cleanupJobDir(paths.dir);
346
333
  });
347
334
  pgid = child.pid;
348
- // The detached supervisor IS the group leader, so its pid is the pgid.
349
- // Persisting it is what lets a human stop the job from the Inspector
350
- // later; the agent's own handoff notice quotes the same number.
335
+ // The detached supervisor starts this process tree. Its launch-time
336
+ // identity lets cancellation reject a stale PID that now belongs to an
337
+ // unrelated process; it is not a containment guarantee.
351
338
  if (pgid !== undefined) {
339
+ pgidIdentity = capturePidIdentities([pgid])?.get(pgid);
340
+ if (pgidIdentity === undefined) {
341
+ try {
342
+ process.kill(-pgid, 'SIGKILL');
343
+ }
344
+ catch { /* the just-started supervisor already exited */ }
345
+ finish(() => reject(new Error(`bash job ${paths.jobId} started without a process identity`)));
346
+ cleanupJobDir(paths.dir);
347
+ return;
348
+ }
352
349
  writeFileSync(paths.jobPgid, String(pgid));
350
+ writeFileSync(paths.jobPgidIdentity, pgidIdentity);
353
351
  liveForegroundJobs.add(paths);
354
352
  }
355
353
  child.unref();
@@ -370,8 +368,8 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
370
368
  const elapsedMs = performance.now() - startedAt;
371
369
  if (timeout !== undefined && timeout > 0) {
372
370
  if (elapsedMs >= timeout * 1000) {
373
- if (pgid)
374
- killGroup(pgid);
371
+ if (pgid !== undefined && pgidIdentity !== undefined)
372
+ void cancelBashJobProcessTree(pgid, pgidIdentity);
375
373
  finish(() => reject(new Error(`timeout:${timeout}`)));
376
374
  cleanupJobDir(paths.dir);
377
375
  }
@@ -344,8 +344,6 @@ class CanvasInboxWatcher {
344
344
  * reports would render as bare refs instead of inlined bodies. One refresh
345
345
  * per unknown node id keeps a genuinely missing node from stalling delivery. */
346
346
  refreshForUnknownReportRefs() {
347
- if (this.reportNodesRefreshing)
348
- return true;
349
347
  const unknown = [];
350
348
  for (const entry of this.buffer) {
351
349
  const refNodeId = reportRefNodeId(entry.ref);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.302",
3
+ "version": "0.3.304",
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.302",
3
+ "version": "0.3.304",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.302",
9
+ "version": "0.3.304",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.302",
5209
+ "version": "0.3.304",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.302",
5214
+ "version": "0.3.304",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.302",
5219
+ "version": "0.3.304",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"