@ferris1225/pi-subagents 4.3.4 → 4.3.6
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 +36 -0
- package/README.md +103 -83
- package/agents/artisan.md +0 -1
- package/agents/steward.md +1 -2
- package/{src/index.ts → index.ts} +19 -19
- package/package.json +4 -3
- package/src/{config.ts → configuration/config.ts} +19 -24
- package/src/configuration/setup.ts +375 -0
- package/src/configuration/ui.ts +245 -0
- package/src/{agents.ts → delegation/agents.ts} +3 -3
- package/src/{dispatch.ts → delegation/dispatch.ts} +12 -18
- package/src/{prompt.ts → delegation/prompt.ts} +5 -9
- package/src/{background.ts → execution/background.ts} +3 -6
- package/src/execution/rpc-control.ts +235 -0
- package/src/{rpc-run.ts → execution/rpc-run.ts} +35 -225
- package/src/{session-fork.ts → execution/session-fork.ts} +1 -1
- package/src/{spawn.ts → execution/spawn.ts} +10 -8
- package/src/isolation/git-command.ts +147 -0
- package/src/isolation/managed-paths.ts +145 -0
- package/src/{recovery.ts → isolation/recovery.ts} +42 -13
- package/src/{temp-hygiene.ts → isolation/temp-hygiene.ts} +7 -7
- package/src/{worktree.ts → isolation/worktree.ts} +11 -158
- package/src/{completion.ts → lifecycle/completion.ts} +2 -2
- package/src/{durable.ts → lifecycle/durable.ts} +101 -27
- package/src/{runtime.ts → lifecycle/runtime.ts} +12 -12
- package/src/{thread-lifecycle.ts → lifecycle/thread-lifecycle.ts} +25 -519
- package/src/lifecycle/thread-restore.ts +253 -0
- package/src/lifecycle/thread-shared.ts +269 -0
- package/src/{tools.ts → lifecycle/tools.ts} +51 -13
- package/src/{announcements.ts → presentation/announcements.ts} +4 -4
- package/src/{format.ts → presentation/format.ts} +3 -3
- package/src/{monitor.ts → presentation/monitor.ts} +2 -2
- package/src/{widget.ts → presentation/widget.ts} +1 -1
- package/agents/sentinel.md +0 -16
- package/src/setup.ts +0 -344
- package/src/ui.ts +0 -160
- /package/src/{models.ts → configuration/models.ts} +0 -0
- /package/src/{status.ts → presentation/status.ts} +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Bounded, abortable process runner used by Git isolation operations. */
|
|
2
|
+
|
|
3
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export interface CommandRunOptions {
|
|
6
|
+
cwd: string;
|
|
7
|
+
input?: Buffer;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
maxOutputBytes?: number;
|
|
11
|
+
/** Extra environment entries merged over the inherited environment. */
|
|
12
|
+
env?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CommandResult {
|
|
16
|
+
code: number;
|
|
17
|
+
stdout: Buffer;
|
|
18
|
+
stderr: Buffer;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Injectable, shell-free command runner used by every Git operation. */
|
|
22
|
+
export type CommandRunner = (
|
|
23
|
+
command: string,
|
|
24
|
+
args: readonly string[],
|
|
25
|
+
options: CommandRunOptions,
|
|
26
|
+
) => Promise<CommandResult>;
|
|
27
|
+
|
|
28
|
+
export const GIT_COMMAND_TIMEOUT_MS = 120_000;
|
|
29
|
+
export const GIT_COMMAND_KILL_GRACE_MS = 2_000;
|
|
30
|
+
export const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
|
|
31
|
+
export const WORKTREE_PATCH_MAX_BYTES = GIT_OUTPUT_MAX_BYTES;
|
|
32
|
+
|
|
33
|
+
/** Terminate the complete spawned process tree so checkout filters cannot
|
|
34
|
+
* survive an abort or timeout. */
|
|
35
|
+
function terminateCommandTree(child: ChildProcess, force: boolean, processGroup: boolean): void {
|
|
36
|
+
if (process.platform === "win32" && child.pid !== undefined) {
|
|
37
|
+
const fallback = (): void => {
|
|
38
|
+
try {
|
|
39
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
40
|
+
} catch {
|
|
41
|
+
/* process may already be gone */
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
45
|
+
stdio: "ignore",
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
});
|
|
48
|
+
killer.once("error", fallback);
|
|
49
|
+
killer.once("close", (code) => {
|
|
50
|
+
if (code !== 0) fallback();
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if (processGroup && child.pid !== undefined) {
|
|
56
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
57
|
+
} else {
|
|
58
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
/* process may already be gone */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Default argument-safe runner. Output is bounded before binary patches enter
|
|
66
|
+
* memory, and timeout/abort terminates the complete checkout-filter process tree. */
|
|
67
|
+
export const runCommand: CommandRunner = (command, args, options) =>
|
|
68
|
+
new Promise<CommandResult>((resolveResult, reject) => {
|
|
69
|
+
if (options.signal?.aborted) {
|
|
70
|
+
reject(new Error(`Command aborted before start: ${command}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const usePosixProcessGroup = process.platform !== "win32";
|
|
74
|
+
const child = spawn(command, [...args], {
|
|
75
|
+
cwd: options.cwd,
|
|
76
|
+
shell: false,
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
79
|
+
detached: usePosixProcessGroup,
|
|
80
|
+
...(options.env ? { env: { ...process.env, ...options.env } } : {}),
|
|
81
|
+
});
|
|
82
|
+
const stdout: Buffer[] = [];
|
|
83
|
+
const stderr: Buffer[] = [];
|
|
84
|
+
const maxOutputBytes = options.maxOutputBytes ?? GIT_OUTPUT_MAX_BYTES;
|
|
85
|
+
let outputBytes = 0;
|
|
86
|
+
let finished = false;
|
|
87
|
+
let failure: Error | undefined;
|
|
88
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
89
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
|
|
91
|
+
const terminate = (): void => {
|
|
92
|
+
terminateCommandTree(child, false, usePosixProcessGroup);
|
|
93
|
+
if (!forceKillTimer) {
|
|
94
|
+
forceKillTimer = setTimeout(
|
|
95
|
+
() => terminateCommandTree(child, true, usePosixProcessGroup),
|
|
96
|
+
GIT_COMMAND_KILL_GRACE_MS,
|
|
97
|
+
);
|
|
98
|
+
if (typeof forceKillTimer.unref === "function") forceKillTimer.unref();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const fail = (error: Error): void => {
|
|
102
|
+
if (failure || finished) return;
|
|
103
|
+
failure = error;
|
|
104
|
+
terminate();
|
|
105
|
+
};
|
|
106
|
+
const append = (target: Buffer[], chunk: Buffer | string): void => {
|
|
107
|
+
if (failure || finished) return;
|
|
108
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
109
|
+
outputBytes += value.length;
|
|
110
|
+
if (outputBytes > maxOutputBytes) {
|
|
111
|
+
fail(new Error(`Command output exceeded ${maxOutputBytes} bytes: ${command}`));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
target.push(value);
|
|
115
|
+
};
|
|
116
|
+
const onAbort = (): void => fail(new Error(`Command aborted: ${command}`));
|
|
117
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
118
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
119
|
+
timeout = setTimeout(
|
|
120
|
+
() => fail(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`)),
|
|
121
|
+
options.timeoutMs,
|
|
122
|
+
);
|
|
123
|
+
if (typeof timeout.unref === "function") timeout.unref();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
child.stdout?.on("data", (chunk: Buffer | string) => append(stdout, chunk));
|
|
127
|
+
child.stderr?.on("data", (chunk: Buffer | string) => append(stderr, chunk));
|
|
128
|
+
child.once("error", (error) => fail(error));
|
|
129
|
+
child.once("close", (code) => {
|
|
130
|
+
if (finished) return;
|
|
131
|
+
finished = true;
|
|
132
|
+
if (timeout) clearTimeout(timeout);
|
|
133
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
134
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
135
|
+
if (failure) {
|
|
136
|
+
reject(failure);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
resolveResult({
|
|
140
|
+
code: code ?? 1,
|
|
141
|
+
stdout: Buffer.concat(stdout),
|
|
142
|
+
stderr: Buffer.concat(stderr),
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
child.stdin?.once("error", () => undefined);
|
|
146
|
+
child.stdin?.end(options.input);
|
|
147
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Canonical filesystem guards for paths recovered from durable manifests. */
|
|
2
|
+
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { getProjectRoot, getSubagentsRoot } from "../execution/spawn.ts";
|
|
7
|
+
|
|
8
|
+
const SESSION_DIR_NAME = /^pi-subagent-session-(?:fork-)?.+$/i;
|
|
9
|
+
const WORKTREE_DIR_NAME = /^pi-subagent-worktree-.+$/i;
|
|
10
|
+
const PROJECT_DIR_NAME = /^.+-[0-9a-f]{12}$/i;
|
|
11
|
+
|
|
12
|
+
function comparable(path: string): string {
|
|
13
|
+
const normalized = resolve(path);
|
|
14
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isCanonicalAbsolute(path: string): boolean {
|
|
18
|
+
return isAbsolute(path) && path === resolve(path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function samePath(left: string, right: string): boolean {
|
|
22
|
+
return comparable(left) === comparable(right);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function isPlainPath(path: string, kind: "directory" | "file"): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
const entry = await lstat(path);
|
|
28
|
+
return !entry.isSymbolicLink() && (kind === "directory" ? entry.isDirectory() : entry.isFile());
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function isDirectRealChild(root: string, candidate: string): Promise<boolean> {
|
|
35
|
+
try {
|
|
36
|
+
const [realRoot, realCandidate] = await Promise.all([realpath(root), realpath(candidate)]);
|
|
37
|
+
return samePath(dirname(realCandidate), realRoot);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function isManagedDirectory(
|
|
44
|
+
root: string,
|
|
45
|
+
candidate: string,
|
|
46
|
+
namePattern: RegExp,
|
|
47
|
+
): Promise<boolean> {
|
|
48
|
+
if (!isCanonicalAbsolute(candidate) || !samePath(dirname(candidate), root) || !namePattern.test(basename(candidate))) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (!existsSync(candidate)) return true;
|
|
52
|
+
return await isPlainPath(candidate, "directory") && await isDirectRealChild(root, candidate);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function isManagedContainer(root: string, candidate: string, name: string): Promise<boolean> {
|
|
56
|
+
if (!samePath(candidate, join(root, name))) return false;
|
|
57
|
+
if (!existsSync(candidate)) return true;
|
|
58
|
+
return await isPlainPath(candidate, "directory") && await isDirectRealChild(root, candidate);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function hasCanonicalProjectRoot(configPath: string, cwd: string, projectRoot: string): Promise<boolean> {
|
|
62
|
+
const subagentsRoot = getSubagentsRoot(configPath);
|
|
63
|
+
if (!samePath(projectRoot, getProjectRoot(configPath, cwd))) return false;
|
|
64
|
+
if (!isAbsolute(projectRoot) || !samePath(dirname(projectRoot), subagentsRoot)) return false;
|
|
65
|
+
if (!existsSync(projectRoot)) return true;
|
|
66
|
+
return await isPlainPath(projectRoot, "directory") && await isDirectRealChild(subagentsRoot, projectRoot);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function isManagedSessionDir(
|
|
70
|
+
configPath: string,
|
|
71
|
+
cwd: string,
|
|
72
|
+
sessionDir: string,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
const projectRoot = getProjectRoot(configPath, cwd);
|
|
75
|
+
if (!await hasCanonicalProjectRoot(configPath, cwd, projectRoot)) return false;
|
|
76
|
+
const sessionsRoot = join(projectRoot, "sessions");
|
|
77
|
+
if (!await isManagedContainer(projectRoot, sessionsRoot, "sessions")) return false;
|
|
78
|
+
return isManagedDirectory(sessionsRoot, sessionDir, SESSION_DIR_NAME);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PersistedWorktreePaths {
|
|
82
|
+
cwd: string;
|
|
83
|
+
worktreePath: string;
|
|
84
|
+
tempDir: string;
|
|
85
|
+
patchPath: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function isManagedWorktreeLayout(
|
|
89
|
+
configPath: string,
|
|
90
|
+
cwd: string,
|
|
91
|
+
worktree: PersistedWorktreePaths,
|
|
92
|
+
): Promise<boolean> {
|
|
93
|
+
const projectRoot = getProjectRoot(configPath, cwd);
|
|
94
|
+
if (!await hasCanonicalProjectRoot(configPath, cwd, projectRoot)) return false;
|
|
95
|
+
const worktreesRoot = join(projectRoot, "worktrees");
|
|
96
|
+
if (!await isManagedContainer(projectRoot, worktreesRoot, "worktrees")) return false;
|
|
97
|
+
if (!await isManagedDirectory(worktreesRoot, worktree.tempDir, WORKTREE_DIR_NAME)) return false;
|
|
98
|
+
if (!isCanonicalAbsolute(worktree.worktreePath) || !samePath(worktree.worktreePath, join(worktree.tempDir, "worktree"))) return false;
|
|
99
|
+
if (!isCanonicalAbsolute(worktree.patchPath) || !samePath(worktree.patchPath, join(worktree.tempDir, "changes.patch"))) return false;
|
|
100
|
+
if (!isCanonicalAbsolute(worktree.cwd)) return false;
|
|
101
|
+
if (existsSync(worktree.worktreePath)) {
|
|
102
|
+
if (!await isPlainPath(worktree.worktreePath, "directory")) return false;
|
|
103
|
+
if (!await isDirectRealChild(worktree.tempDir, worktree.worktreePath)) return false;
|
|
104
|
+
}
|
|
105
|
+
if (existsSync(worktree.patchPath)) {
|
|
106
|
+
if (!await isPlainPath(worktree.patchPath, "file")) return false;
|
|
107
|
+
if (!await isDirectRealChild(worktree.tempDir, worktree.patchPath)) return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Return a recovery group only when every persisted artifact has the fixed
|
|
113
|
+
* `<root>/<project>/worktrees/<group>/{worktree,changes.patch}` shape. Existing
|
|
114
|
+
* path components must also stay inside that shape after junction resolution. */
|
|
115
|
+
export async function managedRecoveryGroup(
|
|
116
|
+
configPath: string,
|
|
117
|
+
paths: { worktreePath?: string; patchPath?: string },
|
|
118
|
+
): Promise<string | undefined> {
|
|
119
|
+
if (paths.worktreePath && !isCanonicalAbsolute(paths.worktreePath)) return undefined;
|
|
120
|
+
if (paths.patchPath && !isCanonicalAbsolute(paths.patchPath)) return undefined;
|
|
121
|
+
const fromWorktree = paths.worktreePath ? dirname(paths.worktreePath) : undefined;
|
|
122
|
+
const fromPatch = paths.patchPath ? dirname(paths.patchPath) : undefined;
|
|
123
|
+
const group = fromWorktree ?? fromPatch;
|
|
124
|
+
if (!group || (fromWorktree && fromPatch && !samePath(fromWorktree, fromPatch))) return undefined;
|
|
125
|
+
if (paths.worktreePath && !samePath(paths.worktreePath, join(group, "worktree"))) return undefined;
|
|
126
|
+
if (paths.patchPath && !samePath(paths.patchPath, join(group, "changes.patch"))) return undefined;
|
|
127
|
+
const worktreesRoot = dirname(group);
|
|
128
|
+
const projectRoot = dirname(worktreesRoot);
|
|
129
|
+
const subagentsRoot = getSubagentsRoot(configPath);
|
|
130
|
+
if (!samePath(worktreesRoot, join(projectRoot, "worktrees")) || !samePath(dirname(projectRoot), subagentsRoot)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
if (!await isManagedDirectory(subagentsRoot, projectRoot, PROJECT_DIR_NAME)) return undefined;
|
|
134
|
+
if (!await isManagedContainer(projectRoot, worktreesRoot, "worktrees")) return undefined;
|
|
135
|
+
if (!await isManagedDirectory(worktreesRoot, group, WORKTREE_DIR_NAME)) return undefined;
|
|
136
|
+
if (paths.worktreePath && existsSync(paths.worktreePath)) {
|
|
137
|
+
if (!await isPlainPath(paths.worktreePath, "directory")) return undefined;
|
|
138
|
+
if (!await isDirectRealChild(group, paths.worktreePath)) return undefined;
|
|
139
|
+
}
|
|
140
|
+
if (paths.patchPath && existsSync(paths.patchPath)) {
|
|
141
|
+
if (!await isPlainPath(paths.patchPath, "file")) return undefined;
|
|
142
|
+
if (!await isDirectRealChild(group, paths.patchPath)) return undefined;
|
|
143
|
+
}
|
|
144
|
+
return group;
|
|
145
|
+
}
|
|
@@ -5,8 +5,9 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { stripVTControlCharacters } from "node:util";
|
|
8
|
-
import { getSubagentsRoot } from "
|
|
9
|
-
import {
|
|
8
|
+
import { getSubagentsRoot } from "../execution/spawn.ts";
|
|
9
|
+
import { managedRecoveryGroup } from "./managed-paths.ts";
|
|
10
|
+
import { removeWorktreeGroup, type WorktreeFinalization } from "./worktree.ts";
|
|
10
11
|
|
|
11
12
|
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
12
13
|
const RECOVERY_MANIFEST_VERSION = 1;
|
|
@@ -15,7 +16,7 @@ export interface RecoveryRecord {
|
|
|
15
16
|
runId: number;
|
|
16
17
|
createdAt: number;
|
|
17
18
|
integrated: boolean;
|
|
18
|
-
/**
|
|
19
|
+
/** Legacy diagnostic metadata; cleanup authorization comes only from managed paths. */
|
|
19
20
|
originalRoot?: string;
|
|
20
21
|
worktreePath?: string;
|
|
21
22
|
patchPath?: string;
|
|
@@ -49,38 +50,62 @@ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
|
49
50
|
|
|
50
51
|
interface RecoveryManifestRead {
|
|
51
52
|
valid: boolean;
|
|
53
|
+
sourceCount: number;
|
|
52
54
|
records: RecoveryRecord[];
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
async function readManifest(path: string): Promise<RecoveryManifestRead> {
|
|
56
58
|
try {
|
|
57
59
|
const parsed = JSON.parse(await readFile(path, "utf8")) as { records?: unknown };
|
|
58
|
-
if (!Array.isArray(parsed.records)) return { valid: false, records: [] };
|
|
60
|
+
if (!Array.isArray(parsed.records)) return { valid: false, sourceCount: 0, records: [] };
|
|
59
61
|
return {
|
|
60
62
|
valid: true,
|
|
63
|
+
sourceCount: parsed.records.length,
|
|
61
64
|
records: parsed.records.flatMap((record) => {
|
|
62
65
|
const normalized = normalizeRecord(record);
|
|
63
66
|
return normalized ? [normalized] : [];
|
|
64
67
|
}),
|
|
65
68
|
};
|
|
66
69
|
} catch {
|
|
67
|
-
return { valid: false, records: [] };
|
|
70
|
+
return { valid: false, sourceCount: 0, records: [] };
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
async function validatedRecords(configPath: string, records: readonly RecoveryRecord[]): Promise<RecoveryRecord[]> {
|
|
75
|
+
const groups = await Promise.all(records.map((record) => managedRecoveryGroup(configPath, record)));
|
|
76
|
+
return records.filter((_record, index) => groups[index] !== undefined);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function referencedRecoveryPaths(
|
|
80
|
+
configPath: string,
|
|
81
|
+
records: readonly RecoveryRecord[],
|
|
82
|
+
): Promise<Set<string>> {
|
|
83
|
+
const groups = await Promise.all(records.map((record) => managedRecoveryGroup(configPath, record)));
|
|
84
|
+
return new Set(groups.filter((group): group is string => group !== undefined));
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
72
|
-
|
|
88
|
+
const path = getRecoveryManifestPath(configPath);
|
|
89
|
+
return withFileMutationQueue(path, async () => {
|
|
90
|
+
const manifest = await readManifest(path);
|
|
91
|
+
const records = await validatedRecords(configPath, manifest.records);
|
|
92
|
+
if (!manifest.valid || records.length !== manifest.sourceCount) await writeManifest(path, records);
|
|
93
|
+
return records;
|
|
94
|
+
});
|
|
73
95
|
}
|
|
74
96
|
|
|
75
|
-
/** Move the previous agent-root manifest into the internal-state
|
|
76
|
-
*
|
|
97
|
+
/** Move valid records from the previous agent-root manifest into the internal-state
|
|
98
|
+
* root. Invalid legacy records are removed without touching their referenced paths. */
|
|
77
99
|
export async function relocateRecoveryManifest(configPath: string): Promise<void> {
|
|
78
100
|
const legacyPath = join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
79
101
|
const currentPath = getRecoveryManifestPath(configPath);
|
|
80
102
|
if (legacyPath === currentPath || !existsSync(legacyPath)) return;
|
|
81
103
|
await withFileMutationQueue(legacyPath, async () => {
|
|
82
104
|
const legacy = await readManifest(legacyPath);
|
|
83
|
-
if (!legacy.valid)
|
|
105
|
+
if (!legacy.valid) {
|
|
106
|
+
await rm(legacyPath, { force: true });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
84
109
|
await persistRecoveryRecords(configPath, legacy.records);
|
|
85
110
|
await rm(legacyPath, { force: true });
|
|
86
111
|
});
|
|
@@ -118,8 +143,13 @@ export async function persistRecoveryRecords(
|
|
|
118
143
|
const path = getRecoveryManifestPath(configPath);
|
|
119
144
|
await withFileMutationQueue(path, async () => {
|
|
120
145
|
const merged = new Map<string, RecoveryRecord>();
|
|
121
|
-
|
|
122
|
-
for (const record of
|
|
146
|
+
const existing = await readManifest(path);
|
|
147
|
+
for (const record of await validatedRecords(configPath, existing.records)) {
|
|
148
|
+
merged.set(recoveryKey(record), record);
|
|
149
|
+
}
|
|
150
|
+
for (const record of await validatedRecords(configPath, records)) {
|
|
151
|
+
merged.set(recoveryKey(record), record);
|
|
152
|
+
}
|
|
123
153
|
await writeManifest(path, [...merged.values()]);
|
|
124
154
|
});
|
|
125
155
|
}
|
|
@@ -157,11 +187,10 @@ export async function announceRecoveryRecords(
|
|
|
157
187
|
if (records.length === 0) return;
|
|
158
188
|
for (const record of records) {
|
|
159
189
|
if (!record.integrated || !record.worktreePath) continue;
|
|
160
|
-
const groupDir =
|
|
190
|
+
const groupDir = await managedRecoveryGroup(configPath, record);
|
|
161
191
|
if (!groupDir) continue;
|
|
162
192
|
if (!existsSync(record.worktreePath) && !(record.patchPath ? existsSync(record.patchPath) : false)) continue;
|
|
163
193
|
await removeWorktreeGroup({
|
|
164
|
-
originalRoot: record.originalRoot,
|
|
165
194
|
worktreePath: record.worktreePath,
|
|
166
195
|
tempDir: groupDir,
|
|
167
196
|
});
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* that no manifest record claims — a settled thread still resumable in the
|
|
15
15
|
* session that produced it — belongs to a live owner and survives; the same
|
|
16
16
|
* directory left behind by a crash does not. Callers sweeping durable roots
|
|
17
|
-
* additionally pass the paths their
|
|
18
|
-
* never removed even if its owner is long gone.
|
|
17
|
+
* additionally pass the paths their thread and recovery manifests still reference,
|
|
18
|
+
* so parked and retained work is never removed even if its owner is long gone.
|
|
19
19
|
*
|
|
20
20
|
* A live sibling pi instance never loses its directories: `kill(pid, 0)` only
|
|
21
21
|
* reports "no such process" when the pid genuinely does not exist, so a live
|
|
@@ -200,11 +200,11 @@ export function sweepProjectTempDirs(
|
|
|
200
200
|
* leaves a full worktree checkout and its session behind until the whole project
|
|
201
201
|
* goes idle for days — which never happens in a checkout still being worked in.
|
|
202
202
|
*
|
|
203
|
-
* `keep` must report every path the
|
|
204
|
-
* work outlives its owner by design. Removal is a
|
|
205
|
-
*
|
|
206
|
-
* registration in its origin repository, which
|
|
207
|
-
* gc clear on their own. */
|
|
203
|
+
* `keep` must report every path the thread and recovery manifests still reference;
|
|
204
|
+
* parked and retained recovery work outlives its owner by design. Removal is a
|
|
205
|
+
* plain recursive delete, as in idle-project cleanup. An abandoned worktree may
|
|
206
|
+
* leave a prunable registration in its origin repository, which
|
|
207
|
+
* `git worktree prune` and routine gc clear on their own. */
|
|
208
208
|
export function sweepProjectDurableDirs(
|
|
209
209
|
durableRoot: string,
|
|
210
210
|
options: SweepOptions = {},
|
|
@@ -8,10 +8,17 @@
|
|
|
8
8
|
* Failed integration deliberately retains both the worktree and patch.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { spawn, type ChildProcess } from "node:child_process";
|
|
12
11
|
import { existsSync, symlinkSync } from "node:fs";
|
|
13
12
|
import { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
14
|
-
import {
|
|
13
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
GIT_COMMAND_TIMEOUT_MS,
|
|
16
|
+
GIT_OUTPUT_MAX_BYTES,
|
|
17
|
+
runCommand,
|
|
18
|
+
type CommandResult,
|
|
19
|
+
type CommandRunner,
|
|
20
|
+
WORKTREE_PATCH_MAX_BYTES,
|
|
21
|
+
} from "./git-command.ts";
|
|
15
22
|
import { writeTempOwnerMarker } from "./temp-hygiene.ts";
|
|
16
23
|
|
|
17
24
|
export type IsolationMode = "shared" | "worktree";
|
|
@@ -28,151 +35,6 @@ export function worktreeGroupId(worktree: Pick<WorktreeIsolation, "tempDir">): s
|
|
|
28
35
|
: base;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
export interface CommandRunOptions {
|
|
32
|
-
cwd: string;
|
|
33
|
-
input?: Buffer;
|
|
34
|
-
signal?: AbortSignal;
|
|
35
|
-
timeoutMs?: number;
|
|
36
|
-
maxOutputBytes?: number;
|
|
37
|
-
/** Extra environment entries merged over the inherited environment. */
|
|
38
|
-
env?: Record<string, string>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface CommandResult {
|
|
42
|
-
code: number;
|
|
43
|
-
stdout: Buffer;
|
|
44
|
-
stderr: Buffer;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Injectable, shell-free command runner used by every Git operation. */
|
|
48
|
-
export type CommandRunner = (
|
|
49
|
-
command: string,
|
|
50
|
-
args: readonly string[],
|
|
51
|
-
options: CommandRunOptions,
|
|
52
|
-
) => Promise<CommandResult>;
|
|
53
|
-
|
|
54
|
-
export const GIT_COMMAND_TIMEOUT_MS = 120_000;
|
|
55
|
-
export const GIT_COMMAND_KILL_GRACE_MS = 2_000;
|
|
56
|
-
export const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
|
|
57
|
-
export const WORKTREE_PATCH_MAX_BYTES = GIT_OUTPUT_MAX_BYTES;
|
|
58
|
-
|
|
59
|
-
/** Git apply validates and writes in one process. The repository lane
|
|
60
|
-
* (thread-lifecycle) already serializes every finalize against all writers of
|
|
61
|
-
* the same canonical checkout, so applies never race each other here. */
|
|
62
|
-
function terminateCommandTree(child: ChildProcess, force: boolean, processGroup: boolean): void {
|
|
63
|
-
if (process.platform === "win32" && child.pid !== undefined) {
|
|
64
|
-
const fallback = (): void => {
|
|
65
|
-
try {
|
|
66
|
-
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
67
|
-
} catch {
|
|
68
|
-
/* process may already be gone */
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
72
|
-
stdio: "ignore",
|
|
73
|
-
windowsHide: true,
|
|
74
|
-
});
|
|
75
|
-
killer.once("error", fallback);
|
|
76
|
-
killer.once("close", (code) => {
|
|
77
|
-
if (code !== 0) fallback();
|
|
78
|
-
});
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
try {
|
|
82
|
-
if (processGroup && child.pid !== undefined) {
|
|
83
|
-
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
84
|
-
} else {
|
|
85
|
-
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
/* process may already be gone */
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Default argument-safe runner. Output is bounded before binary patches enter
|
|
93
|
-
* memory, and timeout/abort terminates the complete checkout-filter process tree. */
|
|
94
|
-
export const runCommand: CommandRunner = (command, args, options) =>
|
|
95
|
-
new Promise<CommandResult>((resolveResult, reject) => {
|
|
96
|
-
if (options.signal?.aborted) {
|
|
97
|
-
reject(new Error(`Command aborted before start: ${command}`));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const usePosixProcessGroup = process.platform !== "win32";
|
|
101
|
-
const child = spawn(command, [...args], {
|
|
102
|
-
cwd: options.cwd,
|
|
103
|
-
shell: false,
|
|
104
|
-
windowsHide: true,
|
|
105
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
106
|
-
detached: usePosixProcessGroup,
|
|
107
|
-
...(options.env ? { env: { ...process.env, ...options.env } } : {}),
|
|
108
|
-
});
|
|
109
|
-
const stdout: Buffer[] = [];
|
|
110
|
-
const stderr: Buffer[] = [];
|
|
111
|
-
const maxOutputBytes = options.maxOutputBytes ?? GIT_OUTPUT_MAX_BYTES;
|
|
112
|
-
let outputBytes = 0;
|
|
113
|
-
let finished = false;
|
|
114
|
-
let failure: Error | undefined;
|
|
115
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
-
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
117
|
-
|
|
118
|
-
const terminate = (): void => {
|
|
119
|
-
terminateCommandTree(child, false, usePosixProcessGroup);
|
|
120
|
-
if (!forceKillTimer) {
|
|
121
|
-
forceKillTimer = setTimeout(
|
|
122
|
-
() => terminateCommandTree(child, true, usePosixProcessGroup),
|
|
123
|
-
GIT_COMMAND_KILL_GRACE_MS,
|
|
124
|
-
);
|
|
125
|
-
if (typeof forceKillTimer.unref === "function") forceKillTimer.unref();
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
const fail = (error: Error): void => {
|
|
129
|
-
if (failure || finished) return;
|
|
130
|
-
failure = error;
|
|
131
|
-
terminate();
|
|
132
|
-
};
|
|
133
|
-
const append = (target: Buffer[], chunk: Buffer | string): void => {
|
|
134
|
-
if (failure || finished) return;
|
|
135
|
-
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
136
|
-
outputBytes += value.length;
|
|
137
|
-
if (outputBytes > maxOutputBytes) {
|
|
138
|
-
fail(new Error(`Command output exceeded ${maxOutputBytes} bytes: ${command}`));
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
target.push(value);
|
|
142
|
-
};
|
|
143
|
-
const onAbort = (): void => fail(new Error(`Command aborted: ${command}`));
|
|
144
|
-
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
145
|
-
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
146
|
-
timeout = setTimeout(
|
|
147
|
-
() => fail(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`)),
|
|
148
|
-
options.timeoutMs,
|
|
149
|
-
);
|
|
150
|
-
if (typeof timeout.unref === "function") timeout.unref();
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
child.stdout?.on("data", (chunk: Buffer | string) => append(stdout, chunk));
|
|
154
|
-
child.stderr?.on("data", (chunk: Buffer | string) => append(stderr, chunk));
|
|
155
|
-
child.once("error", (error) => fail(error));
|
|
156
|
-
child.once("close", (code) => {
|
|
157
|
-
if (finished) return;
|
|
158
|
-
finished = true;
|
|
159
|
-
if (timeout) clearTimeout(timeout);
|
|
160
|
-
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
161
|
-
options.signal?.removeEventListener("abort", onAbort);
|
|
162
|
-
if (failure) {
|
|
163
|
-
reject(failure);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
resolveResult({
|
|
167
|
-
code: code ?? 1,
|
|
168
|
-
stdout: Buffer.concat(stdout),
|
|
169
|
-
stderr: Buffer.concat(stderr),
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
child.stdin?.once("error", () => undefined);
|
|
173
|
-
child.stdin?.end(options.input);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
38
|
export interface WorktreeTarget {
|
|
177
39
|
/** Canonical cwd requested by the caller. */
|
|
178
40
|
originalCwd: string;
|
|
@@ -325,16 +187,6 @@ export function isPathInside(root: string, candidate: string): boolean {
|
|
|
325
187
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
326
188
|
}
|
|
327
189
|
|
|
328
|
-
/** The temp group directory backing a worktree path, when the path actually
|
|
329
|
-
* names our `<group>/worktree` layout. Recovery deletes only paths read back
|
|
330
|
-
* from the manifest through this guard. */
|
|
331
|
-
export function worktreeGroupDir(worktreePath: string): string | undefined {
|
|
332
|
-
const group = dirname(worktreePath);
|
|
333
|
-
return basename(worktreePath) === "worktree" && basename(group).startsWith(WORKTREE_TEMP_DIR_PREFIX)
|
|
334
|
-
? group
|
|
335
|
-
: undefined;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
190
|
/** Delete one isolated worktree group: Git's own removal keeps metadata
|
|
339
191
|
* authoritative, but Git on Windows cannot always delete deep checkouts
|
|
340
192
|
* ("Filename too long"), so the Node removal decides the outcome and the prune
|
|
@@ -663,7 +515,8 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
663
515
|
* that index, so everything runs against a private copy of the checkout's
|
|
664
516
|
* index: the copy first absorbs the current unstaged state (`add -A`),
|
|
665
517
|
* making the working tree "ours" of the merge, and the user's real staged
|
|
666
|
-
* state is never touched.
|
|
518
|
+
* state is never touched. The repository lane serializes finalization, and
|
|
519
|
+
* each `git apply` validates and writes in one process. */
|
|
667
520
|
private async applyPatchThreeWay(): Promise<boolean> {
|
|
668
521
|
const indexCopy = join(this.tempDir, "apply-index");
|
|
669
522
|
try {
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* failure directly so it is never delayed.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { formatUsageCompact, sumUsage, type RunWaitReason } from "
|
|
11
|
-
import type { UsageStats } from "
|
|
10
|
+
import { formatUsageCompact, sumUsage, type RunWaitReason } from "../presentation/monitor.ts";
|
|
11
|
+
import type { UsageStats } from "../execution/rpc-control.ts";
|
|
12
12
|
|
|
13
13
|
export interface CompletionBatchTimings {
|
|
14
14
|
debounceMs: number;
|