@ferris1225/pi-subagents 4.2.2 → 4.2.5
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/README.md +4 -2
- package/agents/explorer.md +5 -8
- package/package.json +55 -55
- package/src/durable.ts +105 -33
- package/src/recovery.ts +20 -2
- package/src/rpc-run.ts +993 -993
- package/src/runtime.ts +3 -3
- package/src/thread-lifecycle.ts +7 -2
- package/src/tools.ts +1 -1
- package/src/worktree.ts +66 -30
package/src/runtime.ts
CHANGED
|
@@ -258,7 +258,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
258
258
|
// thread whose settlement finished during the wait above already
|
|
259
259
|
// wrote (or removed) its own record; the lastResult-derived state
|
|
260
260
|
// below matches it.
|
|
261
|
-
const
|
|
261
|
+
const settled: Array<{ runId: number; cwd: string }> = [];
|
|
262
262
|
const records: ThreadRecord[] = [];
|
|
263
263
|
for (const thread of runtime.threads.values()) {
|
|
264
264
|
if (thread.retired) continue;
|
|
@@ -272,11 +272,11 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
272
272
|
state = "parked";
|
|
273
273
|
}
|
|
274
274
|
if (state === "parked") records.push(threadRecordFromThread(thread, state));
|
|
275
|
-
else
|
|
275
|
+
else settled.push({ runId: thread.id, cwd: thread.cwd });
|
|
276
276
|
}
|
|
277
277
|
await Promise.all([
|
|
278
278
|
...records.map((record) => upsertThreadRecord(runtime.configPath, record).catch(() => undefined)),
|
|
279
|
-
...
|
|
279
|
+
...settled.map(({ runId, cwd }) => removeThreadRecord(runtime.configPath, runId, cwd).catch(() => undefined)),
|
|
280
280
|
]);
|
|
281
281
|
// Retained-failure recovery records are persisted by the finalization
|
|
282
282
|
// itself; shutdown only drops sessions no record claims anymore.
|
package/src/thread-lifecycle.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "./config.ts";
|
|
28
28
|
import {
|
|
29
29
|
isCurrentBoot,
|
|
30
|
+
migrateLegacyThreadsManifest,
|
|
30
31
|
readThreadRecords,
|
|
31
32
|
referencedDurablePaths,
|
|
32
33
|
removeThreadRecord,
|
|
@@ -225,7 +226,7 @@ export function persistThreadCheckpoint(
|
|
|
225
226
|
): void {
|
|
226
227
|
const write = state === "parked"
|
|
227
228
|
? upsertThreadRecord(runtime.configPath, threadRecordFromThread(thread, state))
|
|
228
|
-
: removeThreadRecord(runtime.configPath, thread.id);
|
|
229
|
+
: removeThreadRecord(runtime.configPath, thread.id, thread.cwd);
|
|
229
230
|
void write.catch(() => undefined);
|
|
230
231
|
}
|
|
231
232
|
|
|
@@ -927,6 +928,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
927
928
|
status: "retained",
|
|
928
929
|
integrated: false,
|
|
929
930
|
hadChanges: false,
|
|
931
|
+
originalRoot: candidate.originalRoot,
|
|
930
932
|
...(retainedPath ? { worktreePath: retainedPath } : {}),
|
|
931
933
|
...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
|
|
932
934
|
error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -1144,7 +1146,7 @@ async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRec
|
|
|
1144
1146
|
const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
|
|
1145
1147
|
await worktree?.discard().catch(() => undefined);
|
|
1146
1148
|
}
|
|
1147
|
-
await removeThreadRecord(runtime.configPath, record.runId).catch(() => undefined);
|
|
1149
|
+
await removeThreadRecord(runtime.configPath, record.runId, record.cwd).catch(() => undefined);
|
|
1148
1150
|
}
|
|
1149
1151
|
|
|
1150
1152
|
/** Project-scoped <projectRoot>/results for a completion's artifacts. */
|
|
@@ -1272,6 +1274,9 @@ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<n
|
|
|
1272
1274
|
export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
|
|
1273
1275
|
const restore = (async () => {
|
|
1274
1276
|
try {
|
|
1277
|
+
// Records from a pre-per-project manifest must land in their project
|
|
1278
|
+
// roots before restore reads anything.
|
|
1279
|
+
await migrateLegacyThreadsManifest(runtime.configPath);
|
|
1275
1280
|
runtime.restoredRunIds = await restoreDurableThreads(runtime);
|
|
1276
1281
|
} catch {
|
|
1277
1282
|
/* restore is best-effort */
|
package/src/tools.ts
CHANGED
|
@@ -344,7 +344,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
344
344
|
runtime.retireThreadSession(thread);
|
|
345
345
|
// The destructive retire removes the durable record with the session;
|
|
346
346
|
// an id never resurrects after subagent_stop.
|
|
347
|
-
await removeThreadRecord(runtime.configPath, runId).catch(() => undefined);
|
|
347
|
+
await removeThreadRecord(runtime.configPath, runId, thread.cwd).catch(() => undefined);
|
|
348
348
|
if (thread.lifecycleVersion === stopVersion && thread.lifecycleOperation === "stop") {
|
|
349
349
|
thread.lifecycleOperation = undefined;
|
|
350
350
|
}
|
package/src/worktree.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
13
|
import { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
14
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
14
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
15
15
|
import { writeTempOwnerMarker } from "./temp-hygiene.ts";
|
|
16
16
|
|
|
17
17
|
export type IsolationMode = "shared" | "worktree";
|
|
@@ -233,6 +233,8 @@ export interface WorktreeFinalization {
|
|
|
233
233
|
/** True once the patch was successfully applied to the original worktree. */
|
|
234
234
|
integrated: boolean;
|
|
235
235
|
hadChanges: boolean;
|
|
236
|
+
/** Repository a recovery path can prune stale worktree metadata against. */
|
|
237
|
+
originalRoot?: string;
|
|
236
238
|
worktreePath?: string;
|
|
237
239
|
patchPath?: string;
|
|
238
240
|
error?: string;
|
|
@@ -323,6 +325,63 @@ export function isPathInside(root: string, candidate: string): boolean {
|
|
|
323
325
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
324
326
|
}
|
|
325
327
|
|
|
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
|
+
/** Delete one isolated worktree group: Git's own removal keeps metadata
|
|
339
|
+
* authoritative, but Git on Windows cannot always delete deep checkouts
|
|
340
|
+
* ("Filename too long"), so the Node removal decides the outcome and the prune
|
|
341
|
+
* clears any stale registration left behind. Returns an error string when
|
|
342
|
+
* artifacts still exist afterwards. */
|
|
343
|
+
export async function removeWorktreeGroup(
|
|
344
|
+
paths: { originalRoot?: string; tempDir: string; worktreePath: string },
|
|
345
|
+
runner: CommandRunner = runCommand,
|
|
346
|
+
): Promise<string | undefined> {
|
|
347
|
+
let removeError: string | undefined;
|
|
348
|
+
if (paths.originalRoot && existsSync(paths.worktreePath)) {
|
|
349
|
+
// core.longpaths only exists in Git for Windows, and some POSIX builds
|
|
350
|
+
// reject unknown -c core keys, so the flag stays platform-gated.
|
|
351
|
+
const longPaths = process.platform === "win32" ? ["-c", "core.longpaths=true"] : [];
|
|
352
|
+
try {
|
|
353
|
+
await runGit(
|
|
354
|
+
runner,
|
|
355
|
+
paths.originalRoot,
|
|
356
|
+
[...longPaths, "worktree", "remove", "--force", paths.worktreePath],
|
|
357
|
+
`Removing isolated worktree ${paths.worktreePath}`,
|
|
358
|
+
);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
removeError = error instanceof Error ? error.message : String(error);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
await rm(paths.tempDir, { recursive: true, force: true });
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const rmError = error instanceof Error ? error.message : String(error);
|
|
367
|
+
return removeError
|
|
368
|
+
? `${removeError}; removing temporary directory failed: ${rmError}`
|
|
369
|
+
: `Removing temporary directory failed: ${rmError}`;
|
|
370
|
+
}
|
|
371
|
+
if (!paths.originalRoot) return undefined;
|
|
372
|
+
try {
|
|
373
|
+
await runGit(
|
|
374
|
+
runner,
|
|
375
|
+
paths.originalRoot,
|
|
376
|
+
["worktree", "prune"],
|
|
377
|
+
`Pruning Git worktree metadata for ${paths.originalRoot}`,
|
|
378
|
+
);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return error instanceof Error ? error.message : String(error);
|
|
381
|
+
}
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
|
|
326
385
|
interface RepositoryLocation {
|
|
327
386
|
originalCwd: string;
|
|
328
387
|
originalRoot: string;
|
|
@@ -588,6 +647,7 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
588
647
|
status: "retained",
|
|
589
648
|
integrated,
|
|
590
649
|
hadChanges,
|
|
650
|
+
originalRoot: this.originalRoot,
|
|
591
651
|
...(existsSync(this.worktreePath) ? { worktreePath: this.worktreePath } : {}),
|
|
592
652
|
...(patchWritten && existsSync(this.patchPath) ? { patchPath: this.patchPath } : {}),
|
|
593
653
|
error,
|
|
@@ -649,35 +709,11 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
649
709
|
}
|
|
650
710
|
|
|
651
711
|
/** Return an error string instead of throwing so applied work is never retried. */
|
|
652
|
-
private
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
["worktree", "remove", "--force", this.worktreePath],
|
|
658
|
-
`Removing isolated worktree ${this.worktreePath}`,
|
|
659
|
-
);
|
|
660
|
-
} catch (error) {
|
|
661
|
-
return error instanceof Error ? error.message : String(error);
|
|
662
|
-
}
|
|
663
|
-
let pruneError: string | undefined;
|
|
664
|
-
try {
|
|
665
|
-
await runGit(
|
|
666
|
-
this.runner,
|
|
667
|
-
this.originalRoot,
|
|
668
|
-
["worktree", "prune"],
|
|
669
|
-
`Pruning Git worktree metadata for ${this.originalRoot}`,
|
|
670
|
-
);
|
|
671
|
-
} catch (error) {
|
|
672
|
-
pruneError = error instanceof Error ? error.message : String(error);
|
|
673
|
-
}
|
|
674
|
-
try {
|
|
675
|
-
await rm(this.tempDir, { recursive: true, force: true });
|
|
676
|
-
} catch (error) {
|
|
677
|
-
const rmError = error instanceof Error ? error.message : String(error);
|
|
678
|
-
return pruneError ? `${pruneError}; removing temporary directory failed: ${rmError}` : `Removing temporary directory failed: ${rmError}`;
|
|
679
|
-
}
|
|
680
|
-
return pruneError;
|
|
712
|
+
private removeAndPrune(): Promise<string | undefined> {
|
|
713
|
+
return removeWorktreeGroup(
|
|
714
|
+
{ originalRoot: this.originalRoot, worktreePath: this.worktreePath, tempDir: this.tempDir },
|
|
715
|
+
this.runner,
|
|
716
|
+
);
|
|
681
717
|
}
|
|
682
718
|
}
|
|
683
719
|
|