pi-claude-supervisor 0.5.0 → 0.5.2
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/CHANGELOG.md +30 -1
- package/README.cn.md +26 -9
- package/README.md +38 -18
- package/docs/architecture.md +71 -15
- package/docs/automation-hardening-plan.md +211 -0
- package/docs/engineering-plan.md +76 -3
- package/docs/recovery-review.md +24 -0
- package/docs/stability-matrix-2.1.270.md +32 -0
- package/docs/testing.md +90 -21
- package/package.json +1 -1
- package/src/cwd-lease.ts +95 -6
- package/src/decision-session-store.ts +343 -37
- package/src/decision-worker.ts +97 -13
- package/src/index.ts +189 -88
- package/src/redaction.ts +2 -1
- package/src/reviewer.ts +69 -16
- package/src/supervisor.ts +276 -45
- package/src/types.ts +6 -2
- package/src/verifier.ts +180 -22
- package/src/worker/process-adapter.ts +245 -47
- package/src/worker/tmux-adapter.ts +42 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { constants as fsConstants, readFileSync } from "node:fs";
|
|
4
|
+
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
4
6
|
import type {
|
|
5
7
|
WorkerAdapter,
|
|
6
8
|
WorkerCapabilities,
|
|
@@ -35,6 +37,12 @@ export interface ProcessWorkerAdapterOptions {
|
|
|
35
37
|
cgroupParentPath?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
interface ProcessGroupIdentity {
|
|
41
|
+
pid: number;
|
|
42
|
+
pgid: number;
|
|
43
|
+
startTime: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
interface ProcessRecord {
|
|
39
47
|
child: ChildProcess;
|
|
40
48
|
handle: WorkerHandle;
|
|
@@ -60,6 +68,8 @@ interface ProcessRecord {
|
|
|
60
68
|
cleanupError?: Error;
|
|
61
69
|
cgroupPath?: string;
|
|
62
70
|
cgroupError?: Error;
|
|
71
|
+
processGroupIdentity?: ProcessGroupIdentity;
|
|
72
|
+
processGroupIdentityError?: Error;
|
|
63
73
|
runtimeError?: Error;
|
|
64
74
|
spawned: Promise<void>;
|
|
65
75
|
spawnedSuccessfully: boolean;
|
|
@@ -85,6 +95,7 @@ interface ProcessRecord {
|
|
|
85
95
|
*/
|
|
86
96
|
export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
87
97
|
readonly #records = new Map<string, ProcessRecord>();
|
|
98
|
+
readonly #pendingStartupAborts = new Map<string, string>();
|
|
88
99
|
readonly #mode: "process-pipe" | "claude-jsonl";
|
|
89
100
|
readonly #terminationGraceMs: number;
|
|
90
101
|
readonly #killGraceMs: number;
|
|
@@ -114,24 +125,65 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
114
125
|
pause: true,
|
|
115
126
|
resumeSession: false,
|
|
116
127
|
processGroupControl: true,
|
|
128
|
+
repairableSession: this.#mode === "claude-jsonl",
|
|
117
129
|
};
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void> {
|
|
133
|
+
const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
|
|
134
|
+
assertSafeWorkerCommand(input.command, args, input.approval);
|
|
135
|
+
await assertWorkerCwd(input.cwd);
|
|
136
|
+
await assertExecutable(input.command, input.env?.PATH ?? process.env.PATH);
|
|
137
|
+
if (this.#cgroupMode === "required") await this.#preflightRequiredCgroup();
|
|
138
|
+
}
|
|
139
|
+
|
|
120
140
|
async start(input: WorkerStartInput): Promise<WorkerHandle> {
|
|
121
141
|
if (input.abortSignal?.aborted) throw new Error("worker startup aborted before spawn");
|
|
122
142
|
const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
|
|
123
143
|
assertSafeWorkerCommand(input.command, args, input.approval);
|
|
144
|
+
if (this.#cgroupMode === "required") await this.preflight(input);
|
|
124
145
|
const handle: WorkerHandle = {
|
|
125
146
|
id: randomUUID(),
|
|
126
147
|
startedAt: new Date().toISOString(),
|
|
127
148
|
cwd: input.cwd,
|
|
128
149
|
};
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
const workerEnv = workerEnvironment(process.env, input.env);
|
|
151
|
+
await assertExecutable(input.command, workerEnv.PATH);
|
|
152
|
+
const pendingAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
|
|
153
|
+
if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
|
|
154
|
+
if (pendingAbortReason !== undefined) throw new Error(`worker startup aborted: ${pendingAbortReason}`);
|
|
155
|
+
let cgroupPath: string | undefined;
|
|
156
|
+
let cgroupError: Error | undefined;
|
|
157
|
+
if (this.#cgroupMode !== "off" && process.platform === "linux") {
|
|
158
|
+
try {
|
|
159
|
+
cgroupPath = await this.#createCgroup(handle.id);
|
|
160
|
+
handle.cgroupPath = cgroupPath;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
cgroupError = error instanceof Error ? error : new Error(String(error));
|
|
163
|
+
if (this.#cgroupMode === "required") throw new Error(`unable to create a worker cgroup: ${cgroupError.message}`, { cause: cgroupError });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const delayedAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
|
|
167
|
+
if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
|
|
168
|
+
if (input.abortSignal?.aborted || delayedAbortReason !== undefined) {
|
|
169
|
+
if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
|
|
170
|
+
throw new Error(`worker startup aborted${delayedAbortReason ? `: ${delayedAbortReason}` : " before spawn"}`);
|
|
171
|
+
}
|
|
172
|
+
const launch = cgroupPath
|
|
173
|
+
? cgroupBootstrapLaunch(input.command, args, input.cwd, workerEnv, cgroupPath)
|
|
174
|
+
: { command: input.command, args, env: workerEnv };
|
|
175
|
+
let child: ChildProcess;
|
|
176
|
+
try {
|
|
177
|
+
child = spawn(launch.command, launch.args, {
|
|
178
|
+
cwd: input.cwd,
|
|
179
|
+
env: launch.env,
|
|
180
|
+
detached: true,
|
|
181
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
135
187
|
handle.pid = child.pid;
|
|
136
188
|
let resolveSpawn!: () => void;
|
|
137
189
|
let rejectSpawn!: (error: Error) => void;
|
|
@@ -159,6 +211,8 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
159
211
|
resolveExit,
|
|
160
212
|
sentKeys: new Set(),
|
|
161
213
|
groupCleanupComplete: child.pid === undefined,
|
|
214
|
+
cgroupPath,
|
|
215
|
+
cgroupError,
|
|
162
216
|
spawned,
|
|
163
217
|
spawnedSuccessfully: false,
|
|
164
218
|
inputTail: Promise.resolve(),
|
|
@@ -195,6 +249,13 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
195
249
|
});
|
|
196
250
|
child.once("spawn", () => {
|
|
197
251
|
record.spawnedSuccessfully = true;
|
|
252
|
+
if (child.pid !== undefined && process.platform === "linux") {
|
|
253
|
+
try {
|
|
254
|
+
record.processGroupIdentity = readProcessGroupIdentitySync(child.pid);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
record.processGroupIdentityError = error instanceof Error ? error : new Error(String(error));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
198
259
|
resolveSpawn();
|
|
199
260
|
});
|
|
200
261
|
child.once("error", (error) => {
|
|
@@ -226,8 +287,6 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
226
287
|
try {
|
|
227
288
|
await record.spawned;
|
|
228
289
|
this.#assertNotAborted(record);
|
|
229
|
-
await this.#attachCgroup(record);
|
|
230
|
-
this.#assertNotAborted(record);
|
|
231
290
|
} catch (error) {
|
|
232
291
|
try {
|
|
233
292
|
await this.#ensureGroupCleanup(record);
|
|
@@ -271,6 +330,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
271
330
|
|
|
272
331
|
async abortStart(reason = "startup aborted", startupToken?: string): Promise<void> {
|
|
273
332
|
const records = [...this.#records.values()].filter((record) => record.starting && (startupToken === undefined || record.startupToken === startupToken));
|
|
333
|
+
if (startupToken !== undefined && records.length === 0) this.#pendingStartupAborts.set(startupToken, reason);
|
|
274
334
|
for (const record of records) {
|
|
275
335
|
record.abortRequested = true;
|
|
276
336
|
record.stopping = true;
|
|
@@ -306,7 +366,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
306
366
|
lastOutputAt: record.lastOutputAt,
|
|
307
367
|
lastInputAt: record.lastInputAt,
|
|
308
368
|
activeRequests: this.#mode === "claude-jsonl" ? record.activeRequests : undefined,
|
|
309
|
-
exitReason: running ? undefined : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
|
|
369
|
+
exitReason: running ? undefined : record.stopping ? "stopped" : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
|
|
310
370
|
processGroupCleaned: record.groupCleanupComplete,
|
|
311
371
|
cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
|
|
312
372
|
cgroupRequired: this.#cgroupMode === "required",
|
|
@@ -574,37 +634,33 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
574
634
|
}
|
|
575
635
|
}
|
|
576
636
|
|
|
577
|
-
async #
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
const error = new Error("worker pid was unavailable for cgroup attachment");
|
|
581
|
-
record.cgroupError = error;
|
|
582
|
-
if (this.#cgroupMode === "required") throw new Error(`unable to attach worker to a cgroup: ${error.message}`, { cause: error });
|
|
583
|
-
throw error;
|
|
584
|
-
}
|
|
585
|
-
if (process.platform !== "linux") {
|
|
586
|
-
const error = new Error("cgroups are unavailable on this platform");
|
|
587
|
-
record.cgroupError = error;
|
|
588
|
-
if (this.#cgroupMode === "required") throw new Error(`unable to attach worker to a cgroup: ${error.message}`, { cause: error });
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
let path: string | undefined;
|
|
637
|
+
async #preflightRequiredCgroup(): Promise<void> {
|
|
638
|
+
if (process.platform !== "linux") throw new Error("required cgroup cleanup is unavailable on this platform");
|
|
639
|
+
let probePath: string | undefined;
|
|
592
640
|
try {
|
|
593
641
|
const parent = this.#cgroupParentPath ?? await currentCgroupPath();
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
await
|
|
597
|
-
|
|
598
|
-
|
|
642
|
+
await access(parent, fsConstants.W_OK);
|
|
643
|
+
probePath = `${parent}/pi-claude-supervisor-preflight-${process.pid}-${randomUUID()}`;
|
|
644
|
+
await mkdir(probePath);
|
|
645
|
+
await stat(`${probePath}/cgroup.kill`);
|
|
646
|
+
await stat(`${probePath}/cgroup.events`);
|
|
647
|
+
await writeFile(`${probePath}/cgroup.kill`, "1\n");
|
|
648
|
+
const events = await readFile(`${probePath}/cgroup.events`, "utf8");
|
|
649
|
+
if (!/^populated 0$/mu.test(events)) throw new Error("preflight cgroup did not report populated 0");
|
|
599
650
|
} catch (error) {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
if (
|
|
603
|
-
throw new Error(`unable to attach worker to a cgroup: ${record.cgroupError.message}`, { cause: record.cgroupError });
|
|
604
|
-
}
|
|
651
|
+
throw new Error(`required cgroup preflight failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
652
|
+
} finally {
|
|
653
|
+
if (probePath) await rm(probePath, { recursive: true, force: true }).catch(() => {});
|
|
605
654
|
}
|
|
606
655
|
}
|
|
607
656
|
|
|
657
|
+
async #createCgroup(id: string): Promise<string> {
|
|
658
|
+
const parent = this.#cgroupParentPath ?? await currentCgroupPath();
|
|
659
|
+
const path = `${parent}/pi-claude-supervisor-${id}`;
|
|
660
|
+
await mkdir(path);
|
|
661
|
+
return path;
|
|
662
|
+
}
|
|
663
|
+
|
|
608
664
|
#record(handle: WorkerHandle): ProcessRecord {
|
|
609
665
|
const record = this.#records.get(handle.id);
|
|
610
666
|
if (!record) throw new Error(`unknown worker handle: ${handle.id}`);
|
|
@@ -625,21 +681,30 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
625
681
|
}
|
|
626
682
|
|
|
627
683
|
async #cleanupProcessGroup(record: ProcessRecord): Promise<void> {
|
|
684
|
+
const errors: Error[] = [];
|
|
628
685
|
if (record.cgroupPath) {
|
|
629
|
-
await cleanupCgroup(record.cgroupPath, this.#killGraceMs);
|
|
630
|
-
|
|
631
|
-
return;
|
|
686
|
+
try { await cleanupCgroup(record.cgroupPath, this.#killGraceMs); }
|
|
687
|
+
catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); }
|
|
632
688
|
}
|
|
689
|
+
try { await this.#cleanupProcessGroupOnly(record); }
|
|
690
|
+
catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); }
|
|
691
|
+
if (errors.length === 1) throw errors[0];
|
|
692
|
+
if (errors.length > 1) throw new AggregateError(errors, "worker descendant cleanup failed");
|
|
693
|
+
record.groupCleanupComplete = true;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async #cleanupProcessGroupOnly(record: ProcessRecord): Promise<void> {
|
|
633
697
|
const pid = record.handle.pid;
|
|
634
|
-
if (!pid)
|
|
635
|
-
|
|
636
|
-
|
|
698
|
+
if (!pid) return;
|
|
699
|
+
if (process.platform === "linux") {
|
|
700
|
+
await assertProcessGroupIdentity(record, pid);
|
|
637
701
|
}
|
|
638
702
|
try {
|
|
703
|
+
// detached:true binds this worker's process group to its handle PID;
|
|
704
|
+
// this second boundary catches descendants spawned before cgroup attach.
|
|
639
705
|
process.kill(-pid, "SIGKILL");
|
|
640
706
|
} catch (error) {
|
|
641
707
|
if (!(error instanceof Error) || !/ESRCH/u.test(error.message)) throw error;
|
|
642
|
-
record.groupCleanupComplete = true;
|
|
643
708
|
return;
|
|
644
709
|
}
|
|
645
710
|
const deadline = Date.now() + this.#killGraceMs;
|
|
@@ -647,10 +712,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
647
712
|
try {
|
|
648
713
|
process.kill(-pid, 0);
|
|
649
714
|
} catch (error) {
|
|
650
|
-
if (error instanceof Error && /ESRCH/u.test(error.message))
|
|
651
|
-
record.groupCleanupComplete = true;
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
715
|
+
if (error instanceof Error && /ESRCH/u.test(error.message)) return;
|
|
654
716
|
throw error;
|
|
655
717
|
}
|
|
656
718
|
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
|
|
@@ -665,6 +727,74 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
665
727
|
}
|
|
666
728
|
}
|
|
667
729
|
|
|
730
|
+
const CGROUP_BOOTSTRAP_KEYS = {
|
|
731
|
+
command: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_COMMAND",
|
|
732
|
+
args: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_ARGS",
|
|
733
|
+
cwd: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CWD",
|
|
734
|
+
cgroup: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CGROUP",
|
|
735
|
+
} as const;
|
|
736
|
+
|
|
737
|
+
const CGROUP_BOOTSTRAP_SCRIPT = `
|
|
738
|
+
const { spawn } = require("node:child_process");
|
|
739
|
+
const { writeFileSync } = require("node:fs");
|
|
740
|
+
const keys = ${JSON.stringify(Object.values(CGROUP_BOOTSTRAP_KEYS))};
|
|
741
|
+
const decode = (key) => Buffer.from(process.env[key] || "", "base64").toString("utf8");
|
|
742
|
+
let child;
|
|
743
|
+
try {
|
|
744
|
+
const cgroup = decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.cgroup)});
|
|
745
|
+
writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
|
|
746
|
+
const env = { ...process.env };
|
|
747
|
+
for (const key of keys) delete env[key];
|
|
748
|
+
child = spawn(
|
|
749
|
+
decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.command)}),
|
|
750
|
+
JSON.parse(decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.args)})),
|
|
751
|
+
{ cwd: decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.cwd)}), env, stdio: "inherit" },
|
|
752
|
+
);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
console.error("worker cgroup bootstrap failed:", error instanceof Error ? error.message : String(error));
|
|
755
|
+
process.exit(125);
|
|
756
|
+
}
|
|
757
|
+
const forwardedSignals = ["SIGTERM", "SIGINT", "SIGQUIT"];
|
|
758
|
+
for (const signal of forwardedSignals) {
|
|
759
|
+
process.on(signal, () => {
|
|
760
|
+
try { child.kill(signal); } catch {}
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
let finished = false;
|
|
764
|
+
child.once("error", (error) => {
|
|
765
|
+
if (finished) return;
|
|
766
|
+
finished = true;
|
|
767
|
+
console.error("worker bootstrap child failed:", error.message);
|
|
768
|
+
process.exit(127);
|
|
769
|
+
});
|
|
770
|
+
child.once("exit", (code, signal) => {
|
|
771
|
+
if (finished) return;
|
|
772
|
+
finished = true;
|
|
773
|
+
if (signal) {
|
|
774
|
+
for (const forwarded of forwardedSignals) process.removeAllListeners(forwarded);
|
|
775
|
+
try { process.kill(process.pid, signal); } catch {}
|
|
776
|
+
setTimeout(() => process.exit(128), 50).unref();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
process.exit(code ?? 1);
|
|
780
|
+
});
|
|
781
|
+
`;
|
|
782
|
+
|
|
783
|
+
function cgroupBootstrapLaunch(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, cgroupPath: string): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
|
|
784
|
+
const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
|
|
785
|
+
return {
|
|
786
|
+
command: process.execPath,
|
|
787
|
+
args: ["-e", CGROUP_BOOTSTRAP_SCRIPT],
|
|
788
|
+
env: {
|
|
789
|
+
...env,
|
|
790
|
+
[CGROUP_BOOTSTRAP_KEYS.command]: encode(command),
|
|
791
|
+
[CGROUP_BOOTSTRAP_KEYS.args]: encode(JSON.stringify(args)),
|
|
792
|
+
[CGROUP_BOOTSTRAP_KEYS.cwd]: encode(cwd),
|
|
793
|
+
[CGROUP_BOOTSTRAP_KEYS.cgroup]: encode(cgroupPath),
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
668
798
|
function claudeJsonlArgs(args: readonly string[] = []): string[] {
|
|
669
799
|
const result = [...args];
|
|
670
800
|
if (!result.includes("-p") && !result.includes("--print")) result.push("-p");
|
|
@@ -684,6 +814,27 @@ function ensureOption(args: string[], option: string, expected: string): void {
|
|
|
684
814
|
if (args[index + 1] !== expected) throw new Error(`${option} must be ${expected} in claude-jsonl mode`);
|
|
685
815
|
}
|
|
686
816
|
|
|
817
|
+
async function assertWorkerCwd(cwd: string): Promise<void> {
|
|
818
|
+
const info = await stat(cwd);
|
|
819
|
+
if (!info.isDirectory()) throw new Error(`Worker cwd is not a directory: ${cwd}`);
|
|
820
|
+
await access(cwd, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function assertExecutable(command: string, pathValue: string | undefined): Promise<void> {
|
|
824
|
+
const candidates = isAbsolute(command) || command.includes("/") || command.includes("\\")
|
|
825
|
+
? [command]
|
|
826
|
+
: (pathValue ?? "").split(delimiter).filter(Boolean).map((directory) => join(directory, command));
|
|
827
|
+
for (const candidate of candidates) {
|
|
828
|
+
try {
|
|
829
|
+
await access(candidate, fsConstants.X_OK);
|
|
830
|
+
return;
|
|
831
|
+
} catch {
|
|
832
|
+
// Try the next PATH entry.
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
throw new Error(`worker executable preflight failed (ENOENT): ${command}`);
|
|
836
|
+
}
|
|
837
|
+
|
|
687
838
|
async function currentCgroupPath(): Promise<string> {
|
|
688
839
|
const contents = await readFile("/proc/self/cgroup", "utf8");
|
|
689
840
|
const match = contents.match(/^0::([^\n]*)$/mu);
|
|
@@ -693,13 +844,60 @@ async function currentCgroupPath(): Promise<string> {
|
|
|
693
844
|
return `/sys/fs/cgroup${match[1]}`;
|
|
694
845
|
}
|
|
695
846
|
|
|
847
|
+
function parseProcessGroupIdentity(contents: string, pid: number): ProcessGroupIdentity {
|
|
848
|
+
const closeParen = contents.lastIndexOf(")");
|
|
849
|
+
if (closeParen < 0) throw new Error(`unable to read process identity for ${pid}`);
|
|
850
|
+
const fields = contents.slice(closeParen + 2).trim().split(/\s+/u);
|
|
851
|
+
const pgid = Number(fields[2]);
|
|
852
|
+
const startTime = fields[19];
|
|
853
|
+
if (!Number.isSafeInteger(pgid) || !startTime) throw new Error(`invalid process identity for ${pid}`);
|
|
854
|
+
return { pid, pgid, startTime };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function readProcessGroupIdentitySync(pid: number): ProcessGroupIdentity {
|
|
858
|
+
return parseProcessGroupIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function readProcessGroupIdentity(pid: number): Promise<ProcessGroupIdentity> {
|
|
862
|
+
return parseProcessGroupIdentity(await readFile(`/proc/${pid}/stat`, "utf8"), pid);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function assertProcessGroupIdentity(record: ProcessRecord, pid: number): Promise<void> {
|
|
866
|
+
const identity = record.processGroupIdentity;
|
|
867
|
+
if (record.processGroupIdentityError) throw new Error(`worker process-group identity unavailable: ${record.processGroupIdentityError.message}`);
|
|
868
|
+
if (!identity || identity.pid !== pid || identity.pgid !== pid) throw new Error(`worker process-group identity was not established for ${pid}`);
|
|
869
|
+
try {
|
|
870
|
+
const current = await readProcessGroupIdentity(pid);
|
|
871
|
+
if (current.startTime !== identity.startTime || current.pgid !== identity.pgid) {
|
|
872
|
+
throw new Error(`worker process-group identity changed for ${pid}`);
|
|
873
|
+
}
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return;
|
|
876
|
+
throw error;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
696
880
|
async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
|
|
697
881
|
try {
|
|
698
|
-
await
|
|
882
|
+
await stat(path);
|
|
699
883
|
} catch (error) {
|
|
700
884
|
if (error instanceof Error && /ENOENT/u.test(error.message)) return;
|
|
701
885
|
throw error;
|
|
702
886
|
}
|
|
887
|
+
try {
|
|
888
|
+
await writeFile(`${path}/cgroup.kill`, "1\n");
|
|
889
|
+
} catch (error) {
|
|
890
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) {
|
|
891
|
+
// ENOENT is success only when the cgroup directory itself disappeared.
|
|
892
|
+
try { await stat(path); }
|
|
893
|
+
catch (directoryError) {
|
|
894
|
+
if (directoryError instanceof Error && /ENOENT/u.test(directoryError.message)) return;
|
|
895
|
+
throw directoryError;
|
|
896
|
+
}
|
|
897
|
+
throw new Error(`cgroup.kill is missing from live cgroup ${path}`);
|
|
898
|
+
}
|
|
899
|
+
throw error;
|
|
900
|
+
}
|
|
703
901
|
const deadline = Date.now() + graceMs;
|
|
704
902
|
while (Date.now() <= deadline) {
|
|
705
903
|
try {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { access, chmod, lstat, mkdir, open, readFile, rm, stat, truncate, writeFile } from "node:fs/promises";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
6
7
|
import type {
|
|
7
8
|
WorkerAdapter,
|
|
8
9
|
WorkerCapabilities,
|
|
@@ -108,6 +109,17 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
108
109
|
this.#terminationGraceMs = boundedDelay(options.terminationGraceMs ?? 2_000);
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void> {
|
|
113
|
+
assertSafeWorkerCommand(input.command, input.args ?? [], input.approval);
|
|
114
|
+
await assertDirectory(input.cwd);
|
|
115
|
+
await assertExecutableAvailable(input.command, input.env?.PATH ?? process.env.PATH);
|
|
116
|
+
await assertExecutableAvailable(this.#tmuxBinary, process.env.PATH);
|
|
117
|
+
await mkdir(this.#stateDir, { recursive: true, mode: 0o700 });
|
|
118
|
+
const stateInfo = await lstat(this.#stateDir);
|
|
119
|
+
if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink()) throw new Error(`tmux state directory is not a real directory: ${this.#stateDir}`);
|
|
120
|
+
await chmod(this.#stateDir, 0o700);
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
capabilities(): WorkerCapabilities {
|
|
112
124
|
return {
|
|
113
125
|
transport: "tmux",
|
|
@@ -116,6 +128,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
116
128
|
resumeSession: false,
|
|
117
129
|
processGroupControl: false,
|
|
118
130
|
persistentSession: true,
|
|
131
|
+
repairableSession: true,
|
|
119
132
|
};
|
|
120
133
|
}
|
|
121
134
|
|
|
@@ -272,8 +285,12 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
272
285
|
record.cleanupError = undefined;
|
|
273
286
|
} else if (isPaneIdentityError(error)) {
|
|
274
287
|
record.paneDead = false;
|
|
288
|
+
record.cleanupComplete = false;
|
|
289
|
+
record.cleanupError = asError(error);
|
|
290
|
+
} else {
|
|
291
|
+
record.cleanupComplete = false;
|
|
275
292
|
record.cleanupError = asError(error);
|
|
276
|
-
}
|
|
293
|
+
}
|
|
277
294
|
}
|
|
278
295
|
return this.#status(record, !record.paneDead);
|
|
279
296
|
}
|
|
@@ -409,6 +426,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
409
426
|
try { await rm(record.runtimeDir, { recursive: true, force: true }); }
|
|
410
427
|
catch (error) { record.cleanupError ??= asError(error); }
|
|
411
428
|
}
|
|
429
|
+
record.cleanupComplete = !record.cleanupError;
|
|
412
430
|
if (record.cleanupError) throw new Error(`tmux supervision release failed: ${record.cleanupError.message}`);
|
|
413
431
|
// A released tmux worker is intentionally left running. It can be adopted
|
|
414
432
|
// again explicitly after Pi restarts, and the user's attached window stays open.
|
|
@@ -1034,6 +1052,27 @@ function isPidAlive(pid: number): boolean {
|
|
|
1034
1052
|
}
|
|
1035
1053
|
}
|
|
1036
1054
|
|
|
1055
|
+
async function assertDirectory(path: string): Promise<void> {
|
|
1056
|
+
const info = await stat(path);
|
|
1057
|
+
if (!info.isDirectory()) throw new Error(`Worker cwd is not a directory: ${path}`);
|
|
1058
|
+
await access(path, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function assertExecutableAvailable(command: string, pathValue: string | undefined): Promise<void> {
|
|
1062
|
+
const candidates = isAbsolute(command) || command.includes("/") || command.includes("\\")
|
|
1063
|
+
? [command]
|
|
1064
|
+
: (pathValue ?? "").split(delimiter).filter(Boolean).map((directory) => join(directory, command));
|
|
1065
|
+
for (const candidate of candidates) {
|
|
1066
|
+
try {
|
|
1067
|
+
await access(candidate, fsConstants.X_OK);
|
|
1068
|
+
return;
|
|
1069
|
+
} catch {
|
|
1070
|
+
// Try the next PATH entry.
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
throw new Error(`executable preflight failed (ENOENT): ${command}`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1037
1076
|
function boundedDelay(value: number): number {
|
|
1038
1077
|
if (!Number.isFinite(value) || value < 1) throw new Error("tmux adapter delays must be positive finite numbers");
|
|
1039
1078
|
return value;
|