@ferris1225/pi-subagents 4.3.9 → 4.3.11
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 +39 -0
- package/README.md +136 -166
- package/agents/artisan.md +7 -11
- package/agents/scout.md +7 -11
- package/agents/sentinel.md +7 -8
- package/agents/steward.md +8 -9
- package/index.ts +1 -1
- package/package.json +12 -10
- package/src/configuration/setup.ts +16 -14
- package/src/delegation/agents.ts +1 -1
- package/src/delegation/dispatch.ts +18 -23
- package/src/delegation/phase-scope.ts +7 -37
- package/src/delegation/prompt.ts +15 -32
- 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 +6 -8
- package/src/lifecycle/runtime.ts +23 -70
- package/src/lifecycle/thread-lifecycle.ts +56 -459
- package/src/lifecycle/thread-restore.ts +13 -35
- package/src/lifecycle/thread-shared.ts +5 -65
- package/src/lifecycle/tools.ts +87 -252
- 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
package/src/execution/rpc-run.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A child stays alive across prompt/abort operations and speaks
|
|
5
5
|
* strict LF-delimited JSONL. The process is terminated only after the logical
|
|
6
|
-
* run settles, is
|
|
7
|
-
*
|
|
6
|
+
* run settles, is stopped, or fails. Session files remain owned by the parent
|
|
7
|
+
* runtime for in-run model fallback and manual recovery.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
@@ -13,6 +13,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
13
13
|
import { basename, join } from "node:path";
|
|
14
14
|
import { StringDecoder } from "node:string_decoder";
|
|
15
15
|
import type { Message } from "@earendil-works/pi-ai";
|
|
16
|
+
import type { RpcCommand, RpcExtensionUIResponse, RpcResponse } from "@earendil-works/pi-coding-agent";
|
|
16
17
|
import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "../delegation/agents.ts";
|
|
17
18
|
import type { ThinkingLevel } from "../configuration/config.ts";
|
|
18
19
|
import { writeTempOwnerMarker } from "../isolation/temp-hygiene.ts";
|
|
@@ -36,8 +37,6 @@ export const RPC_COMMAND_TIMEOUT_MS = 30_000;
|
|
|
36
37
|
/** clear_queue is stop-path hygiene ahead of the abort: give it its own short
|
|
37
38
|
* budget so a hung response cannot eat into the abort-settle window. */
|
|
38
39
|
const RPC_CLEAR_QUEUE_TIMEOUT_MS = 2_000;
|
|
39
|
-
/** Keep logical stop responsive when a steering ACK is lost. */
|
|
40
|
-
const RPC_STEER_ACK_TIMEOUT_MS = 2_000;
|
|
41
40
|
/** Time allowed for the child to boot and answer get_state. */
|
|
42
41
|
export const RPC_READY_TIMEOUT_MS = 60_000;
|
|
43
42
|
export const RPC_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
|
@@ -197,15 +196,6 @@ async function writePromptToTempFile(
|
|
|
197
196
|
}
|
|
198
197
|
}
|
|
199
198
|
|
|
200
|
-
interface RpcResponse {
|
|
201
|
-
id?: string;
|
|
202
|
-
type: "response";
|
|
203
|
-
command: string;
|
|
204
|
-
success: boolean;
|
|
205
|
-
error?: string;
|
|
206
|
-
data?: unknown;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
199
|
interface RpcSessionUsage {
|
|
210
200
|
input: number;
|
|
211
201
|
output: number;
|
|
@@ -435,7 +425,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
435
425
|
const readyTimeoutMs = options.rpcReadyTimeoutMs ?? RPC_READY_TIMEOUT_MS;
|
|
436
426
|
const commandTimeoutMs = options.rpcCommandTimeoutMs ?? RPC_COMMAND_TIMEOUT_MS;
|
|
437
427
|
|
|
438
|
-
const writeLine = (value:
|
|
428
|
+
const writeLine = (value: RpcCommand | RpcExtensionUIResponse): Promise<void> =>
|
|
439
429
|
new Promise((resolve, reject) => {
|
|
440
430
|
if (!proc.stdin || proc.stdin.destroyed || !proc.stdin.writable) {
|
|
441
431
|
reject(new Error("Subagent RPC stdin is not writable."));
|
|
@@ -449,7 +439,10 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
449
439
|
});
|
|
450
440
|
});
|
|
451
441
|
|
|
452
|
-
const send = async <T extends
|
|
442
|
+
const send = async <T extends RpcCommand>(
|
|
443
|
+
command: T,
|
|
444
|
+
timeoutMs = commandTimeoutMs,
|
|
445
|
+
): Promise<Extract<RpcResponse, { command: T["type"]; success: true }>> => {
|
|
453
446
|
if (finished || closed) throw new Error("Subagent RPC process is no longer active.");
|
|
454
447
|
const id = `req_${++requestId}`;
|
|
455
448
|
const payload = { ...command, id };
|
|
@@ -471,7 +464,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
471
464
|
if (!response.success) {
|
|
472
465
|
throw new RpcCommandRejectedError(response.error || `RPC ${response.command} failed.`);
|
|
473
466
|
}
|
|
474
|
-
return response;
|
|
467
|
+
return response as Extract<RpcResponse, { command: T["type"]; success: true }>;
|
|
475
468
|
});
|
|
476
469
|
};
|
|
477
470
|
|
|
@@ -501,9 +494,8 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
501
494
|
const abortAcceptedPrompt = async (): Promise<boolean> => {
|
|
502
495
|
const acceptance = await initialPrompt.promise;
|
|
503
496
|
if (!acceptance.accepted) return false;
|
|
504
|
-
// Pi continues queued
|
|
505
|
-
//
|
|
506
|
-
// thread — cannot be revived by stale queue entries. Best-effort: an
|
|
497
|
+
// Pi continues queued messages after an abort. Drop them first so stale
|
|
498
|
+
// queue entries cannot revive a stopped run. Best-effort: an
|
|
507
499
|
// older child rejects the command and a hung child falls through to
|
|
508
500
|
// the bounded abort below.
|
|
509
501
|
try {
|
|
@@ -529,9 +521,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
529
521
|
};
|
|
530
522
|
|
|
531
523
|
const attemptControl: AttemptControl = {
|
|
532
|
-
async steer(command): Promise<void> {
|
|
533
|
-
await send(command, RPC_STEER_ACK_TIMEOUT_MS);
|
|
534
|
-
},
|
|
535
524
|
async stop(reason = "Subagent was aborted"): Promise<void> {
|
|
536
525
|
if (finished) {
|
|
537
526
|
if (!closed) await processClosed.promise;
|
|
@@ -693,8 +682,8 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
693
682
|
result.usage.contextTokens = usage.totalTokens || 0;
|
|
694
683
|
}
|
|
695
684
|
if (!result.model && (message as any).model) result.model = (message as any).model;
|
|
696
|
-
|
|
697
|
-
|
|
685
|
+
result.stopReason = message.stopReason;
|
|
686
|
+
result.errorMessage = message.errorMessage;
|
|
698
687
|
}
|
|
699
688
|
emit({ kind: "usage", usage: { ...result.usage }, model: result.model });
|
|
700
689
|
}
|
|
@@ -747,25 +736,35 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
747
736
|
finish();
|
|
748
737
|
});
|
|
749
738
|
|
|
750
|
-
proc.once("close", (code) => {
|
|
739
|
+
proc.once("close", (code, exitSignal) => {
|
|
751
740
|
closed = true;
|
|
752
|
-
|
|
741
|
+
const disposition = exitSignal ? `signal=${exitSignal}` : `code=${code}`;
|
|
742
|
+
resolveInitialPrompt(false, new Error(`Subagent RPC process exited before the initial prompt was accepted (${disposition}).`));
|
|
753
743
|
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
754
744
|
stdoutBuffer += stdoutDecoder.end();
|
|
755
745
|
result.stderr += stderrDecoder.end();
|
|
756
746
|
if (stdoutBuffer.length > 0) processLine(stdoutBuffer);
|
|
757
747
|
const exitError = new Error(
|
|
758
|
-
`Subagent RPC process exited before settling (
|
|
748
|
+
`Subagent RPC process exited before settling (${disposition}).${result.stderr ? ` ${result.stderr.trim()}` : ""}`,
|
|
759
749
|
);
|
|
760
750
|
rejectPending(exitError);
|
|
761
751
|
if (abortSettlement) {
|
|
762
752
|
abortSettlement.reject(exitError);
|
|
763
753
|
abortSettlement = undefined;
|
|
764
754
|
}
|
|
765
|
-
if (!finished) {
|
|
755
|
+
if (!finished && !usageSettlementStarted) {
|
|
756
|
+
const silentStartupExit = !result.rpcPromptDispatched && !result.rpcActivity
|
|
757
|
+
&& result.messages.length === 0 && !result.stderr.trim() && !result.errorMessage?.trim();
|
|
766
758
|
result.exitCode = code === 0 ? 1 : (code ?? 1);
|
|
767
|
-
result.stopReason
|
|
768
|
-
if (signal?.aborted)
|
|
759
|
+
result.stopReason = signal?.aborted ? "aborted" : "error";
|
|
760
|
+
if (signal?.aborted) {
|
|
761
|
+
result.errorMessage ||= "Subagent was aborted";
|
|
762
|
+
} else {
|
|
763
|
+
result.errorMessage = result.errorMessage?.trim()
|
|
764
|
+
? `${result.errorMessage}\n${exitError.message}` : exitError.message;
|
|
765
|
+
if (silentStartupExit) result.rpcStartupFailed = true;
|
|
766
|
+
else result.dispatchFailed = true;
|
|
767
|
+
}
|
|
769
768
|
finish();
|
|
770
769
|
}
|
|
771
770
|
processClosed.resolve();
|
package/src/execution/spawn.ts
CHANGED
|
@@ -290,7 +290,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
|
|
293
|
-
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity.
|
|
293
|
+
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. Main handles this phase; inspect launch diagnostics instead of redispatching it.`;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
@@ -334,20 +334,26 @@ async function waitForControlledRetry(
|
|
|
334
334
|
return !signal?.aborted && !control?.isStopRequested();
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
/** Runtime/assistant diagnostics, never an inference from individual failed tools. */
|
|
338
|
+
export function getResultError(result: SingleResult): string | undefined {
|
|
339
|
+
if (result.exitCode === -1 || !isFailedResult(result)) return undefined;
|
|
340
|
+
return result.errorMessage?.trim()
|
|
341
|
+
|| lastAssistantMessage(result.messages)?.errorMessage?.trim()
|
|
342
|
+
|| result.stderr.trim()
|
|
343
|
+
|| (result.stopReason === "aborted"
|
|
344
|
+
? "Subagent was aborted."
|
|
345
|
+
: `Subagent failed (exit code ${result.exitCode}${result.stopReason ? `, stop reason ${result.stopReason}` : ""}); no failure reason was recorded.`);
|
|
346
|
+
}
|
|
347
|
+
|
|
337
348
|
export function getResultOutput(result: SingleResult): string {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return error || partial || "(no output)";
|
|
343
|
-
}
|
|
344
|
-
return getFinalOutput(result.messages) || "(no output)";
|
|
349
|
+
const error = getResultError(result);
|
|
350
|
+
const output = getFinalOutput(result.messages);
|
|
351
|
+
if (error && output) return `${error}\n\n--- Partial output ---\n${output}`;
|
|
352
|
+
return error || output || "(no output)";
|
|
345
353
|
}
|
|
346
354
|
|
|
347
|
-
/**
|
|
348
|
-
*
|
|
349
|
-
* thread may return after main integrated sibling worktrees or edited the tree
|
|
350
|
-
* itself, so a file read in an earlier generation is not proof of its content. */
|
|
355
|
+
/** Model handoff preserves useful history, but main or siblings may have edited
|
|
356
|
+
* the workspace since the selected model last read it. */
|
|
351
357
|
const RESUME_CONTINUATION_RULES =
|
|
352
358
|
"Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Do not redo searches, reads, or edits that already succeeded. The workspace may have changed while this thread was inactive: before editing a file, re-read it unless you read it during this continuation. Finish with the result-only handoff your role requires.";
|
|
353
359
|
|
|
@@ -355,12 +361,6 @@ export function buildResumePrompt(task: string, reason: string): string {
|
|
|
355
361
|
return `You are resuming an earlier sub-agent session after ${reason}. ${RESUME_CONTINUATION_RULES} Current objective: ${task}. Pick up exactly where you left off and finish it. Continue now.`;
|
|
356
362
|
}
|
|
357
363
|
|
|
358
|
-
/** A resume with an appended objective continues the same thread: the new
|
|
359
|
-
* objective is guidance layered on retained context, not a restart. */
|
|
360
|
-
export function buildAppendedObjectivePrompt(previousTask: string, objective: string): string {
|
|
361
|
-
return `You are continuing an earlier sub-agent session with an appended objective from the parent. ${RESUME_CONTINUATION_RULES} Previous objective: ${previousTask}. Appended objective: ${objective}. Complete the appended objective on top of the work already done, without restarting from scratch. Continue now.`;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
364
|
/** Create a fresh private session directory under the given root. The owner
|
|
365
365
|
* marker is what lets a later load tell a session this process still owns from
|
|
366
366
|
* one a crash abandoned. */
|
|
@@ -553,10 +553,11 @@ export async function runSingleAgentWithMainFallback(
|
|
|
553
553
|
}
|
|
554
554
|
const delay = startupDelays[attempt];
|
|
555
555
|
if (delay === undefined) {
|
|
556
|
-
|
|
556
|
+
const reason = getResultError(lastResult);
|
|
557
|
+
lastResult.errorMessage = [formatStartupRetryExhaustedError(
|
|
557
558
|
lastResult.model ?? opts.agent.model ?? "default",
|
|
558
559
|
attempt + 1,
|
|
559
|
-
);
|
|
560
|
+
), reason].filter(Boolean).join("\n");
|
|
560
561
|
lastResult.stopReason ??= "error";
|
|
561
562
|
lastResult.dispatchFailed = true;
|
|
562
563
|
return lastResult;
|
|
@@ -5,17 +5,15 @@
|
|
|
5
5
|
* Two classes live there and both are swept the same way. Transient per-run
|
|
6
6
|
* files (child prompt copies, the no-retry policy extension) sit in `tmp/`;
|
|
7
7
|
* retained child sessions and isolated worktrees sit in `sessions/` and
|
|
8
|
-
* `worktrees/`, where they must outlive the process that made them
|
|
9
|
-
*
|
|
8
|
+
* `worktrees/`, where they must outlive the process that made them for manual
|
|
9
|
+
* recovery after reload. Every mkdtemp directory gets an owner marker with the
|
|
10
10
|
* creating pid. At extension load, directories whose owner is dead are removed;
|
|
11
11
|
* unmarked leftovers fall back to an age cap.
|
|
12
12
|
*
|
|
13
|
-
* Ownership
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
13
|
+
* Ownership keeps a live parent's retained sessions and worktrees intact even
|
|
14
|
+
* when no manifest record claims them. Callers sweeping durable roots additionally
|
|
15
|
+
* pass the paths their thread and recovery manifests still reference, so interrupted
|
|
16
|
+
* and retained work survives even when its owner is gone.
|
|
19
17
|
*
|
|
20
18
|
* A live sibling pi instance never loses its directories: `kill(pid, 0)` only
|
|
21
19
|
* reports "no such process" when the pid genuinely does not exist, so a live
|
|
@@ -36,7 +34,7 @@ export const TEMP_OWNER_FILE_NAME = "owner.json";
|
|
|
36
34
|
const TEMP_DIR_PREFIXES = ["pi-subagents-"] as const;
|
|
37
35
|
|
|
38
36
|
/** Durable directories created under `<project>/sessions` and
|
|
39
|
-
* `<project>/worktrees`: retained child sessions
|
|
37
|
+
* `<project>/worktrees`: retained child sessions and
|
|
40
38
|
* isolated worktree groups. */
|
|
41
39
|
const DURABLE_DIR_PREFIXES = ["pi-subagent-session-", "pi-subagent-worktree-"] as const;
|
|
42
40
|
|
|
@@ -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
|
@@ -43,9 +43,8 @@ const THREADS_MANIFEST_VERSION = 1;
|
|
|
43
43
|
* win over the age rule. */
|
|
44
44
|
export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
*
|
|
48
|
-
* 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. */
|
|
49
48
|
export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
50
49
|
|
|
51
50
|
/** Result excerpts are for status display after restore, not full transcripts. */
|
|
@@ -84,7 +83,7 @@ export interface ThreadRecord {
|
|
|
84
83
|
executionCwd: string;
|
|
85
84
|
/** Resolved (clamped) level of the last generation. */
|
|
86
85
|
thinkingLevel?: string;
|
|
87
|
-
/**
|
|
86
|
+
/** Originally requested level, retained with the recovery metadata. */
|
|
88
87
|
requestedThinkingLevel?: string;
|
|
89
88
|
isolation: IsolationMode;
|
|
90
89
|
state: "parked" | "completed" | "failed";
|
|
@@ -106,9 +105,8 @@ export function currentBootId(now = Date.now()): number {
|
|
|
106
105
|
/** Whether a record's `childPids` can still name processes of this boot. Pids
|
|
107
106
|
* are only unique within a boot: after a restart the same number belongs to
|
|
108
107
|
* whatever claimed it, so restore must not signal them. Records written before
|
|
109
|
-
* this field existed carry no boot id and count as unverifiable
|
|
110
|
-
* stray child alive
|
|
111
|
-
* 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. */
|
|
112
110
|
export function isCurrentBoot(record: ThreadRecord, now = Date.now()): boolean {
|
|
113
111
|
if (record.bootId === undefined) return false;
|
|
114
112
|
return Math.abs(record.bootId - currentBootId(now)) <= BOOT_ID_TOLERANCE_MS;
|
|
@@ -375,7 +373,7 @@ function summarizeResult(result: SingleResult): ThreadResultSummary | undefined
|
|
|
375
373
|
|
|
376
374
|
/** Project a live thread into its durable record. Only handles whose
|
|
377
375
|
* filesystem is still meaningful are persisted; finalized-and-removed
|
|
378
|
-
* worktrees keep
|
|
376
|
+
* worktrees keep their checkpoint commit as recovery evidence. */
|
|
379
377
|
export function threadRecordFromThread(
|
|
380
378
|
thread: SubagentThread,
|
|
381
379
|
state: "parked" | "completed" | "failed",
|