@ferris1225/pi-subagents 4.1.8 → 4.1.9
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 +82 -51
- package/agents/cleaner.md +13 -14
- package/agents/documenter.md +10 -17
- package/agents/explorer.md +6 -16
- package/agents/reviewer.md +28 -29
- package/agents/worker.md +14 -33
- package/package.json +1 -1
- package/src/announcements.ts +8 -0
- package/src/background.ts +21 -3
- package/src/dispatch.ts +721 -746
- package/src/durable.ts +336 -0
- package/src/fixloop.ts +30 -34
- package/src/format.ts +1 -8
- package/src/index.ts +7 -0
- package/src/monitor.ts +28 -29
- package/src/prompt.ts +4 -4
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +69 -44
- package/src/session-fork.ts +7 -2
- package/src/spawn.ts +31 -28
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1324
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/worktree.ts +144 -4
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Thread controls and lookup tools around the subagent runtime:
|
|
3
|
-
* subagent_control (
|
|
3
|
+
* subagent_control (resume), subagent_wait (in-turn
|
|
4
4
|
* result lookup), subagent_status, and destructive subagent_stop.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -10,6 +10,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
|
|
13
|
+
import { removeThreadRecord } from "./durable.ts";
|
|
13
14
|
import { formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
|
|
14
15
|
import { emptyUsage } from "./rpc-run.ts";
|
|
15
16
|
import {
|
|
@@ -46,15 +47,12 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
|
|
|
46
47
|
|
|
47
48
|
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
48
49
|
const SubagentControlParams = Type.Object({
|
|
49
|
-
action: StringEnum(["
|
|
50
|
+
action: StringEnum(["resume"] as const, {
|
|
50
51
|
description: "Control operation for the logical sub-agent thread.",
|
|
51
52
|
}),
|
|
52
53
|
id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch/status output." }),
|
|
53
|
-
instruction: Type.Optional(
|
|
54
|
-
Type.String({ description: "Instruction queued by steer after the current child tool batch." }),
|
|
55
|
-
),
|
|
56
54
|
objective: Type.Optional(
|
|
57
|
-
Type.String({ description: "
|
|
55
|
+
Type.String({ description: "Optional appended objective for resume. Omit to continue the current retained objective." }),
|
|
58
56
|
),
|
|
59
57
|
});
|
|
60
58
|
|
|
@@ -62,21 +60,14 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
62
60
|
name: "subagent_control",
|
|
63
61
|
label: "Subagent Control",
|
|
64
62
|
description: [
|
|
65
|
-
"
|
|
66
|
-
"steer queues an instruction after the current tool batch while the top-level RPC child is active.",
|
|
67
|
-
"retarget replaces the objective in that same active top-level child.",
|
|
68
|
-
"Managed downstream documenter/reviewer/fix stages are controlled by the parent queue rather than its settled RPC control: use park or stop there, then resume with an objective to redirect retained context.",
|
|
69
|
-
"park aborts to a stable checkpoint, terminates the child, preserves context, and releases its concurrency slot.",
|
|
63
|
+
"Resume an existing sub-agent thread by stable run id.",
|
|
70
64
|
"resume restarts a parked, completed, or failed retained thread with the same run id and cumulative active time; omit objective to continue the current goal, or provide one to append it to retained context and make it the displayed current goal.",
|
|
71
|
-
"
|
|
65
|
+
"Threads parked or interrupted by a shutdown/reload are restorable; use subagent_stop for destructive cancellation.",
|
|
72
66
|
].join(" "),
|
|
73
|
-
promptSnippet: "
|
|
67
|
+
promptSnippet: "Resume a parked or settled subagent thread with its retained context.",
|
|
74
68
|
promptGuidelines: [
|
|
75
|
-
"Use subagent_control
|
|
76
|
-
"Use
|
|
77
|
-
"Use subagent_control park to checkpoint useful context while releasing the process/concurrency slot, and resume to continue the same run id later. Resume without objective keeps the current goal; resume with objective appends that goal to retained context.",
|
|
78
|
-
"Use subagent_control fork only on a parked or settled retained thread; isolated work must settle and integrate before it can fork. Fork creates a new run id while leaving the source untouched.",
|
|
79
|
-
"Use subagent_stop only for destructive cancellation; it retires that thread's retained session without retiring independent forks.",
|
|
69
|
+
"Use subagent_control resume to continue a parked/settled thread on the same run id. Resume without objective keeps the current goal; resume with objective appends that goal to retained context.",
|
|
70
|
+
"Use subagent_stop only for destructive cancellation; it retires that thread's retained session.",
|
|
80
71
|
],
|
|
81
72
|
parameters: SubagentControlParams,
|
|
82
73
|
|
|
@@ -92,54 +83,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
92
83
|
|
|
93
84
|
try {
|
|
94
85
|
switch (params.action) {
|
|
95
|
-
case "steer": {
|
|
96
|
-
const instruction = nonBlank(params.instruction);
|
|
97
|
-
if (!instruction) {
|
|
98
|
-
return { content: [{ type: "text", text: "steer requires a non-blank instruction." }], details: {} };
|
|
99
|
-
}
|
|
100
|
-
if (!(["running", "steering"] as const).includes(thread.control.getPhase() as any)) {
|
|
101
|
-
return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.control.getPhase()}; only a running thread can be steered.` }], details: {} };
|
|
102
|
-
}
|
|
103
|
-
await thread.control.steer(instruction);
|
|
104
|
-
return { content: [{ type: "text", text: `Queued steering instruction for run #${thread.id} after its current tool batch.` }], details: {} };
|
|
105
|
-
}
|
|
106
|
-
case "retarget": {
|
|
107
|
-
const objective = nonBlank(params.objective);
|
|
108
|
-
if (!objective) {
|
|
109
|
-
return { content: [{ type: "text", text: "retarget requires a non-blank objective." }], details: {} };
|
|
110
|
-
}
|
|
111
|
-
const phase = thread.control.getPhase();
|
|
112
|
-
if (thread.state === "queued" && phase === "queued") {
|
|
113
|
-
thread.task = objective;
|
|
114
|
-
thread.control.retargetPending(objective);
|
|
115
|
-
monitor.setTask(thread.id, objective);
|
|
116
|
-
monitor.setContinuationKind(thread.id, "retarget");
|
|
117
|
-
return { content: [{ type: "text", text: `Updated queued run #${thread.id} to the replacement objective; no child was spawned by this control action.` }], details: {} };
|
|
118
|
-
}
|
|
119
|
-
if (!(["starting", "running", "steering", "interrupting", "retrying"] as const).includes(phase as any)) {
|
|
120
|
-
return { content: [{ type: "text", text: `Run #${thread.id} is ${thread.state}; use resume with objective to restart retained context.` }], details: {} };
|
|
121
|
-
}
|
|
122
|
-
thread.task = objective;
|
|
123
|
-
monitor.setTask(thread.id, objective);
|
|
124
|
-
monitor.setContinuationKind(thread.id, "retarget");
|
|
125
|
-
await thread.control.retarget(objective);
|
|
126
|
-
return { content: [{ type: "text", text: `Retargeted run #${thread.id} in the same session; the aborted objective will not be delivered as a completion.` }], details: {} };
|
|
127
|
-
}
|
|
128
|
-
case "park": {
|
|
129
|
-
if (thread.state === "parked") {
|
|
130
|
-
return { content: [{ type: "text", text: `Run #${thread.id} is already parked.` }], details: {} };
|
|
131
|
-
}
|
|
132
|
-
const disposition = await thread.park();
|
|
133
|
-
return disposition === "queued"
|
|
134
|
-
? {
|
|
135
|
-
content: [{ type: "text", text: `Parked queued run #${thread.id}; it never spawned a child or empty session.` }],
|
|
136
|
-
details: {},
|
|
137
|
-
}
|
|
138
|
-
: {
|
|
139
|
-
content: [{ type: "text", text: `Parked run #${thread.id} at a stable checkpoint; its session is retained and concurrency slot released.` }],
|
|
140
|
-
details: {},
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
86
|
case "resume": {
|
|
144
87
|
if (thread.retired) {
|
|
145
88
|
return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and has no resumable session.` }], details: {} };
|
|
@@ -165,24 +108,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
165
108
|
: "no prior child session existed, so only the logical run and objective are continued";
|
|
166
109
|
return { content: [{ type: "text", text: `Resumed run #${thread.id}, ${mode}; ${context}, and cumulative active time is preserved. Completion will arrive automatically.` }], details: {}, terminate: true };
|
|
167
110
|
}
|
|
168
|
-
case "fork": {
|
|
169
|
-
const objective = params.objective === undefined ? undefined : nonBlank(params.objective);
|
|
170
|
-
if (params.objective !== undefined && !objective) {
|
|
171
|
-
return { content: [{ type: "text", text: "fork objective must be non-blank when provided." }], details: {} };
|
|
172
|
-
}
|
|
173
|
-
const pending = await thread.fork(objective, ctx);
|
|
174
|
-
if (pending.exitCode !== -1 || pending.runId === undefined) {
|
|
175
|
-
return { content: [{ type: "text", text: getResultOutput(pending) }], details: {} };
|
|
176
|
-
}
|
|
177
|
-
return {
|
|
178
|
-
content: [{
|
|
179
|
-
type: "text",
|
|
180
|
-
text: `Forked run #${thread.id} into new run #${pending.runId}; ${objective ? `appended branch objective: ${formatTaskSummary(objective, 80, false)}` : `continuing current objective: ${formatTaskSummary(thread.task, 80, false)}`}. Retained context is copied, the source is unchanged, and child completion will arrive automatically.`,
|
|
181
|
-
}],
|
|
182
|
-
details: { sourceRunId: thread.id, childRunId: pending.runId, result: pending },
|
|
183
|
-
terminate: true,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
111
|
}
|
|
187
112
|
} catch (error) {
|
|
188
113
|
throw new Error(`Could not ${params.action} run #${thread.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -431,8 +356,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
431
356
|
: undefined;
|
|
432
357
|
const metadata = [
|
|
433
358
|
activeThread?.isolation === "worktree" ? `worktree ${active.integrationStatus ?? activeThread.worktree?.state ?? "active"}` : undefined,
|
|
434
|
-
activeThread?.forkedFromRunId !== undefined ? `forked from #${activeThread.forkedFromRunId}` : undefined,
|
|
435
|
-
(activeThread?.forkChildRunIds.length ?? 0) > 0 ? `forks ${activeThread!.forkChildRunIds.map((id) => `#${id}`).join(",")}` : undefined,
|
|
436
359
|
].filter(Boolean).join(" · ");
|
|
437
360
|
const stageStatus = activeChild
|
|
438
361
|
? monitor.summarize(activeChild)
|
|
@@ -444,8 +367,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
444
367
|
text: parked
|
|
445
368
|
? `Run #${active.id} ${owner} is parked with retained${retainedStage ? ` ${retainedStage} stage` : ""} context${metadata ? ` (${metadata})` : ""}. Use subagent_control resume to restart it, or subagent_stop to retire it.`
|
|
446
369
|
: managedDownstream
|
|
447
|
-
? `Run #${active.id} ${owner} is in a managed downstream stage (${stageStatus}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait for its result
|
|
448
|
-
: `Run #${active.id} ${owner} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result
|
|
370
|
+
? `Run #${active.id} ${owner} is in a managed downstream stage (${stageStatus}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait for its result or subagent_stop to cancel it.`
|
|
371
|
+
: `Run #${active.id} ${owner} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result or subagent_stop to cancel it.`,
|
|
449
372
|
},
|
|
450
373
|
],
|
|
451
374
|
details: {},
|
|
@@ -462,8 +385,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
462
385
|
const parts = [
|
|
463
386
|
`#${run.id} ${monitor.summarize(run)}`,
|
|
464
387
|
run.label,
|
|
465
|
-
thread?.forkedFromRunId !== undefined ? `forked from #${thread.forkedFromRunId}` : undefined,
|
|
466
|
-
(thread?.forkChildRunIds.length ?? 0) > 0 ? `forks ${thread!.forkChildRunIds.map((id) => `#${id}`).join(",")}` : undefined,
|
|
467
388
|
run.activity ?? statusLabel(run.status),
|
|
468
389
|
].filter(Boolean);
|
|
469
390
|
return `- ${parts.join(" · ")}`;
|
|
@@ -475,13 +396,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
475
396
|
const retainedStage = run?.managedWorkflow && thread.agentName !== run.agent
|
|
476
397
|
? ` · retained stage ${thread.agentName}`
|
|
477
398
|
: "";
|
|
478
|
-
const relations = [
|
|
479
|
-
thread.forkedFromRunId !== undefined ? `forked from #${thread.forkedFromRunId}` : undefined,
|
|
480
|
-
thread.forkChildRunIds.length > 0 ? `forks ${thread.forkChildRunIds.map((id) => `#${id}`).join(",")}` : undefined,
|
|
481
|
-
].filter(Boolean);
|
|
482
|
-
const relation = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
|
|
483
399
|
const isolation = thread.isolation === "worktree" ? ` · worktree ${thread.worktree?.state ?? "active"}` : "";
|
|
484
|
-
return `- #${thread.id} ${owner} · ${run?.label ?? runLabel(thread.task)} · parked${thread.sessionDir ? " · context retained" : " · not started"}${retainedStage}${isolation}
|
|
400
|
+
return `- #${thread.id} ${owner} · ${run?.label ?? runLabel(thread.task)} · parked${thread.sessionDir ? " · context retained" : " · not started"}${retainedStage}${isolation}`;
|
|
485
401
|
});
|
|
486
402
|
const completed = [...runtime.settledRuns.entries()].slice(-5);
|
|
487
403
|
const completedLines = completed.map(([id, result]) => {
|
|
@@ -491,12 +407,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
491
407
|
? `${result.model ?? "?"} (main after ${result.modelFallbackFrom} failed)`
|
|
492
408
|
: (result.model ?? "?");
|
|
493
409
|
const isolation = result.isolation === "worktree" ? ` · worktree ${result.integrationStatus ?? "unknown"}` : "";
|
|
494
|
-
|
|
495
|
-
result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
|
|
496
|
-
(result.forkChildRunIds?.length ?? 0) > 0 ? `forks ${result.forkChildRunIds!.map((childId) => `#${childId}`).join(",")}` : undefined,
|
|
497
|
-
].filter(Boolean);
|
|
498
|
-
const relation = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
|
|
499
|
-
return `- #${id} ${result.agent}${label ? ` · ${label}` : ""} · ${isFailedResult(result) ? "failed" : "completed"} · ${model}${isolation}${relation}${usage ? ` · ${usage}` : ""}`;
|
|
410
|
+
return `- #${id} ${result.agent}${label ? ` · ${label}` : ""} · ${isFailedResult(result) ? "failed" : "completed"} · ${model}${isolation}${usage ? ` · ${usage}` : ""}`;
|
|
500
411
|
});
|
|
501
412
|
|
|
502
413
|
const sections: string[] = [];
|
|
@@ -506,7 +417,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
506
417
|
sections.push(parkedLines.length > 0 ? parkedLines.join("\n") : "(none)");
|
|
507
418
|
sections.push(`### Finished this session (${runtime.settledRuns.size})`);
|
|
508
419
|
sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
|
|
509
|
-
sections.push("Pass a run id to subagent_status for the full result, use subagent_control to
|
|
420
|
+
sections.push("Pass a run id to subagent_status for the full result, use subagent_control to resume a settled thread, or subagent_wait for active work.");
|
|
510
421
|
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
511
422
|
},
|
|
512
423
|
|
|
@@ -551,7 +462,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
551
462
|
|
|
552
463
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
553
464
|
// Start config I/O without yielding: every target below must be claimed
|
|
554
|
-
// synchronously before a resume
|
|
465
|
+
// synchronously before a resume preflight can cross its next await.
|
|
555
466
|
const configPromise = loadConfig(runtime.configPath).catch(() => undefined);
|
|
556
467
|
const completionResults: SingleResult[] = [];
|
|
557
468
|
const candidateIds = params.all === true
|
|
@@ -560,7 +471,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
560
471
|
...[...runtime.threads.values()]
|
|
561
472
|
.filter((thread) =>
|
|
562
473
|
thread.lifecycleOperation !== undefined ||
|
|
563
|
-
["queued", "resuming", "running", "
|
|
474
|
+
["queued", "resuming", "running", "interrupting"].includes(thread.state),
|
|
564
475
|
)
|
|
565
476
|
.map((thread) => thread.id),
|
|
566
477
|
])]
|
|
@@ -607,10 +518,10 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
607
518
|
const wasResuming = previousState === "resuming";
|
|
608
519
|
const wasActive =
|
|
609
520
|
thread.lifecycleOperation !== undefined ||
|
|
610
|
-
["queued", "resuming", "running", "
|
|
521
|
+
["queued", "resuming", "running", "interrupting"].includes(previousState);
|
|
611
522
|
const stopVersion = ++thread.lifecycleVersion;
|
|
612
523
|
// Stop-all claims every target before the first await. This invalidates
|
|
613
|
-
// all concurrent resume
|
|
524
|
+
// all concurrent resume preflights as one synchronous operation.
|
|
614
525
|
thread.lifecycleOperation = "stop";
|
|
615
526
|
thread.retired = true;
|
|
616
527
|
thread.retireOnSettle = true;
|
|
@@ -692,7 +603,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
692
603
|
stoppedResult = prior
|
|
693
604
|
? {
|
|
694
605
|
...prior,
|
|
695
|
-
parked: undefined,
|
|
696
606
|
exitCode: 1,
|
|
697
607
|
stopReason: "aborted",
|
|
698
608
|
errorMessage: stopMessage,
|
|
@@ -756,6 +666,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
756
666
|
if (stoppedResult) completionResults.push(stoppedResult);
|
|
757
667
|
monitor.removeRun(runId);
|
|
758
668
|
runtime.retireThreadSession(thread);
|
|
669
|
+
// The destructive retire removes the durable record with the session;
|
|
670
|
+
// an id never resurrects after subagent_stop.
|
|
671
|
+
await removeThreadRecord(runtime.configPath, runId).catch(() => undefined);
|
|
759
672
|
if (thread.lifecycleVersion === stopVersion && thread.lifecycleOperation === "stop") {
|
|
760
673
|
thread.lifecycleOperation = undefined;
|
|
761
674
|
}
|
package/src/widget.ts
CHANGED
|
@@ -98,7 +98,7 @@ function runPrimaryLine(
|
|
|
98
98
|
// A chain child shows its role in the chain plus a task-derived label; the
|
|
99
99
|
// templated fix brief itself would only repeat the parent review's content.
|
|
100
100
|
const continuation = run.parentRunId === undefined
|
|
101
|
-
? continuationLabel(run.continuationKind
|
|
101
|
+
? continuationLabel(run.continuationKind)
|
|
102
102
|
: undefined;
|
|
103
103
|
const taskSource = run.parentRunId !== undefined
|
|
104
104
|
? [run.relationLabel, run.label].filter((part): part is string => Boolean(part)).join(" · ")
|
|
@@ -215,8 +215,8 @@ function runActivityLine(run: RunView, theme: Theme, width: number, indent: stri
|
|
|
215
215
|
|
|
216
216
|
/** Render active runs as compact workflow-aware trees. Stable managed parents
|
|
217
217
|
* retain their stage timeline while the current internal child supplies exact
|
|
218
|
-
* model/thinking/activity telemetry.
|
|
219
|
-
*
|
|
218
|
+
* model/thinking/activity telemetry. Control ids remain available through
|
|
219
|
+
* status. */
|
|
220
220
|
export function formatActiveRunLines(
|
|
221
221
|
runs: readonly RunView[],
|
|
222
222
|
theme: Theme,
|
package/src/worktree.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Detached Git worktree isolation for write-capable sub-agents.
|
|
3
3
|
*
|
|
4
4
|
* A handle is created before a child is queued and stays owned by the logical
|
|
5
|
-
* thread across retries, model candidates,
|
|
5
|
+
* thread across retries, model candidates, and resumes. 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.
|
|
@@ -19,7 +19,7 @@ export type IsolationMode = "shared" | "worktree";
|
|
|
19
19
|
const WORKTREE_TEMP_DIR_PREFIX = "pi-subagent-worktree-";
|
|
20
20
|
|
|
21
21
|
/** Short stable identity of one isolated worktree group (the mkdtemp suffix).
|
|
22
|
-
* Continuation
|
|
22
|
+
* Continuation generations create a fresh worktree, so the identity
|
|
23
23
|
* visibly changes when the group's filesystem boundary changes. */
|
|
24
24
|
export function worktreeGroupId(worktree: Pick<WorktreeIsolation, "tempDir">): string {
|
|
25
25
|
const base = worktree.tempDir.split(/[\\/]/).filter(Boolean).pop() ?? worktree.tempDir;
|
|
@@ -213,6 +213,28 @@ export interface WorktreeCheckpoint {
|
|
|
213
213
|
patch: Buffer;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
export interface WorktreeCheckpointRef {
|
|
217
|
+
baseHead: string;
|
|
218
|
+
commit: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Persistable projection of one worktree handle: enough to rebuild the
|
|
222
|
+
* handle after a reload or restart. Patch bytes are deliberately omitted —
|
|
223
|
+
* only the checkpoint commit, which lives in the shared repository object
|
|
224
|
+
* store, is needed to seed a continuation. */
|
|
225
|
+
export interface WorktreeSnapshot {
|
|
226
|
+
originalCwd: string;
|
|
227
|
+
originalRoot: string;
|
|
228
|
+
cwd: string;
|
|
229
|
+
worktreePath: string;
|
|
230
|
+
tempDir: string;
|
|
231
|
+
patchPath: string;
|
|
232
|
+
head: string;
|
|
233
|
+
integrationBaseHead: string;
|
|
234
|
+
state: "active" | "retained" | "integrated" | "no_changes";
|
|
235
|
+
checkpoint?: WorktreeCheckpointRef;
|
|
236
|
+
}
|
|
237
|
+
|
|
216
238
|
export interface WorktreeCreateOptions {
|
|
217
239
|
runner?: CommandRunner;
|
|
218
240
|
/** Test hook; production uses the OS temp directory. */
|
|
@@ -244,7 +266,12 @@ export interface WorktreeIsolation {
|
|
|
244
266
|
readonly tempDir: string;
|
|
245
267
|
readonly patchPath: string;
|
|
246
268
|
readonly head: string;
|
|
269
|
+
/** Diff base for final integration; a continuation baseline commit when
|
|
270
|
+
* the generation was seeded with already-integrated work. */
|
|
271
|
+
readonly integrationBaseHead: string;
|
|
247
272
|
readonly state: "active" | "finalizing" | WorktreeFinalizationStatus;
|
|
273
|
+
/** Checkpoint retained after finalization for continuation resumes. */
|
|
274
|
+
getContinuationCheckpoint(): WorktreeCheckpoint | undefined;
|
|
248
275
|
/** Capture the complete isolated filesystem state for a fresh continuation.
|
|
249
276
|
* The synthetic commit lets Git merge an already-committed seed without
|
|
250
277
|
* attempting to apply the same patch twice. */
|
|
@@ -410,13 +437,26 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
410
437
|
private readonly runner: CommandRunner,
|
|
411
438
|
/** May be a synthetic tree commit representing a seed that the parent
|
|
412
439
|
* checkout already contains. Finalization then integrates only new edits. */
|
|
413
|
-
|
|
414
|
-
|
|
440
|
+
readonly integrationBaseHead: string = head,
|
|
441
|
+
restored?: {
|
|
442
|
+
state: WorktreeIsolation["state"];
|
|
443
|
+
checkpoint?: WorktreeCheckpoint;
|
|
444
|
+
},
|
|
445
|
+
) {
|
|
446
|
+
if (restored) {
|
|
447
|
+
this.currentState = restored.state;
|
|
448
|
+
if (restored.checkpoint) this.continuationCheckpoint = cloneCheckpoint(restored.checkpoint);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
415
451
|
|
|
416
452
|
get state(): WorktreeIsolation["state"] {
|
|
417
453
|
return this.currentState;
|
|
418
454
|
}
|
|
419
455
|
|
|
456
|
+
getContinuationCheckpoint(): WorktreeCheckpoint | undefined {
|
|
457
|
+
return this.continuationCheckpoint ? cloneCheckpoint(this.continuationCheckpoint) : undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
420
460
|
async snapshotCheckpoint(): Promise<WorktreeCheckpoint> {
|
|
421
461
|
if (this.continuationCheckpoint) return cloneCheckpoint(this.continuationCheckpoint);
|
|
422
462
|
if (this.currentState === "no_changes") {
|
|
@@ -720,3 +760,103 @@ export async function createWorktreeIsolation(
|
|
|
720
760
|
);
|
|
721
761
|
}
|
|
722
762
|
}
|
|
763
|
+
|
|
764
|
+
/** Snapshot only states whose filesystem or repository objects still exist.
|
|
765
|
+
* Transient (`finalizing`) and discarded handles are not persistable. */
|
|
766
|
+
export function worktreeSnapshot(worktree: WorktreeIsolation): WorktreeSnapshot | undefined {
|
|
767
|
+
const state = worktree.state;
|
|
768
|
+
if (state !== "active" && state !== "retained" && state !== "integrated" && state !== "no_changes") {
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
const checkpoint = worktree.getContinuationCheckpoint();
|
|
772
|
+
return {
|
|
773
|
+
originalCwd: worktree.originalCwd,
|
|
774
|
+
originalRoot: worktree.originalRoot,
|
|
775
|
+
cwd: worktree.cwd,
|
|
776
|
+
worktreePath: worktree.worktreePath,
|
|
777
|
+
tempDir: worktree.tempDir,
|
|
778
|
+
patchPath: worktree.patchPath,
|
|
779
|
+
head: worktree.head,
|
|
780
|
+
integrationBaseHead: worktree.integrationBaseHead,
|
|
781
|
+
state,
|
|
782
|
+
...(checkpoint && checkpoint.patch.length > 0
|
|
783
|
+
? { checkpoint: { baseHead: checkpoint.baseHead, commit: checkpoint.commit } }
|
|
784
|
+
: {}),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Validate an untrusted persisted snapshot; null when it is unusable. */
|
|
789
|
+
export function normalizeWorktreeSnapshot(value: unknown): WorktreeSnapshot | null {
|
|
790
|
+
if (!value || typeof value !== "object") return null;
|
|
791
|
+
const raw = value as Record<string, unknown>;
|
|
792
|
+
const fields: Record<string, string> = {};
|
|
793
|
+
for (const key of [
|
|
794
|
+
"originalCwd",
|
|
795
|
+
"originalRoot",
|
|
796
|
+
"cwd",
|
|
797
|
+
"worktreePath",
|
|
798
|
+
"tempDir",
|
|
799
|
+
"patchPath",
|
|
800
|
+
"head",
|
|
801
|
+
"integrationBaseHead",
|
|
802
|
+
] as const) {
|
|
803
|
+
if (typeof raw[key] !== "string" || !raw[key]) return null;
|
|
804
|
+
fields[key] = raw[key] as string;
|
|
805
|
+
}
|
|
806
|
+
if (raw.state !== "active" && raw.state !== "retained" && raw.state !== "integrated" && raw.state !== "no_changes") {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
let checkpoint: WorktreeCheckpointRef | undefined;
|
|
810
|
+
if (raw.checkpoint && typeof raw.checkpoint === "object") {
|
|
811
|
+
const rawCheckpoint = raw.checkpoint as Record<string, unknown>;
|
|
812
|
+
if (typeof rawCheckpoint.baseHead !== "string" || !rawCheckpoint.baseHead) return null;
|
|
813
|
+
if (typeof rawCheckpoint.commit !== "string" || !rawCheckpoint.commit) return null;
|
|
814
|
+
checkpoint = { baseHead: rawCheckpoint.baseHead, commit: rawCheckpoint.commit };
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
originalCwd: fields.originalCwd!,
|
|
818
|
+
originalRoot: fields.originalRoot!,
|
|
819
|
+
cwd: fields.cwd!,
|
|
820
|
+
worktreePath: fields.worktreePath!,
|
|
821
|
+
tempDir: fields.tempDir!,
|
|
822
|
+
patchPath: fields.patchPath!,
|
|
823
|
+
head: fields.head!,
|
|
824
|
+
integrationBaseHead: fields.integrationBaseHead!,
|
|
825
|
+
state: raw.state,
|
|
826
|
+
...(checkpoint ? { checkpoint } : {}),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Rebuild a handle from a persisted snapshot. Returns undefined when the
|
|
831
|
+
* on-disk worktree that an active/retained snapshot promises is gone; settled
|
|
832
|
+
* states (integrated/no_changes) intentionally need no filesystem. The
|
|
833
|
+
* restored checkpoint carries no patch bytes — only its commit is consumed by
|
|
834
|
+
* continuation seeds. */
|
|
835
|
+
export async function restoreWorktreeIsolation(
|
|
836
|
+
snapshot: WorktreeSnapshot,
|
|
837
|
+
options: { runner?: CommandRunner } = {},
|
|
838
|
+
): Promise<WorktreeIsolation | undefined> {
|
|
839
|
+
if (
|
|
840
|
+
(snapshot.state === "active" || snapshot.state === "retained") &&
|
|
841
|
+
!existsSync(snapshot.worktreePath)
|
|
842
|
+
) {
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
return new GitWorktreeIsolation(
|
|
846
|
+
snapshot.originalCwd,
|
|
847
|
+
snapshot.originalRoot,
|
|
848
|
+
snapshot.cwd,
|
|
849
|
+
snapshot.worktreePath,
|
|
850
|
+
snapshot.tempDir,
|
|
851
|
+
snapshot.patchPath,
|
|
852
|
+
snapshot.head,
|
|
853
|
+
options.runner ?? runCommand,
|
|
854
|
+
snapshot.integrationBaseHead,
|
|
855
|
+
{
|
|
856
|
+
state: snapshot.state,
|
|
857
|
+
...(snapshot.checkpoint
|
|
858
|
+
? { checkpoint: { ...snapshot.checkpoint, patch: Buffer.alloc(0) } }
|
|
859
|
+
: {}),
|
|
860
|
+
},
|
|
861
|
+
);
|
|
862
|
+
}
|