@ferris1225/pi-subagents 4.3.8 → 4.3.10
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 +41 -0
- package/README.md +165 -117
- package/index.ts +2 -0
- package/package.json +12 -10
- package/src/configuration/setup.ts +16 -14
- package/src/delegation/agents.ts +2 -1
- package/src/delegation/dispatch.ts +189 -38
- package/src/delegation/phase-scope.ts +178 -0
- package/src/delegation/prompt.ts +46 -36
- package/src/delegation/risk.ts +168 -0
- package/src/execution/rpc-control.ts +2 -29
- package/src/execution/rpc-run.ts +29 -30
- package/src/execution/spawn.ts +21 -20
- package/src/isolation/temp-hygiene.ts +7 -9
- package/src/isolation/worktree.ts +13 -88
- package/src/lifecycle/durable.ts +24 -8
- package/src/lifecycle/runtime.ts +27 -63
- package/src/lifecycle/thread-lifecycle.ts +67 -414
- package/src/lifecycle/thread-restore.ts +16 -35
- package/src/lifecycle/thread-shared.ts +9 -63
- package/src/lifecycle/tools.ts +86 -241
- package/src/presentation/announcements.ts +1 -1
- package/src/presentation/format.ts +6 -16
- package/src/presentation/monitor.ts +0 -91
- package/src/presentation/widget.ts +7 -17
- package/src/execution/session-fork.ts +0 -86
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Detached Git worktree isolation for write-capable sub-agents.
|
|
3
3
|
*
|
|
4
|
-
* A handle is created
|
|
5
|
-
*
|
|
4
|
+
* A handle is created in the bounded queue and stays owned by the logical run
|
|
5
|
+
* across startup retries and model candidates. Finalize
|
|
6
6
|
* is idempotent: it records a binary patch, applies it to the original working
|
|
7
7
|
* tree without touching its index, then removes/prunes the temporary worktree.
|
|
8
8
|
* Failed integration deliberately retains both the worktree and patch.
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
runCommand,
|
|
18
18
|
type CommandResult,
|
|
19
19
|
type CommandRunner,
|
|
20
|
-
WORKTREE_PATCH_MAX_BYTES,
|
|
21
20
|
} from "./git-command.ts";
|
|
22
21
|
import { writeTempOwnerMarker } from "./temp-hygiene.ts";
|
|
23
22
|
|
|
@@ -25,9 +24,7 @@ export type IsolationMode = "shared" | "worktree";
|
|
|
25
24
|
|
|
26
25
|
const WORKTREE_TEMP_DIR_PREFIX = "pi-subagent-worktree-";
|
|
27
26
|
|
|
28
|
-
/** Short stable identity of one isolated worktree group (the mkdtemp suffix).
|
|
29
|
-
* Continuation generations create a fresh worktree, so the identity
|
|
30
|
-
* visibly changes when the group's filesystem boundary changes. */
|
|
27
|
+
/** Short stable identity of one isolated worktree group (the mkdtemp suffix). */
|
|
31
28
|
export function worktreeGroupId(worktree: Pick<WorktreeIsolation, "tempDir">): string {
|
|
32
29
|
const base = worktree.tempDir.split(/[\\/]/).filter(Boolean).pop() ?? worktree.tempDir;
|
|
33
30
|
return base.startsWith(WORKTREE_TEMP_DIR_PREFIX)
|
|
@@ -60,9 +57,8 @@ export interface WorktreeCheckpointRef {
|
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
/** Persistable projection of one worktree handle: enough to rebuild the
|
|
63
|
-
* handle after a reload or restart. Patch bytes are deliberately omitted
|
|
64
|
-
*
|
|
65
|
-
* store, is needed to seed a continuation. */
|
|
60
|
+
* handle after a reload or restart. Patch bytes are deliberately omitted;
|
|
61
|
+
* the checkpoint commit remains in the shared repository as recovery evidence. */
|
|
66
62
|
export interface WorktreeSnapshot {
|
|
67
63
|
originalCwd: string;
|
|
68
64
|
originalRoot: string;
|
|
@@ -81,11 +77,6 @@ export interface WorktreeCreateOptions {
|
|
|
81
77
|
/** Parent directory for the worktree group: the project-scoped durable
|
|
82
78
|
* worktrees root, so isolation never lands in the OS temp directory. */
|
|
83
79
|
tempBaseDir: string;
|
|
84
|
-
/** Complete source generation checkpoint merged onto the current HEAD. */
|
|
85
|
-
seedCheckpoint?: WorktreeCheckpoint;
|
|
86
|
-
/** The seed is already present in the parent checkout, so only later edits
|
|
87
|
-
* should be integrated when this continuation settles. */
|
|
88
|
-
seedIsIntegrated?: boolean;
|
|
89
80
|
}
|
|
90
81
|
|
|
91
82
|
export type WorktreeFinalizationStatus = "integrated" | "no_changes" | "retained";
|
|
@@ -110,17 +101,12 @@ export interface WorktreeIsolation {
|
|
|
110
101
|
readonly tempDir: string;
|
|
111
102
|
readonly patchPath: string;
|
|
112
103
|
readonly head: string;
|
|
113
|
-
/** Diff base
|
|
114
|
-
* the generation was seeded with already-integrated work. */
|
|
104
|
+
/** Diff base retained in snapshots so recovery never reapplies integrated work. */
|
|
115
105
|
readonly integrationBaseHead: string;
|
|
116
106
|
readonly state: "active" | "finalizing" | WorktreeFinalizationStatus;
|
|
117
|
-
/** Checkpoint retained after finalization for
|
|
107
|
+
/** Checkpoint retained after finalization for recovery evidence. */
|
|
118
108
|
getContinuationCheckpoint(): WorktreeCheckpoint | undefined;
|
|
119
|
-
/**
|
|
120
|
-
* The synthetic commit lets Git merge an already-committed seed without
|
|
121
|
-
* attempting to apply the same patch twice. */
|
|
122
|
-
snapshotCheckpoint(): Promise<WorktreeCheckpoint>;
|
|
123
|
-
/** Remove a newly-created continuation that failed before it was dispatched. */
|
|
109
|
+
/** Remove an unused worktree whose dispatch was cancelled. */
|
|
124
110
|
discard(): Promise<void>;
|
|
125
111
|
/** Whether anything is pending against the integration base — the same
|
|
126
112
|
* question finalization answers as `hadChanges`, asked before settlement so
|
|
@@ -319,8 +305,7 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
319
305
|
private currentState: WorktreeIsolation["state"] = "active";
|
|
320
306
|
private finalization?: Promise<WorktreeFinalization>;
|
|
321
307
|
private discardPromise?: Promise<void>;
|
|
322
|
-
/** Full workspace checkpoint
|
|
323
|
-
* retained after cleanup so settled threads can continue safely. */
|
|
308
|
+
/** Full workspace checkpoint retained for manual recovery. */
|
|
324
309
|
private continuationCheckpoint?: WorktreeCheckpoint;
|
|
325
310
|
|
|
326
311
|
constructor(
|
|
@@ -332,8 +317,7 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
332
317
|
readonly patchPath: string,
|
|
333
318
|
readonly head: string,
|
|
334
319
|
private readonly runner: CommandRunner,
|
|
335
|
-
/**
|
|
336
|
-
* checkout already contains. Finalization then integrates only new edits. */
|
|
320
|
+
/** A restored base may represent work already integrated into the parent. */
|
|
337
321
|
readonly integrationBaseHead: string = head,
|
|
338
322
|
restored?: {
|
|
339
323
|
state: WorktreeIsolation["state"];
|
|
@@ -354,19 +338,6 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
354
338
|
return this.continuationCheckpoint ? cloneCheckpoint(this.continuationCheckpoint) : undefined;
|
|
355
339
|
}
|
|
356
340
|
|
|
357
|
-
async snapshotCheckpoint(): Promise<WorktreeCheckpoint> {
|
|
358
|
-
if (this.continuationCheckpoint) return cloneCheckpoint(this.continuationCheckpoint);
|
|
359
|
-
if (this.currentState === "no_changes") {
|
|
360
|
-
return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
|
|
361
|
-
}
|
|
362
|
-
if (!existsSync(this.worktreePath)) {
|
|
363
|
-
throw new Error(`Cannot snapshot isolated worktree after it was removed: ${this.worktreePath}`);
|
|
364
|
-
}
|
|
365
|
-
const snapshot = await this.collectChanges(this.head);
|
|
366
|
-
this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
|
|
367
|
-
return cloneCheckpoint(this.continuationCheckpoint);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
341
|
discard(): Promise<void> {
|
|
371
342
|
if (this.discardPromise) return this.discardPromise;
|
|
372
343
|
if (this.finalization) {
|
|
@@ -385,8 +356,8 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
385
356
|
|
|
386
357
|
async hasPendingChanges(): Promise<boolean> {
|
|
387
358
|
// A settled worktree already recorded the answer; asking Git again after
|
|
388
|
-
// removal would fail. Diffing the integration base (not HEAD)
|
|
389
|
-
//
|
|
359
|
+
// removal would fail. Diffing the integration base (not HEAD) excludes
|
|
360
|
+
// previously integrated work from the recovered run's pending changes.
|
|
390
361
|
if (this.currentState === "no_changes") return false;
|
|
391
362
|
if (this.currentState !== "active" || !existsSync(this.worktreePath)) return true;
|
|
392
363
|
const diff = await this.collectChanges(this.integrationBaseHead);
|
|
@@ -633,50 +604,6 @@ export async function createWorktreeIsolation(
|
|
|
633
604
|
await mkdir(isolatedCwd, { recursive: true });
|
|
634
605
|
await linkNodeModules(runner, target.originalRoot, worktreePath);
|
|
635
606
|
|
|
636
|
-
let integrationBaseHead = target.head;
|
|
637
|
-
const checkpoint = options.seedCheckpoint;
|
|
638
|
-
if (checkpoint && checkpoint.patch.length > WORKTREE_PATCH_MAX_BYTES) {
|
|
639
|
-
throw new Error(
|
|
640
|
-
`Isolated checkpoint exceeds the ${WORKTREE_PATCH_MAX_BYTES}-byte patch limit (${checkpoint.patch.length} bytes).`,
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
|
-
if (checkpoint && checkpoint.patch.length > 0) {
|
|
644
|
-
// Merge the checkpoint commit with today's HEAD instead of blindly
|
|
645
|
-
// applying its old patch. If the parent committed generation one after
|
|
646
|
-
// integration, Git recognizes the equivalent tree and produces HEAD
|
|
647
|
-
// unchanged; unrelated newer commits are preserved by the three-way merge.
|
|
648
|
-
const merged = await runGit(
|
|
649
|
-
runner,
|
|
650
|
-
worktreePath,
|
|
651
|
-
["merge-tree", "--write-tree", "--messages", target.head, checkpoint.commit],
|
|
652
|
-
`Merging isolated checkpoint into continuation ${worktreePath}`,
|
|
653
|
-
);
|
|
654
|
-
const mergedTree = merged.stdout.toString("utf8").split(/\r?\n/, 1)[0]?.trim();
|
|
655
|
-
if (!mergedTree) throw new Error("Git returned no merged continuation tree id.");
|
|
656
|
-
await runGit(
|
|
657
|
-
runner,
|
|
658
|
-
worktreePath,
|
|
659
|
-
["read-tree", "--reset", "-u", mergedTree],
|
|
660
|
-
`Materializing isolated checkpoint in ${worktreePath}`,
|
|
661
|
-
);
|
|
662
|
-
if (options.seedIsIntegrated) {
|
|
663
|
-
const commit = await runGit(
|
|
664
|
-
runner,
|
|
665
|
-
worktreePath,
|
|
666
|
-
[
|
|
667
|
-
"-c", "user.name=pi-subagents",
|
|
668
|
-
"-c", "user.email=pi-subagents@example.invalid",
|
|
669
|
-
"commit-tree", mergedTree,
|
|
670
|
-
"-p", target.head,
|
|
671
|
-
"-m", "pi-subagents continuation baseline",
|
|
672
|
-
],
|
|
673
|
-
`Creating continuation baseline commit in ${worktreePath}`,
|
|
674
|
-
);
|
|
675
|
-
integrationBaseHead = commit.stdout.toString("utf8").trim();
|
|
676
|
-
if (!integrationBaseHead) throw new Error("Git returned no continuation baseline commit id.");
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
607
|
return new GitWorktreeIsolation(
|
|
681
608
|
target.originalCwd,
|
|
682
609
|
target.originalRoot,
|
|
@@ -686,7 +613,6 @@ export async function createWorktreeIsolation(
|
|
|
686
613
|
patchPath,
|
|
687
614
|
target.head,
|
|
688
615
|
runner,
|
|
689
|
-
integrationBaseHead,
|
|
690
616
|
);
|
|
691
617
|
} catch (error) {
|
|
692
618
|
const rollbackErrors: string[] = [];
|
|
@@ -795,8 +721,7 @@ export function normalizeWorktreeSnapshot(value: unknown): WorktreeSnapshot | nu
|
|
|
795
721
|
/** Rebuild a handle from a persisted snapshot. Returns undefined when the
|
|
796
722
|
* on-disk worktree that an active/retained snapshot promises is gone; settled
|
|
797
723
|
* states (integrated/no_changes) intentionally need no filesystem. The
|
|
798
|
-
* restored checkpoint
|
|
799
|
-
* continuation seeds. */
|
|
724
|
+
* restored checkpoint retains its commit identity without duplicating patch bytes. */
|
|
800
725
|
export async function restoreWorktreeIsolation(
|
|
801
726
|
snapshot: WorktreeSnapshot,
|
|
802
727
|
options: { runner?: CommandRunner } = {},
|
package/src/lifecycle/durable.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promis
|
|
|
19
19
|
import { uptime } from "node:os";
|
|
20
20
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
21
21
|
import type { UsageStats } from "../execution/rpc-control.ts";
|
|
22
|
+
import { normalizePhaseId, normalizePhaseScope, type PhaseScope } from "../delegation/phase-scope.ts";
|
|
22
23
|
import type { SubagentThread } from "./runtime.ts";
|
|
23
24
|
import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "../execution/spawn.ts";
|
|
24
25
|
import { isManagedSessionDir, isManagedWorktreeLayout, samePath } from "../isolation/managed-paths.ts";
|
|
@@ -42,9 +43,8 @@ const THREADS_MANIFEST_VERSION = 1;
|
|
|
42
43
|
* win over the age rule. */
|
|
43
44
|
export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
* records from previous versions; restore discards them on sight. */
|
|
46
|
+
/** Interrupted work retains its recovery record for a month. Older manifests
|
|
47
|
+
* may still carry settled records; restore discards those on sight. */
|
|
48
48
|
export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
49
49
|
|
|
50
50
|
/** Result excerpts are for status display after restore, not full transcripts. */
|
|
@@ -76,11 +76,14 @@ export interface ThreadRecord {
|
|
|
76
76
|
generation: number;
|
|
77
77
|
agentName: string;
|
|
78
78
|
task: string;
|
|
79
|
+
phaseId?: string;
|
|
80
|
+
scope?: PhaseScope;
|
|
81
|
+
writeCapable?: boolean;
|
|
79
82
|
cwd: string;
|
|
80
83
|
executionCwd: string;
|
|
81
84
|
/** Resolved (clamped) level of the last generation. */
|
|
82
85
|
thinkingLevel?: string;
|
|
83
|
-
/**
|
|
86
|
+
/** Originally requested level, retained with the recovery metadata. */
|
|
84
87
|
requestedThinkingLevel?: string;
|
|
85
88
|
isolation: IsolationMode;
|
|
86
89
|
state: "parked" | "completed" | "failed";
|
|
@@ -102,9 +105,8 @@ export function currentBootId(now = Date.now()): number {
|
|
|
102
105
|
/** Whether a record's `childPids` can still name processes of this boot. Pids
|
|
103
106
|
* are only unique within a boot: after a restart the same number belongs to
|
|
104
107
|
* whatever claimed it, so restore must not signal them. Records written before
|
|
105
|
-
* this field existed carry no boot id and count as unverifiable
|
|
106
|
-
* stray child alive
|
|
107
|
-
* unrelated process tree is not recoverable. */
|
|
108
|
+
* this field existed carry no boot id and count as unverifiable; leaving a
|
|
109
|
+
* stray child alive is safer than killing an unrelated process tree. */
|
|
108
110
|
export function isCurrentBoot(record: ThreadRecord, now = Date.now()): boolean {
|
|
109
111
|
if (record.bootId === undefined) return false;
|
|
110
112
|
return Math.abs(record.bootId - currentBootId(now)) <= BOOT_ID_TOLERANCE_MS;
|
|
@@ -166,6 +168,14 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
|
|
|
166
168
|
if (raw.state !== "parked" && raw.state !== "completed" && raw.state !== "failed") return undefined;
|
|
167
169
|
const worktree = raw.worktree === undefined ? undefined : normalizeWorktreeSnapshot(raw.worktree);
|
|
168
170
|
if (worktree === null) return undefined;
|
|
171
|
+
let phaseId: string | undefined;
|
|
172
|
+
let scope: PhaseScope | undefined;
|
|
173
|
+
try {
|
|
174
|
+
phaseId = normalizePhaseId(raw.phaseId as string | undefined);
|
|
175
|
+
scope = normalizePhaseScope(raw.scope as Parameters<typeof normalizePhaseScope>[0], raw.cwd);
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
169
179
|
return {
|
|
170
180
|
runId: raw.runId,
|
|
171
181
|
createdAt: raw.createdAt,
|
|
@@ -173,6 +183,9 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
|
|
|
173
183
|
generation: typeof raw.generation === "number" && Number.isInteger(raw.generation) && raw.generation >= 0 ? raw.generation : 0,
|
|
174
184
|
agentName: raw.agentName,
|
|
175
185
|
task: raw.task,
|
|
186
|
+
...(phaseId ? { phaseId } : {}),
|
|
187
|
+
...(scope ? { scope } : {}),
|
|
188
|
+
...(typeof raw.writeCapable === "boolean" ? { writeCapable: raw.writeCapable } : {}),
|
|
176
189
|
cwd: raw.cwd,
|
|
177
190
|
executionCwd: typeof raw.executionCwd === "string" && raw.executionCwd ? raw.executionCwd : raw.cwd,
|
|
178
191
|
...(typeof raw.thinkingLevel === "string" && raw.thinkingLevel ? { thinkingLevel: raw.thinkingLevel } : {}),
|
|
@@ -360,7 +373,7 @@ function summarizeResult(result: SingleResult): ThreadResultSummary | undefined
|
|
|
360
373
|
|
|
361
374
|
/** Project a live thread into its durable record. Only handles whose
|
|
362
375
|
* filesystem is still meaningful are persisted; finalized-and-removed
|
|
363
|
-
* worktrees keep
|
|
376
|
+
* worktrees keep their checkpoint commit as recovery evidence. */
|
|
364
377
|
export function threadRecordFromThread(
|
|
365
378
|
thread: SubagentThread,
|
|
366
379
|
state: "parked" | "completed" | "failed",
|
|
@@ -375,6 +388,9 @@ export function threadRecordFromThread(
|
|
|
375
388
|
generation: thread.generation,
|
|
376
389
|
agentName: thread.agentName,
|
|
377
390
|
task: thread.task,
|
|
391
|
+
...(thread.phaseId ? { phaseId: thread.phaseId } : {}),
|
|
392
|
+
...(thread.scope ? { scope: thread.scope } : {}),
|
|
393
|
+
...(thread.writeCapable !== undefined ? { writeCapable: thread.writeCapable } : {}),
|
|
378
394
|
cwd: thread.cwd,
|
|
379
395
|
executionCwd: thread.executionCwd,
|
|
380
396
|
...(thread.thinkingLevel ? { thinkingLevel: thread.thinkingLevel } : {}),
|
package/src/lifecycle/runtime.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared per-session runtime state for pi-subagents.
|
|
3
3
|
*
|
|
4
|
-
* The extension registers
|
|
4
|
+
* The extension registers dispatch, read-only status, and destructive stop tools
|
|
5
5
|
* that share the background queue, completion batcher, abort controllers per
|
|
6
6
|
* run, and settled-results store.
|
|
7
7
|
* `createRuntime` builds those once per extension load and hands the same object
|
|
8
8
|
* to every registration site, so state stays in one place without globals.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { ExtensionAPI
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { PhaseScope } from "../delegation/phase-scope.ts";
|
|
12
13
|
import { rmSync } from "node:fs";
|
|
13
14
|
import { resolveSubagentConcurrency, BackgroundTaskQueue } from "../execution/background.ts";
|
|
14
15
|
import {
|
|
@@ -22,66 +23,50 @@ import { type ThinkingLevel } from "../configuration/config.ts";
|
|
|
22
23
|
import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
|
|
23
24
|
import { isRunActiveStatus, monitor } from "../presentation/monitor.ts";
|
|
24
25
|
import type { RpcRunControl } from "../execution/rpc-control.ts";
|
|
25
|
-
import type { StartBackgroundInternal } from "./thread-shared.ts";
|
|
26
26
|
import { isFailedResult, type SingleResult } from "../execution/spawn.ts";
|
|
27
27
|
import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "../isolation/worktree.ts";
|
|
28
28
|
|
|
29
|
-
export type ThreadState =
|
|
30
|
-
| "queued"
|
|
31
|
-
| "resuming"
|
|
32
|
-
| "running"
|
|
33
|
-
| "interrupting"
|
|
34
|
-
| "parked"
|
|
35
|
-
| "completed"
|
|
36
|
-
| "failed"
|
|
37
|
-
| "stopped";
|
|
29
|
+
export type ThreadState = "queued" | "running" | "interrupting" | "parked" | "completed" | "failed" | "stopped";
|
|
38
30
|
|
|
39
|
-
export type ThreadLifecycleOperation = "
|
|
31
|
+
export type ThreadLifecycleOperation = "stop" | "settle";
|
|
40
32
|
|
|
41
33
|
export interface SubagentThread {
|
|
42
34
|
id: number;
|
|
43
35
|
generation: number;
|
|
44
36
|
agentName: string;
|
|
45
37
|
task: string;
|
|
38
|
+
phaseId?: string;
|
|
39
|
+
scope?: PhaseScope;
|
|
40
|
+
/** Capability snapshot used by declared-scope admission, including after restore. */
|
|
41
|
+
writeCapable?: boolean;
|
|
46
42
|
/** Caller-facing cwd in the original worktree. */
|
|
47
43
|
cwd: string;
|
|
48
44
|
/** Actual child cwd (the equivalent path inside an isolated worktree). */
|
|
49
45
|
executionCwd: string;
|
|
50
|
-
/** Level actually used, after clamping to the effective model's capability. */
|
|
51
46
|
thinkingLevel?: ThinkingLevel;
|
|
52
|
-
/** Level the dispatch asked for, before clamping; replayed on every resume. */
|
|
53
47
|
requestedThinkingLevel?: ThinkingLevel;
|
|
54
48
|
isolation: IsolationMode;
|
|
55
49
|
worktree?: WorktreeIsolation;
|
|
56
|
-
/** Durable restoration failure that permanently blocks continuation. */
|
|
57
|
-
resumeUnavailableReason?: string;
|
|
58
50
|
/** Original durable evidence retained when its worktree handle is unavailable. */
|
|
59
51
|
restorationRecord?: ThreadRecord;
|
|
60
52
|
state: ThreadState;
|
|
61
53
|
control: RpcRunControl;
|
|
62
54
|
queueController?: AbortController;
|
|
63
|
-
/** Resolves
|
|
64
|
-
* finalization, and queue work have fully quiesced and released their
|
|
65
|
-
* concurrency slot. */
|
|
55
|
+
/** Resolves after the child, isolation finalization, and queue work fully quiesce. */
|
|
66
56
|
generationCompletion: Promise<void>;
|
|
67
|
-
/**
|
|
57
|
+
/** Arbitration between asynchronous settlement and destructive stop. */
|
|
68
58
|
lifecycleVersion: number;
|
|
69
59
|
lifecycleOperation?: ThreadLifecycleOperation;
|
|
70
60
|
sessionId?: string;
|
|
71
61
|
sessionDir?: string;
|
|
72
|
-
/** Active execution time accumulated across retained resume generations. */
|
|
73
62
|
elapsedMs: number;
|
|
74
|
-
/**
|
|
63
|
+
/** Terminal or interrupted partial result; independent of the transient monitor row. */
|
|
75
64
|
lastResult?: SingleResult;
|
|
76
65
|
/** A destructive stop retires context even if the active child settles later. */
|
|
77
66
|
retireOnSettle?: boolean;
|
|
78
67
|
retired?: boolean;
|
|
79
|
-
/**
|
|
80
|
-
resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
81
|
-
/** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
|
|
82
|
-
* runs under the canonical original-repository lane. */
|
|
68
|
+
/** All owners finalize under the same original-repository lane. */
|
|
83
69
|
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
84
|
-
/** Best-effort shutdown notification for retained integration artifacts. */
|
|
85
70
|
notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
|
|
86
71
|
isolationFailureNotified?: boolean;
|
|
87
72
|
}
|
|
@@ -93,9 +78,6 @@ export interface SubagentRuntime {
|
|
|
93
78
|
getActiveTools: () => string[];
|
|
94
79
|
/** False after session_shutdown; guards delivery and queue work. */
|
|
95
80
|
sessionActive: boolean;
|
|
96
|
-
/** The process-wide background dispatcher. Set at tool registration so
|
|
97
|
-
* threads restored from the durable manifest can resume before any dispatch. */
|
|
98
|
-
dispatcher?: StartBackgroundInternal;
|
|
99
81
|
/** Resolves when the load-time durable restore pass has finished. Everything
|
|
100
82
|
* that answers "which threads exist" awaits it — the lookup tools, a fresh
|
|
101
83
|
* dispatch before it allocates a run id, and the restored-thread notice — so
|
|
@@ -129,9 +111,6 @@ export interface SubagentRuntime {
|
|
|
129
111
|
registerRunResult: (runId: number, result: SingleResult) => void;
|
|
130
112
|
/** Logical threads outlive process attempts and completed generations. */
|
|
131
113
|
threads: Map<number, SubagentThread>;
|
|
132
|
-
/** Resume setup that has claimed a thread but has not yet enqueued its
|
|
133
|
-
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
134
|
-
preflightOperations: Set<Promise<void>>;
|
|
135
114
|
/** Every session directory retained for this parent session. */
|
|
136
115
|
sessionDirs: Set<string>;
|
|
137
116
|
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
@@ -231,7 +210,6 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
231
210
|
settledRuns: new Map<number, SingleResult>(),
|
|
232
211
|
settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
|
|
233
212
|
threads: new Map<number, SubagentThread>(),
|
|
234
|
-
preflightOperations: new Set<Promise<void>>(),
|
|
235
213
|
sessionDirs: new Set<string>(),
|
|
236
214
|
retainSession: (result) => {
|
|
237
215
|
if (result.sessionDir) runtime.sessionDirs.add(result.sessionDir);
|
|
@@ -267,11 +245,9 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
267
245
|
if (!runtime.sessionActive) return;
|
|
268
246
|
runtime.sessionActive = false;
|
|
269
247
|
const shutdownThreads = [...runtime.threads.values()];
|
|
270
|
-
const liveStates = new Set(["queued", "
|
|
248
|
+
const liveStates = new Set(["queued", "running", "interrupting"]);
|
|
271
249
|
const previousStates = new Map(shutdownThreads.map((thread) => [thread.id, thread.state] as const));
|
|
272
|
-
// Invalidate
|
|
273
|
-
// Resume preflight checks both this version and sessionActive, then
|
|
274
|
-
// cleans any worktree/session it created before resolving its tracker.
|
|
250
|
+
// Invalidate pending lifecycle claims synchronously before the first await.
|
|
275
251
|
// A generation already inside its settlement keeps its own claim: it
|
|
276
252
|
// finalizes its worktree and persists its terminal record itself.
|
|
277
253
|
const interrupting = shutdownThreads.filter((thread) =>
|
|
@@ -288,29 +264,21 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
288
264
|
if (thread.lifecycleOperation === "settle") continue;
|
|
289
265
|
thread.lifecycleOperation = "stop";
|
|
290
266
|
// Deliberately NOT retireOnSettle: shutdown interrupts to the last
|
|
291
|
-
// checkpoint
|
|
267
|
+
// checkpoint and preserves session/worktree artifacts for manual recovery.
|
|
292
268
|
thread.retireOnSettle = false;
|
|
293
269
|
if (liveStates.has(thread.state)) thread.state = "stopped";
|
|
294
270
|
}
|
|
295
|
-
const preflights = [...runtime.preflightOperations];
|
|
296
271
|
runtime.completionBatcher.dispose();
|
|
297
|
-
//
|
|
298
|
-
// session is gone, so there is no window left to deliver them into.
|
|
272
|
+
// The parent session is gone; no window remains for buffered delivery.
|
|
299
273
|
heldCompletions = [];
|
|
300
274
|
compactionInFlight = false;
|
|
301
275
|
runtime.backgroundQueue.cancelAll();
|
|
302
|
-
//
|
|
303
|
-
// before persisting records or releasing ownership maps.
|
|
276
|
+
// Quiesce child processes and owned queue work before persisting records.
|
|
304
277
|
await Promise.all([
|
|
305
|
-
|
|
306
|
-
interrupting.map((thread) =>
|
|
307
|
-
thread.control.stop("Parent session shut down").catch(() => undefined),
|
|
308
|
-
),
|
|
309
|
-
),
|
|
310
|
-
Promise.allSettled(preflights),
|
|
278
|
+
...interrupting.map((thread) => thread.control.stop("Parent session shut down").catch(() => undefined)),
|
|
311
279
|
runtime.backgroundQueue.waitForIdle(),
|
|
312
280
|
]);
|
|
313
|
-
// Only interrupted
|
|
281
|
+
// Only interrupted work keeps recovery artifacts across reloads:
|
|
314
282
|
// each keeps its durable record and retained artifacts. Settled
|
|
315
283
|
// threads drop their record — the manifest exists only while
|
|
316
284
|
// unfinished work needs it — and their sessions are deleted now. A
|
|
@@ -321,16 +289,13 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
321
289
|
const records: ThreadRecord[] = [];
|
|
322
290
|
for (const thread of runtime.threads.values()) {
|
|
323
291
|
if (thread.retired) continue;
|
|
324
|
-
if (thread.
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
elapsedMs: thread.elapsedMs,
|
|
332
|
-
});
|
|
333
|
-
}
|
|
292
|
+
if (thread.restorationRecord) {
|
|
293
|
+
// Keep recovery evidence until an explicit destructive stop retires it.
|
|
294
|
+
records.push({
|
|
295
|
+
...thread.restorationRecord,
|
|
296
|
+
updatedAt: Date.now(),
|
|
297
|
+
elapsedMs: thread.elapsedMs,
|
|
298
|
+
});
|
|
334
299
|
continue;
|
|
335
300
|
}
|
|
336
301
|
const previous = previousStates.get(thread.id) ?? thread.state;
|
|
@@ -371,7 +336,6 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
371
336
|
// sessionDirs entries still referenced by records stay owned by the
|
|
372
337
|
// manifest; the next process re-registers them at restore.
|
|
373
338
|
runtime.sessionDirs.clear();
|
|
374
|
-
runtime.preflightOperations.clear();
|
|
375
339
|
runtime.threads.clear();
|
|
376
340
|
monitor.clear();
|
|
377
341
|
},
|