@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.
- package/dist/api/__tests__/integration/client.test.js +37 -36
- package/dist/api/client.d.ts +32 -55
- package/dist/api/client.js +102 -98
- package/dist/api/dto/messages.d.ts +6 -5
- package/dist/api/errors.d.ts +3 -0
- package/dist/api/errors.js +10 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/clients/attach/viewer.js +532 -532
- package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
- package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
- package/dist/commands/api-client.d.ts +3 -3
- package/dist/commands/api-client.js +3 -3
- package/dist/commands/memory/read.js +2 -1
- package/dist/commands/node/bash.js +6 -6
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/sys/daemon.js +11 -13
- package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
- package/dist/core/__tests__/bash-guard.test.js +190 -0
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
- package/dist/core/__tests__/helpers/harness.d.ts +2 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
- package/dist/core/bash-guard.d.ts +6 -0
- package/dist/core/bash-guard.js +393 -0
- package/dist/core/bash-jobs.d.ts +20 -8
- package/dist/core/bash-jobs.js +40 -21
- package/dist/core/canvas/types.d.ts +4 -2
- package/dist/core/command.js +1 -1
- package/dist/core/fault-classifier.d.ts +1 -1
- package/dist/core/runtime/broker/event-projection.d.ts +0 -3
- package/dist/core/runtime/broker/event-projection.js +2 -15
- package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
- package/dist/core/runtime/broker/fault-retry.js +74 -6
- package/dist/core/runtime/broker-persona-guidance.js +12 -0
- package/dist/core/runtime/broker.js +0 -5
- package/dist/core/runtime/fault.js +1 -1
- package/dist/core/runtime/host.js +10 -1
- package/dist/core/runtime/spawn.js +5 -6
- package/dist/core/shell-segments.d.ts +42 -0
- package/dist/core/shell-segments.js +169 -0
- package/dist/core/substrate/surface-match.d.ts +0 -9
- package/dist/core/substrate/surface-match.js +4 -160
- package/dist/core/worktree-close.d.ts +4 -0
- package/dist/core/worktree-close.js +225 -0
- package/dist/core/worktree-containment.d.ts +14 -0
- package/dist/core/worktree-containment.js +48 -0
- package/dist/core/worktree-mutation-async.d.ts +13 -0
- package/dist/core/worktree-mutation-async.js +281 -0
- package/dist/core/worktree-sweep.js +17 -79
- package/dist/core/worktree.d.ts +1 -0
- package/dist/core/worktree.js +1 -1
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
- package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +7 -5
- package/dist/daemon/api/handlers/reports.js +3 -2
- package/dist/daemon/api/handlers/worktree.js +7 -6
- package/dist/daemon/fleet.d.ts +1 -1
- package/dist/daemon/fleet.js +30 -12
- package/dist/daemon/manage.d.ts +8 -5
- package/dist/daemon/manage.js +63 -40
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -3
- package/dist/pi-extensions/canvas-bash-valve.js +75 -77
- package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
- package/package.json +1 -1
- 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
|
-
|
|
4
|
-
|
|
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
|
|
22
|
-
// tail-able, independent of whether this pi process
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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"
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
349
|
-
//
|
|
350
|
-
//
|
|
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
|
-
|
|
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
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.304",
|
|
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.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.
|
|
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.
|
|
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.
|
|
5219
|
+
"version": "0.3.304",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|