@hcflabs/pi-subagent 0.0.0-stage → 27.10.7
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/CONTEXT.md +77 -0
- package/LICENSE +22 -0
- package/README.md +282 -2
- package/dist/composition.d.ts +11 -0
- package/dist/composition.js +104 -0
- package/dist/direct-herdr.d.ts +71 -0
- package/dist/direct-herdr.js +527 -0
- package/dist/discard.d.ts +51 -0
- package/dist/discard.js +218 -0
- package/dist/display-text.d.ts +5 -0
- package/dist/display-text.js +9 -0
- package/dist/ephemeral.d.ts +79 -0
- package/dist/ephemeral.js +916 -0
- package/dist/git-process.d.ts +8 -0
- package/dist/git-process.js +25 -0
- package/dist/git-runtime.d.ts +115 -0
- package/dist/git-runtime.js +489 -0
- package/dist/herdr-runtime.d.ts +147 -0
- package/dist/herdr-runtime.js +1366 -0
- package/dist/index.d.ts +170 -0
- package/dist/index.js +587 -0
- package/dist/integration-git.d.ts +63 -0
- package/dist/integration-git.js +504 -0
- package/dist/launch-runtime.d.ts +54 -0
- package/dist/launch-runtime.js +178 -0
- package/dist/mcp-role.d.ts +12 -0
- package/dist/mcp-role.js +152 -0
- package/dist/process-lease.d.ts +5 -0
- package/dist/process-lease.js +43 -0
- package/dist/process.d.ts +9 -0
- package/dist/process.js +11 -0
- package/dist/prune.d.ts +78 -0
- package/dist/prune.js +205 -0
- package/dist/review-evidence.d.ts +17 -0
- package/dist/review-evidence.js +221 -0
- package/dist/runner.d.ts +431 -0
- package/dist/runner.js +3380 -0
- package/dist/schema.d.ts +2560 -0
- package/dist/schema.js +1365 -0
- package/dist/store.d.ts +66 -0
- package/dist/store.js +394 -0
- package/dist/worktree.d.ts +65 -0
- package/dist/worktree.js +288 -0
- package/docs/orchestration-architecture.html +138 -0
- package/docs/orchestration-architecture.svg +135 -0
- package/docs/orchestration.md +577 -0
- package/docs/worker-lifecycle.html +97 -0
- package/docs/worker-lifecycle.svg +86 -0
- package/example.png +0 -0
- package/examples/roles/implementer.md +25 -0
- package/examples/roles/reviewer.md +25 -0
- package/examples/roles/scout.md +25 -0
- package/extensions/admission.ts +82 -0
- package/extensions/config.ts +19 -0
- package/extensions/git-read.ts +126 -0
- package/extensions/isolated.ts +945 -0
- package/extensions/model-class-policy.ts +1 -0
- package/extensions/result-transport.ts +236 -0
- package/extensions/role-mcp.ts +14 -0
- package/extensions/role-tools.ts +178 -0
- package/extensions/subagent-command.ts +699 -0
- package/extensions/subagent.ts +958 -0
- package/extensions/workflow.ts +336 -0
- package/package.json +84 -4
- package/prompts/review.md +20 -0
- package/skills/pi-subagent/SKILL.md +41 -0
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type RunState } from "./schema.ts";
|
|
2
|
+
declare const productiveRunLeaseBrand: unique symbol;
|
|
3
|
+
export declare class ProductiveRunLeaseBusyError extends Error {
|
|
4
|
+
constructor();
|
|
5
|
+
}
|
|
6
|
+
export interface ProductiveRunLease {
|
|
7
|
+
readonly [productiveRunLeaseBrand]: true;
|
|
8
|
+
}
|
|
9
|
+
export type LifecycleLockOptions = {
|
|
10
|
+
readonly purpose?: "productive";
|
|
11
|
+
readonly productiveRunLease?: ProductiveRunLease;
|
|
12
|
+
} | {
|
|
13
|
+
readonly purpose: "status" | "abort";
|
|
14
|
+
readonly productiveRunLease?: never;
|
|
15
|
+
};
|
|
16
|
+
export interface LifecycleLock {
|
|
17
|
+
readonly productiveRunLeaseActive: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare class RunStateHandle {
|
|
20
|
+
state: RunState;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
private writes;
|
|
23
|
+
private readonly root;
|
|
24
|
+
private readonly requestId;
|
|
25
|
+
private readonly replace;
|
|
26
|
+
constructor(state: RunState, path: string, replace: (contents: string) => Promise<void>);
|
|
27
|
+
save(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export declare class FileRunStore {
|
|
30
|
+
private readonly agentDir?;
|
|
31
|
+
private readonly onStateSaved?;
|
|
32
|
+
private readonly productiveRunLeases;
|
|
33
|
+
constructor(agentDir?: string, onStateSaved?: (state: RunState) => void);
|
|
34
|
+
stateDirectory(root: string): string;
|
|
35
|
+
statePath(root: string, id: string): string;
|
|
36
|
+
private lockPath;
|
|
37
|
+
private productiveRunPath;
|
|
38
|
+
private assertSafeDestination;
|
|
39
|
+
withProductiveRunLease<T>(root: string, operation: (lease: ProductiveRunLease) => Promise<T>): Promise<T>;
|
|
40
|
+
hasProductiveRunLease(root: string): Promise<boolean>;
|
|
41
|
+
withLock<T>(root: string, operation: (lifecycle: LifecycleLock) => Promise<T>, options?: LifecycleLockOptions): Promise<T>;
|
|
42
|
+
create(state: RunState): Promise<RunStateHandle>;
|
|
43
|
+
load(root: string, id: string): Promise<RunStateHandle>;
|
|
44
|
+
/** Address saved state without resolving a parent checkout that may no longer exist. */
|
|
45
|
+
private validateDirectory;
|
|
46
|
+
loadFromDirectory(directory: string, id: string): Promise<RunStateHandle>;
|
|
47
|
+
private loadFromValidatedDirectory;
|
|
48
|
+
hasProductiveRunLeaseInDirectory(directory: string): Promise<boolean>;
|
|
49
|
+
withDirectoryLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
|
|
50
|
+
/** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
|
|
51
|
+
listOrphans(primary: string): Promise<{
|
|
52
|
+
states: RunStateHandle[];
|
|
53
|
+
invalidIds: string[];
|
|
54
|
+
}>;
|
|
55
|
+
private stateEntries;
|
|
56
|
+
/** Read only version and request status; never migrate or interpret v4 worker evidence. */
|
|
57
|
+
assertLegacyAdmissionSafe(root: string): Promise<void>;
|
|
58
|
+
list(root: string): Promise<{
|
|
59
|
+
states: RunStateHandle[];
|
|
60
|
+
invalidIds: string[];
|
|
61
|
+
}>;
|
|
62
|
+
/** Raw text of a state file, for evidence when it does not parse. */
|
|
63
|
+
readRaw(root: string, id: string): Promise<string>;
|
|
64
|
+
private handle;
|
|
65
|
+
}
|
|
66
|
+
export {};
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, readdir, realpath } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@hcflabs/pi-config-store";
|
|
6
|
+
import { check, lock } from "proper-lockfile";
|
|
7
|
+
import { parseRunState, RUN_STATE_VERSION } from "./schema.js";
|
|
8
|
+
import { discardResources } from "./discard.js";
|
|
9
|
+
import { isMissing, isWithin } from "./worktree.js";
|
|
10
|
+
const INITIAL_STATE_MAX_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
/*
|
|
12
|
+
* create() caps the complete initial serialization at 2 MiB. Loaded requests
|
|
13
|
+
* are independently bounded by parseExecuteRequest.
|
|
14
|
+
*
|
|
15
|
+
* Saves validate and reject atomically instead of dropping valid evidence.
|
|
16
|
+
* Reads use the same finite ceiling and preserve rejected files.
|
|
17
|
+
*/
|
|
18
|
+
const STATE_MAX_BYTES = 128 * 1024 * 1024;
|
|
19
|
+
const ADMISSION_MAX_FILES = 256;
|
|
20
|
+
const ADMISSION_MAX_BYTES = STATE_MAX_BYTES;
|
|
21
|
+
const V4_TERMINAL_STATUSES = new Set(["completed", "final_failed", "superseded", "aborted"]);
|
|
22
|
+
const V4_UNFINISHED_STATUSES = new Set(["pending", "running", "needs_attention"]);
|
|
23
|
+
const LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 };
|
|
24
|
+
const LIFECYCLE_LOCK_OPTIONS = {
|
|
25
|
+
...LOCK_OPTIONS,
|
|
26
|
+
retries: { forever: true, factor: 1, minTimeout: 25, maxTimeout: 25 },
|
|
27
|
+
};
|
|
28
|
+
const productiveRunLeaseBrand = Symbol("productiveRunLease");
|
|
29
|
+
export class ProductiveRunLeaseBusyError extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("Another Pi Subagent productive request is active in this repository.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isAlreadyPresent(error) {
|
|
35
|
+
return Boolean(error && typeof error === "object" && error.code === "EEXIST");
|
|
36
|
+
}
|
|
37
|
+
function isLocked(error) {
|
|
38
|
+
return Boolean(error && typeof error === "object" && error.code === "ELOCKED");
|
|
39
|
+
}
|
|
40
|
+
async function canonicalPlannedPath(path) {
|
|
41
|
+
let ancestor = resolve(path);
|
|
42
|
+
const suffix = [];
|
|
43
|
+
for (;;) {
|
|
44
|
+
try {
|
|
45
|
+
return resolve(await realpath(ancestor), ...suffix.reverse());
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (!isMissing(error))
|
|
49
|
+
throw error;
|
|
50
|
+
const parent = dirname(ancestor);
|
|
51
|
+
if (parent === ancestor)
|
|
52
|
+
throw error;
|
|
53
|
+
suffix.push(basename(ancestor));
|
|
54
|
+
ancestor = parent;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function serialize(state, maxBytes = STATE_MAX_BYTES) {
|
|
59
|
+
const validated = parseRunState(state);
|
|
60
|
+
const contents = `${JSON.stringify(validated, null, 2)}\n`;
|
|
61
|
+
if (Buffer.byteLength(contents, "utf8") > maxBytes) {
|
|
62
|
+
throw new Error(`pi-subagent state exceeds ${maxBytes} bytes.`);
|
|
63
|
+
}
|
|
64
|
+
return contents;
|
|
65
|
+
}
|
|
66
|
+
export class RunStateHandle {
|
|
67
|
+
state;
|
|
68
|
+
path;
|
|
69
|
+
writes = Promise.resolve();
|
|
70
|
+
root;
|
|
71
|
+
requestId;
|
|
72
|
+
replace;
|
|
73
|
+
constructor(state, path, replace) {
|
|
74
|
+
this.state = state;
|
|
75
|
+
this.path = path;
|
|
76
|
+
this.root = state.root;
|
|
77
|
+
this.requestId = state.request.id;
|
|
78
|
+
this.replace = replace;
|
|
79
|
+
}
|
|
80
|
+
async save() {
|
|
81
|
+
if (this.state.root !== this.root || this.state.request.id !== this.requestId) {
|
|
82
|
+
throw new Error("A state handle cannot change its owned repository or request ID.");
|
|
83
|
+
}
|
|
84
|
+
const contents = serialize(this.state);
|
|
85
|
+
this.writes = this.writes.then(async () => await this.replace(contents));
|
|
86
|
+
await this.writes;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class FileRunStore {
|
|
90
|
+
agentDir;
|
|
91
|
+
onStateSaved;
|
|
92
|
+
productiveRunLeases = new WeakMap();
|
|
93
|
+
constructor(agentDir, onStateSaved) {
|
|
94
|
+
this.agentDir = agentDir;
|
|
95
|
+
this.onStateSaved = onStateSaved;
|
|
96
|
+
}
|
|
97
|
+
stateDirectory(root) {
|
|
98
|
+
const canonicalRoot = realpathSync.native(root);
|
|
99
|
+
const directory = join(extensionConfigDir("pi-subagent", this.agentDir), "state", createHash("sha256").update(canonicalRoot).digest("hex"));
|
|
100
|
+
if (isWithin(canonicalRoot, resolve(directory))) {
|
|
101
|
+
throw new Error("Pi Subagent state directory must be outside the Git workspace.");
|
|
102
|
+
}
|
|
103
|
+
return directory;
|
|
104
|
+
}
|
|
105
|
+
statePath(root, id) {
|
|
106
|
+
return join(this.stateDirectory(root), `${id}.json`);
|
|
107
|
+
}
|
|
108
|
+
lockPath(root) {
|
|
109
|
+
return join(this.stateDirectory(root), "lifecycle.lock");
|
|
110
|
+
}
|
|
111
|
+
productiveRunPath(root) {
|
|
112
|
+
return join(this.stateDirectory(root), "productive-run");
|
|
113
|
+
}
|
|
114
|
+
async assertSafeDestination(root, destination) {
|
|
115
|
+
if (isWithin(await canonicalPlannedPath(root), await canonicalPlannedPath(destination))) {
|
|
116
|
+
throw new Error("Pi Subagent state directory must be outside the Git workspace.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async withProductiveRunLease(root, operation) {
|
|
120
|
+
const directory = this.stateDirectory(root);
|
|
121
|
+
await this.assertSafeDestination(root, directory);
|
|
122
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
123
|
+
const path = this.productiveRunPath(root);
|
|
124
|
+
let release;
|
|
125
|
+
try {
|
|
126
|
+
release = await lock(path, { ...LOCK_OPTIONS, lockfilePath: `${path}.lock` });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (isLocked(error)) {
|
|
130
|
+
throw new ProductiveRunLeaseBusyError();
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
const lease = { [productiveRunLeaseBrand]: true };
|
|
135
|
+
this.productiveRunLeases.set(lease, path);
|
|
136
|
+
try {
|
|
137
|
+
return await operation(lease);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
this.productiveRunLeases.delete(lease);
|
|
141
|
+
await release();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async hasProductiveRunLease(root) {
|
|
145
|
+
const directory = this.stateDirectory(root);
|
|
146
|
+
await this.assertSafeDestination(root, directory);
|
|
147
|
+
const path = this.productiveRunPath(root);
|
|
148
|
+
return await check(path, { realpath: false, stale: LOCK_OPTIONS.stale, lockfilePath: `${path}.lock` });
|
|
149
|
+
}
|
|
150
|
+
async withLock(root, operation, options = {}) {
|
|
151
|
+
const directory = this.stateDirectory(root);
|
|
152
|
+
await this.assertSafeDestination(root, directory);
|
|
153
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
154
|
+
const path = this.lockPath(root);
|
|
155
|
+
const release = await lock(path, { ...LIFECYCLE_LOCK_OPTIONS, lockfilePath: `${path}.lock` });
|
|
156
|
+
try {
|
|
157
|
+
const productiveRunLeaseActive = await this.hasProductiveRunLease(root);
|
|
158
|
+
const ownedProductiveRunPath = options.productiveRunLease
|
|
159
|
+
? this.productiveRunLeases.get(options.productiveRunLease)
|
|
160
|
+
: undefined;
|
|
161
|
+
if (productiveRunLeaseActive
|
|
162
|
+
&& (options.purpose ?? "productive") === "productive"
|
|
163
|
+
&& ownedProductiveRunPath !== this.productiveRunPath(root)) {
|
|
164
|
+
throw new Error("Another Pi Subagent productive request is active in this repository.");
|
|
165
|
+
}
|
|
166
|
+
const lifecycle = { productiveRunLeaseActive };
|
|
167
|
+
return await operation(lifecycle);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await release();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async create(state) {
|
|
174
|
+
const path = this.statePath(state.root, state.request.id);
|
|
175
|
+
await this.assertSafeDestination(state.root, path);
|
|
176
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
177
|
+
const contents = serialize(state, INITIAL_STATE_MAX_BYTES);
|
|
178
|
+
let file;
|
|
179
|
+
try {
|
|
180
|
+
file = await open(path, "wx", 0o600);
|
|
181
|
+
await file.writeFile(contents, "utf8");
|
|
182
|
+
await file.sync();
|
|
183
|
+
await file.close();
|
|
184
|
+
file = undefined;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
await file?.close();
|
|
188
|
+
if (isAlreadyPresent(error))
|
|
189
|
+
throw new Error(`Pi Subagent request ${state.request.id} already exists.`);
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
const saved = JSON.parse(contents);
|
|
193
|
+
this.onStateSaved?.(structuredClone(saved));
|
|
194
|
+
return this.handle(saved, path);
|
|
195
|
+
}
|
|
196
|
+
async load(root, id) {
|
|
197
|
+
const path = this.statePath(root, id);
|
|
198
|
+
let raw;
|
|
199
|
+
try {
|
|
200
|
+
raw = await readTextFileBounded(path, STATE_MAX_BYTES);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error instanceof Error && error.message === `Text file exceeds ${STATE_MAX_BYTES} bytes: ${path}`) {
|
|
204
|
+
throw new Error(`pi-subagent state exceeds ${STATE_MAX_BYTES} bytes.`);
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
const state = parseRunState(JSON.parse(raw));
|
|
209
|
+
if (state.root !== realpathSync.native(root) || state.request.id !== id) {
|
|
210
|
+
throw new Error("pi-subagent state identity does not match its repository and filename.");
|
|
211
|
+
}
|
|
212
|
+
return this.handle(state, path);
|
|
213
|
+
}
|
|
214
|
+
/** Address saved state without resolving a parent checkout that may no longer exist. */
|
|
215
|
+
async validateDirectory(directory) {
|
|
216
|
+
const parent = join(extensionConfigDir("pi-subagent", this.agentDir), "state");
|
|
217
|
+
if (dirname(directory) !== parent || !/^[a-f0-9]{64}$/.test(basename(directory))
|
|
218
|
+
|| !(await lstat(directory)).isDirectory() || await realpath(directory) !== join(await realpath(parent), basename(directory))) {
|
|
219
|
+
throw new Error("Invalid Pi Subagent state directory.");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async loadFromDirectory(directory, id) {
|
|
223
|
+
await this.validateDirectory(directory);
|
|
224
|
+
return await this.loadFromValidatedDirectory(directory, id);
|
|
225
|
+
}
|
|
226
|
+
async loadFromValidatedDirectory(directory, id) {
|
|
227
|
+
if (basename(id) !== id || !id || id === "." || id === "..")
|
|
228
|
+
throw new Error("Invalid request ID.");
|
|
229
|
+
const path = join(directory, `${id}.json`);
|
|
230
|
+
if (!(await lstat(path)).isFile())
|
|
231
|
+
throw new Error("State is not a regular file.");
|
|
232
|
+
const state = parseRunState(JSON.parse(await readTextFileBounded(path, STATE_MAX_BYTES)));
|
|
233
|
+
if (state.request.id !== id || !isAbsolute(state.root)
|
|
234
|
+
|| createHash("sha256").update(state.root).digest("hex") !== basename(directory)) {
|
|
235
|
+
throw new Error("pi-subagent state identity does not match its directory and filename.");
|
|
236
|
+
}
|
|
237
|
+
await this.assertSafeDestination(state.root, path);
|
|
238
|
+
return this.handle(state, path);
|
|
239
|
+
}
|
|
240
|
+
async hasProductiveRunLeaseInDirectory(directory) {
|
|
241
|
+
await this.validateDirectory(directory);
|
|
242
|
+
const path = join(directory, "productive-run");
|
|
243
|
+
return await check(path, { realpath: false, stale: LOCK_OPTIONS.stale, lockfilePath: `${path}.lock` });
|
|
244
|
+
}
|
|
245
|
+
async withDirectoryLock(directory, operation) {
|
|
246
|
+
await this.validateDirectory(directory);
|
|
247
|
+
const path = join(directory, "lifecycle.lock");
|
|
248
|
+
const release = await lock(path, { ...LIFECYCLE_LOCK_OPTIONS, lockfilePath: `${path}.lock` });
|
|
249
|
+
try {
|
|
250
|
+
return await operation();
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await release();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
|
|
257
|
+
async listOrphans(primary) {
|
|
258
|
+
const parent = join(extensionConfigDir("pi-subagent", this.agentDir), "state");
|
|
259
|
+
let directories;
|
|
260
|
+
try {
|
|
261
|
+
directories = await readdir(parent, { withFileTypes: true });
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (isMissing(error))
|
|
265
|
+
return { states: [], invalidIds: [] };
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
const states = [];
|
|
269
|
+
const invalidIds = [];
|
|
270
|
+
for (const entry of directories) {
|
|
271
|
+
const directory = join(parent, entry.name);
|
|
272
|
+
try {
|
|
273
|
+
await this.validateDirectory(directory);
|
|
274
|
+
// Every state in a directory shares one root; a live root means no orphan can be here.
|
|
275
|
+
const invalid = [];
|
|
276
|
+
let live = false;
|
|
277
|
+
for (const file of await readdir(directory)) {
|
|
278
|
+
if (!file.endsWith(".json"))
|
|
279
|
+
continue;
|
|
280
|
+
try {
|
|
281
|
+
const handle = await this.loadFromValidatedDirectory(directory, file.slice(0, -5));
|
|
282
|
+
try {
|
|
283
|
+
await lstat(handle.state.root);
|
|
284
|
+
live = true;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (!isMissing(error))
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
const worktrees = discardResources(handle.state).flatMap((resource) => resource.worktree ? [resource.worktree] : []);
|
|
292
|
+
if (worktrees.length && worktrees.every((item) => item.repoRoot === primary))
|
|
293
|
+
states.push(handle);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
invalid.push(join(directory, file));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!live)
|
|
300
|
+
invalidIds.push(...invalid);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
invalidIds.push(directory);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { states, invalidIds };
|
|
307
|
+
}
|
|
308
|
+
async stateEntries(root) {
|
|
309
|
+
try {
|
|
310
|
+
return (await readdir(this.stateDirectory(root), { withFileTypes: true }))
|
|
311
|
+
.filter((item) => item.name.endsWith(".json"))
|
|
312
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (isMissing(error))
|
|
316
|
+
return [];
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Read only version and request status; never migrate or interpret v4 worker evidence. */
|
|
321
|
+
async assertLegacyAdmissionSafe(root) {
|
|
322
|
+
const directory = this.stateDirectory(root);
|
|
323
|
+
await this.assertSafeDestination(root, directory);
|
|
324
|
+
const entries = await this.stateEntries(root);
|
|
325
|
+
if (entries.length > ADMISSION_MAX_FILES) {
|
|
326
|
+
throw new Error(`Pi Subagent cannot inventory more than ${ADMISSION_MAX_FILES} state files for safe admission; recover older requests with their owning version first.`);
|
|
327
|
+
}
|
|
328
|
+
let remainingBytes = ADMISSION_MAX_BYTES;
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
const id = entry.name.slice(0, -5);
|
|
331
|
+
const path = join(directory, entry.name);
|
|
332
|
+
const recovery = `State preserved at ${path}. Inspect and recover any retained workers with their compatible owner before starting new isolated work.`;
|
|
333
|
+
let value;
|
|
334
|
+
try {
|
|
335
|
+
if (!entry.isFile())
|
|
336
|
+
throw new Error("State is not a regular file.");
|
|
337
|
+
if (remainingBytes === 0)
|
|
338
|
+
throw new Error("Admission inventory byte limit reached.");
|
|
339
|
+
const raw = await readTextFileBounded(path, remainingBytes);
|
|
340
|
+
remainingBytes -= Buffer.byteLength(raw, "utf8");
|
|
341
|
+
value = JSON.parse(raw);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
throw new Error(`Pi Subagent cannot read state ${id} for safe admission. ${recovery}`);
|
|
345
|
+
}
|
|
346
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !("version" in value)) {
|
|
347
|
+
throw new Error(`Pi Subagent malformed state ${id} blocks safe admission. ${recovery}`);
|
|
348
|
+
}
|
|
349
|
+
if (value.version === RUN_STATE_VERSION)
|
|
350
|
+
continue;
|
|
351
|
+
if (value.version !== 4 || !("status" in value) || typeof value.status !== "string"
|
|
352
|
+
|| (!V4_TERMINAL_STATUSES.has(value.status) && !V4_UNFINISHED_STATUSES.has(value.status))) {
|
|
353
|
+
throw new Error(`Pi Subagent malformed or unsupported legacy state ${id} blocks safe admission. ${recovery}`);
|
|
354
|
+
}
|
|
355
|
+
if (V4_UNFINISHED_STATUSES.has(value.status)) {
|
|
356
|
+
throw new Error(`Pi Subagent unfinished v4 request ${id} (${value.status}) blocks new isolated work. ${recovery}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async list(root) {
|
|
361
|
+
const entries = await this.stateEntries(root);
|
|
362
|
+
const states = [];
|
|
363
|
+
const invalidIds = [];
|
|
364
|
+
for (const entry of entries) {
|
|
365
|
+
const id = entry.name.slice(0, -5);
|
|
366
|
+
// A symlink, directory, or other non-regular entry is unreadable state, never absent state.
|
|
367
|
+
if (!entry.isFile()) {
|
|
368
|
+
invalidIds.push(id);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
states.push(await this.load(root, id));
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
invalidIds.push(id);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { states, invalidIds };
|
|
379
|
+
}
|
|
380
|
+
/** Raw text of a state file, for evidence when it does not parse. */
|
|
381
|
+
async readRaw(root, id) {
|
|
382
|
+
const path = this.statePath(root, id);
|
|
383
|
+
if (!(await lstat(path)).isFile())
|
|
384
|
+
throw new Error(`State entry ${id} is not a regular file.`);
|
|
385
|
+
return await readTextFileBounded(path, STATE_MAX_BYTES);
|
|
386
|
+
}
|
|
387
|
+
handle(state, path) {
|
|
388
|
+
return new RunStateHandle(state, path, async (contents) => {
|
|
389
|
+
await this.assertSafeDestination(state.root, path);
|
|
390
|
+
await writePrivateTextFileAtomically(path, contents);
|
|
391
|
+
this.onStateSaved?.(JSON.parse(contents));
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type GitRunner } from "./git-process.ts";
|
|
2
|
+
export declare function isMissing(error: unknown): boolean;
|
|
3
|
+
export declare function isWithin(root: string, candidate: string): boolean;
|
|
4
|
+
export interface WorktreeInfo {
|
|
5
|
+
path: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
repoRoot: string;
|
|
9
|
+
baseCommit: string;
|
|
10
|
+
}
|
|
11
|
+
export type WorktreePayload = {
|
|
12
|
+
outcome: "pruned";
|
|
13
|
+
path: string;
|
|
14
|
+
branch: string;
|
|
15
|
+
} | {
|
|
16
|
+
outcome: "retained";
|
|
17
|
+
path: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
commits: number;
|
|
20
|
+
dirty: boolean;
|
|
21
|
+
} | {
|
|
22
|
+
outcome: "recovery";
|
|
23
|
+
path: string;
|
|
24
|
+
branch: string;
|
|
25
|
+
note: string;
|
|
26
|
+
commits?: number;
|
|
27
|
+
dirty?: boolean;
|
|
28
|
+
};
|
|
29
|
+
export declare const WORKTREES_DIRNAME = ".worktrees";
|
|
30
|
+
export declare const READABLE_BRANCH_PATTERN: RegExp;
|
|
31
|
+
/** Keep saved opaque names eligible for exact recovery and cleanup. */
|
|
32
|
+
export declare const OWNED_BRANCH_PATTERN: RegExp;
|
|
33
|
+
export declare class WorktreeSetupError extends Error {
|
|
34
|
+
name: string;
|
|
35
|
+
readonly worktree: WorktreeInfo;
|
|
36
|
+
constructor(message: string, worktree: WorktreeInfo);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Creates one worktree per child from parent HEAD. Returns undefined only when
|
|
40
|
+
* the workspace is not a git repository or HEAD is unborn — callers degrade
|
|
41
|
+
* silently to the shared working directory. Setup failures in a real git
|
|
42
|
+
* repository (unwritable path, stale branch, git lock) throw so an isolated
|
|
43
|
+
* role never silently loses its isolation.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createChildWorktree(cwd: string, childId: string, run?: GitRunner, signal?: AbortSignal, onPrepared?: (worktree: WorktreeInfo) => Promise<void>, nameParts?: readonly string[]): Promise<WorktreeInfo | undefined>;
|
|
46
|
+
export interface WorktreeDirtyInspection {
|
|
47
|
+
dirty: boolean;
|
|
48
|
+
failure?: string;
|
|
49
|
+
initializedSubmodules?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export declare function inspectIndexFlags(cwd: string, run?: GitRunner, signal?: AbortSignal): Promise<{
|
|
52
|
+
hidden: boolean;
|
|
53
|
+
failure?: string;
|
|
54
|
+
}>;
|
|
55
|
+
/** Proves a worktree has no tracked, untracked, index-hidden, or nested submodule work; cleanup also checks ignored files. */
|
|
56
|
+
export declare function inspectWorktreeDirty(cwd: string, run?: GitRunner, includeIgnored?: boolean): Promise<WorktreeDirtyInspection>;
|
|
57
|
+
/**
|
|
58
|
+
* Inspects and possibly prunes a child worktree after it finishes. Commit count
|
|
59
|
+
* reads the dedicated branch and refuses to prune when checkout HEAD no longer
|
|
60
|
+
* names it; the clean-tree proof includes untracked, ignored, and submodule
|
|
61
|
+
* changes despite repository config. A worktree with zero branch commits and a
|
|
62
|
+
* clean tree is removed only when every probe succeeds and base_commit was
|
|
63
|
+
* recorded; any uncertainty preserves work and omits unknown measurements.
|
|
64
|
+
*/
|
|
65
|
+
export declare function finalizeChildWorktree(info: WorktreeInfo, run?: GitRunner): Promise<WorktreePayload>;
|