@ferris1225/pi-subagents 4.1.23 → 4.2.0
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 +57 -114
- package/agents/executor.md +53 -0
- package/package.json +1 -1
- package/src/agents.ts +2 -2
- package/src/announcements.ts +0 -26
- package/src/completion.ts +0 -11
- package/src/config.ts +51 -13
- package/src/dispatch.ts +16 -310
- package/src/durable.ts +0 -5
- package/src/format.ts +0 -8
- package/src/monitor.ts +18 -143
- package/src/prompt.ts +7 -30
- package/src/recovery.ts +145 -145
- package/src/runtime.ts +3 -10
- package/src/setup.ts +13 -21
- package/src/spawn.ts +0 -10
- package/src/thread-lifecycle.ts +51 -219
- package/src/widget.ts +63 -274
- package/agents/cleaner.md +0 -50
- package/agents/documenter.md +0 -40
- package/agents/reviewer.md +0 -82
- package/agents/synthesizer.md +0 -39
- package/agents/worker.md +0 -43
- package/src/workflow.ts +0 -215
package/src/monitor.ts
CHANGED
|
@@ -20,7 +20,6 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
|
20
20
|
|
|
21
21
|
export type RunStatus = "queued" | "running" | "interrupting" | "parked" | "done" | "failed";
|
|
22
22
|
export type ContinuationKind = "resume-retained" | "resume-appended";
|
|
23
|
-
export type WorkflowStageStatus = "done" | "active" | "pending" | "changes" | "failed";
|
|
24
23
|
|
|
25
24
|
/** Why a queued run has produced no output yet. Three genuinely different
|
|
26
25
|
* situations used to be reported as one "queued": waiting for a free process
|
|
@@ -32,19 +31,6 @@ export type WorkflowStageStatus = "done" | "active" | "pending" | "changes" | "f
|
|
|
32
31
|
* "queued"; cleared on every transition out of it. */
|
|
33
32
|
export type RunWaitReason = "process-slot" | "repository-lane" | "starting";
|
|
34
33
|
|
|
35
|
-
/** Ephemeral projection of one real or currently planned managed stage. It is
|
|
36
|
-
* live monitor state only; durable results remain the per-run chain records. */
|
|
37
|
-
export interface WorkflowStage {
|
|
38
|
-
agent: string;
|
|
39
|
-
relation: string;
|
|
40
|
-
status: WorkflowStageStatus;
|
|
41
|
-
/** Telemetry snapshot frozen when the stage settled (the live child row
|
|
42
|
-
* leaves the monitor at that moment); the active stage reads its live child. */
|
|
43
|
-
model?: string;
|
|
44
|
-
usage?: UsageStats;
|
|
45
|
-
elapsedMs?: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
34
|
export function isRunActiveStatus(status: RunStatus): boolean {
|
|
49
35
|
return status === "queued" || status === "running" || status === "interrupting";
|
|
50
36
|
}
|
|
@@ -88,45 +74,19 @@ export interface RunView {
|
|
|
88
74
|
endedAt?: number;
|
|
89
75
|
/** Why this generation reused retained context, shown in the widget/status. */
|
|
90
76
|
continuationKind?: ContinuationKind;
|
|
91
|
-
/** When set, this is an internal managed-workflow step. */
|
|
92
|
-
groupId?: string;
|
|
93
|
-
/** Human-readable role within a workflow, e.g. "final review" or "final documentation sync". */
|
|
94
|
-
relationLabel?: string;
|
|
95
|
-
/** Stable owning run whose row represents the whole managed workflow. */
|
|
96
|
-
parentRunId?: number;
|
|
97
|
-
/** This stable top-level row currently owns a multi-stage managed workflow.
|
|
98
|
-
* Its elapsed time is workflow-wide; active child rows own stage telemetry. */
|
|
99
|
-
managedWorkflow?: boolean;
|
|
100
|
-
/** Live-only stage timeline retained on the parent while completed internal
|
|
101
|
-
* child rows leave the monitor. */
|
|
102
|
-
workflowStages?: WorkflowStage[];
|
|
103
77
|
}
|
|
104
78
|
|
|
105
|
-
/**
|
|
79
|
+
/** Extra metadata for a run whose row must carry isolation or resume context. */
|
|
106
80
|
export interface RunChainMeta {
|
|
107
|
-
groupId?: string;
|
|
108
|
-
relationLabel?: string;
|
|
109
|
-
parentRunId?: number;
|
|
110
81
|
isolation?: IsolationMode;
|
|
111
82
|
worktreeId?: string;
|
|
112
83
|
continuationKind?: ContinuationKind;
|
|
113
84
|
/** Initial wait reason; defaults to "process-slot" (a fresh dispatch enters
|
|
114
|
-
* the process queue).
|
|
115
|
-
* they
|
|
85
|
+
* the process queue). Children spawned outside the queue pass "starting"
|
|
86
|
+
* because they never wait for a slot. */
|
|
116
87
|
waitReason?: RunWaitReason;
|
|
117
88
|
}
|
|
118
89
|
|
|
119
|
-
/** Ephemeral activity of the parent pi model while its agent loop runs: the
|
|
120
|
-
* live model/thinking ref and a one-line "what is it doing now". Not a run —
|
|
121
|
-
* no id, usage, or chain machinery; the view disappears when the loop settles. */
|
|
122
|
-
export interface MainActivity {
|
|
123
|
-
model?: string;
|
|
124
|
-
thinking?: string;
|
|
125
|
-
activity?: string;
|
|
126
|
-
/** Epoch ms when the current agent loop started. */
|
|
127
|
-
activeSince: number;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
90
|
// ---------------------------------------------------------------------------
|
|
131
91
|
// Formatting helpers
|
|
132
92
|
// ---------------------------------------------------------------------------
|
|
@@ -299,6 +259,17 @@ export function runLabel(task: string): string {
|
|
|
299
259
|
: `${takeGraphemes(chars, RUN_LABEL_MAX - 1)}${TASK_SUMMARY_ELLIPSIS}`;
|
|
300
260
|
}
|
|
301
261
|
|
|
262
|
+
/** Narrow an already-extracted run label to a smaller budget, keeping its
|
|
263
|
+
* tail: runLabel tail-weights path fragments because the filename is the
|
|
264
|
+
* recognisable part, and a second squeeze must not trade that tail away.
|
|
265
|
+
* Grapheme-safe. */
|
|
266
|
+
export function shrinkRunLabel(text: string, maxWidth: number): string {
|
|
267
|
+
if (maxWidth <= 0) return "";
|
|
268
|
+
if (visibleWidth(text) <= maxWidth) return text;
|
|
269
|
+
const chars = [...graphemeSegmenter.segment(text)].map((s) => s.segment);
|
|
270
|
+
return `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, maxWidth - 1)}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
302
273
|
function formatTokens(count: number): string {
|
|
303
274
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
304
275
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
@@ -483,71 +454,6 @@ export class MonitorStore {
|
|
|
483
454
|
private runs: RunView[] = [];
|
|
484
455
|
private nextId = 1;
|
|
485
456
|
private subscribers = new Set<() => void>();
|
|
486
|
-
private mainModel?: string;
|
|
487
|
-
private mainThinking?: string;
|
|
488
|
-
private mainActivity?: string;
|
|
489
|
-
private mainActiveSince?: number;
|
|
490
|
-
|
|
491
|
-
// --- parent pi model activity ------------------------------------------
|
|
492
|
-
// Fed by the parent session's extension events (agent loop, streaming,
|
|
493
|
-
// tool executions); rendered as the widget's first line. Change-guarded so
|
|
494
|
-
// per-token streaming deltas do not flood subscribers.
|
|
495
|
-
|
|
496
|
-
setMainModel(model?: string): void {
|
|
497
|
-
if (!model || this.mainModel === model) return;
|
|
498
|
-
this.mainModel = model;
|
|
499
|
-
this.notify();
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
setMainThinking(thinking?: string): void {
|
|
503
|
-
if (!thinking || this.mainThinking === thinking) return;
|
|
504
|
-
this.mainThinking = thinking;
|
|
505
|
-
this.notify();
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
setMainActivity(text: string): void {
|
|
509
|
-
const activity = sanitizeActivityText(text) || undefined;
|
|
510
|
-
if (!activity || this.mainActivity === activity) return;
|
|
511
|
-
this.mainActivity = activity;
|
|
512
|
-
this.notify();
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/** Record the main model starting a tool; the activity shows the tool's
|
|
516
|
-
* most telling argument, same vocabulary as subagent rows. */
|
|
517
|
-
recordMainToolStart(toolName: string, activity: string): void {
|
|
518
|
-
const safeToolName = sanitizeActivityText(toolName) || "tool";
|
|
519
|
-
this.setMainActivity(activity || safeToolName);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** Record a failed main-model tool; successful completions keep their last
|
|
523
|
-
* activity until the next model event supplies a better description. */
|
|
524
|
-
recordMainToolEnd(toolName: string, isError: boolean): void {
|
|
525
|
-
if (isError) this.setMainActivity(`✗ ${sanitizeActivityText(toolName) || "tool"} failed`);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/** Track the parent agent loop: started at agent_start, cleared when the
|
|
529
|
-
* loop settles (agent_end / agent_settled). */
|
|
530
|
-
setMainAgentActive(active: boolean): void {
|
|
531
|
-
if ((this.mainActiveSince !== undefined) === active) return;
|
|
532
|
-
this.mainActiveSince = active ? Date.now() : undefined;
|
|
533
|
-
this.mainActivity = undefined;
|
|
534
|
-
this.notify();
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/** Live view of the parent model while its agent loop runs; undefined when idle. */
|
|
538
|
-
getMainActivity(): MainActivity | undefined {
|
|
539
|
-
if (this.mainActiveSince === undefined) return undefined;
|
|
540
|
-
return {
|
|
541
|
-
...(this.mainModel ? { model: this.mainModel } : {}),
|
|
542
|
-
...(this.mainThinking ? { thinking: this.mainThinking } : {}),
|
|
543
|
-
...(this.mainActivity ? { activity: this.mainActivity } : {}),
|
|
544
|
-
activeSince: this.mainActiveSince,
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
isMainAgentActive(): boolean {
|
|
549
|
-
return this.mainActiveSince !== undefined;
|
|
550
|
-
}
|
|
551
457
|
|
|
552
458
|
beginTurn(): void {
|
|
553
459
|
// Clear finished runs from a previous turn, but keep active and parked
|
|
@@ -597,9 +503,6 @@ export class MonitorStore {
|
|
|
597
503
|
waitReason: meta?.waitReason ?? "process-slot",
|
|
598
504
|
usage: emptyUsage(),
|
|
599
505
|
elapsedMs: 0,
|
|
600
|
-
...(meta?.groupId ? { groupId: meta.groupId } : {}),
|
|
601
|
-
...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
|
|
602
|
-
...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
|
|
603
506
|
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined, ...(meta.worktreeId ? { worktreeId: meta.worktreeId } : {}) } : {}),
|
|
604
507
|
...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
|
|
605
508
|
});
|
|
@@ -638,27 +541,6 @@ export class MonitorStore {
|
|
|
638
541
|
this.notify();
|
|
639
542
|
}
|
|
640
543
|
|
|
641
|
-
/** Switch a stable top-level row from one model run to workflow ownership.
|
|
642
|
-
* The original role remains for identity; child rows show stage telemetry. */
|
|
643
|
-
setManagedWorkflow(id: number, active: boolean): void {
|
|
644
|
-
const run = this.find(id);
|
|
645
|
-
if (!run) return;
|
|
646
|
-
run.managedWorkflow = active || undefined;
|
|
647
|
-
if (!active) run.workflowStages = undefined;
|
|
648
|
-
this.notify();
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/** Replace the live workflow projection atomically so renderers never observe
|
|
652
|
-
* a half-updated fix/re-review plan. */
|
|
653
|
-
setWorkflowStages(id: number, stages: readonly WorkflowStage[]): void {
|
|
654
|
-
const run = this.find(id);
|
|
655
|
-
if (!run) return;
|
|
656
|
-
run.workflowStages = stages.length > 0
|
|
657
|
-
? stages.map((stage) => ({ ...stage }))
|
|
658
|
-
: undefined;
|
|
659
|
-
this.notify();
|
|
660
|
-
}
|
|
661
|
-
|
|
662
544
|
setUsage(id: number, usage: UsageStats, model?: string): void {
|
|
663
545
|
const run = this.find(id);
|
|
664
546
|
if (!run) return;
|
|
@@ -795,8 +677,6 @@ export class MonitorStore {
|
|
|
795
677
|
run.waitReason = "process-slot";
|
|
796
678
|
run.usage = emptyUsage();
|
|
797
679
|
run.activity = undefined;
|
|
798
|
-
run.managedWorkflow = undefined;
|
|
799
|
-
run.workflowStages = undefined;
|
|
800
680
|
run.activeSince = undefined;
|
|
801
681
|
run.endedAt = undefined;
|
|
802
682
|
run.elapsedMs = Math.max(run.elapsedMs, meta?.elapsedMs ?? 0);
|
|
@@ -814,10 +694,6 @@ export class MonitorStore {
|
|
|
814
694
|
* finishRun calls from the old session remain safe no-ops. */
|
|
815
695
|
clear(): void {
|
|
816
696
|
this.runs = [];
|
|
817
|
-
this.mainModel = undefined;
|
|
818
|
-
this.mainThinking = undefined;
|
|
819
|
-
this.mainActivity = undefined;
|
|
820
|
-
this.mainActiveSince = undefined;
|
|
821
697
|
this.notify();
|
|
822
698
|
}
|
|
823
699
|
|
|
@@ -843,14 +719,13 @@ export class MonitorStore {
|
|
|
843
719
|
|
|
844
720
|
summarize(run: RunView): string {
|
|
845
721
|
const usage = formatUsageCompact(run.usage);
|
|
846
|
-
const parts = [run.
|
|
722
|
+
const parts = [run.agent];
|
|
847
723
|
const continuation = continuationLabel(run.continuationKind);
|
|
848
724
|
if (continuation) parts.push(continuation);
|
|
849
|
-
if (run.
|
|
850
|
-
if (
|
|
851
|
-
if (!run.managedWorkflow && run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
725
|
+
if (run.model) parts.push(run.model);
|
|
726
|
+
if (run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
852
727
|
if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
|
|
853
|
-
if (
|
|
728
|
+
if (usage) parts.push(usage);
|
|
854
729
|
const elapsed = formatElapsed(run);
|
|
855
730
|
if (elapsed) parts.push(elapsed);
|
|
856
731
|
return parts.join(" · ");
|
package/src/prompt.ts
CHANGED
|
@@ -20,38 +20,21 @@ export function buildDelegationDirective(
|
|
|
20
20
|
|
|
21
21
|
const catalog = agents.map(formatCatalogEntry).join("\n");
|
|
22
22
|
const hasExplorer = agents.some((agent) => agent.name === "explorer");
|
|
23
|
-
const
|
|
24
|
-
const hasCleaner = agents.some((agent) => agent.name === "cleaner");
|
|
25
|
-
const hasDocumenter = agents.some((agent) => agent.name === "documenter");
|
|
26
|
-
const hasReviewer = agents.some((agent) => agent.name === "reviewer");
|
|
27
|
-
const hasSynthesizer = agents.some((agent) => agent.name === "synthesizer");
|
|
28
|
-
const codeWriterNames = [
|
|
29
|
-
...(hasWorker ? ["worker"] : []),
|
|
30
|
-
...(hasCleaner ? ["cleaner"] : []),
|
|
31
|
-
];
|
|
23
|
+
const hasExecutor = agents.some((agent) => agent.name === "executor");
|
|
32
24
|
|
|
33
25
|
const dispatchRules = [
|
|
34
|
-
`Delegate aggressively: child contexts are cheap, yours is scarce. Inline only trivial work — a lookup, a single focused edit, an answer already in context${
|
|
26
|
+
`Delegate aggressively: child contexts are cheap, yours is scarce. Inline only trivial work — a lookup, a single focused edit, an answer already in context${hasExecutor ? "; default every non-trivial delegated task (implementation, fix, refactor, test, cleanup, docs sync, result merging) to `executor`" : ""}.`,
|
|
35
27
|
...(hasExplorer
|
|
36
28
|
? [
|
|
37
29
|
"`explorer`: split a broad question into parallel explorers with disjoint scopes. Its findings are leads, never proof — re-read load-bearing files before acting yourself (a child you brief re-verifies).",
|
|
38
30
|
]
|
|
39
31
|
: []),
|
|
40
|
-
...(
|
|
41
|
-
? ["`cleaner`: dispatch for requested cleanup AND proactively when finished work leaves dead code or duplication. Your brief is its edit authorization — every safe proven cut applies without per-item approval; never a gate. Scope: the uncommitted diff, or whatever your brief names (Git range, directory)."]
|
|
42
|
-
: []),
|
|
43
|
-
...(hasDocumenter
|
|
44
|
-
? ["`documenter`: standalone docs/comment work; dispatch it proactively when a change — yours or a child's — leaves README/docs/comment drift no writer already synced; cheap and may make zero edits."]
|
|
45
|
-
: []),
|
|
46
|
-
...(hasReviewer
|
|
32
|
+
...(hasExecutor
|
|
47
33
|
? [
|
|
48
|
-
|
|
34
|
+
"`executor`: brief it as the edit authorization. For cleanup, name the scope (uncommitted diff, Git range, directory) — every safe proven cut applies without per-item approval; finding no safe cut is a valid result. After a wide fan-out, pass the result-artifact paths to one executor and read its merged brief instead of every result yourself.",
|
|
49
35
|
]
|
|
50
36
|
: []),
|
|
51
|
-
|
|
52
|
-
? ["`synthesizer`: after a wide fan-out, pass the result-artifact paths to one synthesizer and read its brief instead of every result yourself."]
|
|
53
|
-
: []),
|
|
54
|
-
`Parallelize by default: map the todo list onto ONE \`tasks\` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.`,
|
|
37
|
+
"Parallelize by default: map the todo list onto ONE `tasks` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.",
|
|
55
38
|
"Brief each child completely — goal, exact paths, constraints, expected output; it has no conversation memory and cannot delegate. Resume parked threads with `subagent_control resume`.",
|
|
56
39
|
];
|
|
57
40
|
|
|
@@ -63,13 +46,7 @@ export function buildDelegationDirective(
|
|
|
63
46
|
|
|
64
47
|
const verificationRules = [
|
|
65
48
|
"Never report an unrun check as passed; surface unavailable checks and pre-existing failures, and inspect actual changes before reporting completion.",
|
|
66
|
-
|
|
67
|
-
? [
|
|
68
|
-
"A REVIEW_FAIL from a gate you dispatched directly returns its findings to you: fix them inline or via a briefed worker without waiting for the user, then re-verify ONCE. If the gate still fails, report the remaining findings and move on — never loop gate dispatches.",
|
|
69
|
-
"Multi-model cross-review only when explicitly requested or for high-risk security, FFI, migration, or concurrency changes.",
|
|
70
|
-
]
|
|
71
|
-
: []),
|
|
72
|
-
"Commit or push only when explicitly requested, applicable checks pass, and no review finding remains unresolved.",
|
|
49
|
+
"Commit or push only when explicitly requested and applicable checks pass.",
|
|
73
50
|
];
|
|
74
51
|
|
|
75
52
|
return `
|
|
@@ -86,6 +63,6 @@ ${bullets(dispatchRules)}
|
|
|
86
63
|
Result handoff:
|
|
87
64
|
${bullets(handoffRules)}
|
|
88
65
|
|
|
89
|
-
|
|
66
|
+
Verification:
|
|
90
67
|
${bullets(verificationRules)}`;
|
|
91
68
|
}
|
package/src/recovery.ts
CHANGED
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
/** Durable handoff for worktree integration/cleanup failures across sessions. */
|
|
2
|
-
|
|
3
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
7
|
-
import { stripVTControlCharacters } from "node:util";
|
|
8
|
-
import type { WorktreeFinalization } from "./worktree.ts";
|
|
9
|
-
|
|
10
|
-
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
11
|
-
const RECOVERY_MANIFEST_VERSION = 1;
|
|
12
|
-
|
|
13
|
-
export interface RecoveryRecord {
|
|
14
|
-
runId: number;
|
|
15
|
-
createdAt: number;
|
|
16
|
-
integrated: boolean;
|
|
17
|
-
worktreePath?: string;
|
|
18
|
-
patchPath?: string;
|
|
19
|
-
error?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface RecoveryManifest {
|
|
23
|
-
version: number;
|
|
24
|
-
records: RecoveryRecord[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function getRecoveryManifestPath(configPath: string): string {
|
|
28
|
-
return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
32
|
-
if (!value || typeof value !== "object") return undefined;
|
|
33
|
-
const raw = value as Record<string, unknown>;
|
|
34
|
-
if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
|
|
35
|
-
if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
|
|
36
|
-
return {
|
|
37
|
-
runId: raw.runId,
|
|
38
|
-
createdAt: raw.createdAt,
|
|
39
|
-
integrated: raw.integrated === true,
|
|
40
|
-
...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
|
|
41
|
-
...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
|
|
42
|
-
...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
47
|
-
try {
|
|
48
|
-
const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
|
|
49
|
-
records?: unknown;
|
|
50
|
-
};
|
|
51
|
-
if (!Array.isArray(parsed.records)) return [];
|
|
52
|
-
return parsed.records.flatMap((record) => {
|
|
53
|
-
const normalized = normalizeRecord(record);
|
|
54
|
-
return normalized ? [normalized] : [];
|
|
55
|
-
});
|
|
56
|
-
} catch {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function recoveryKey(record: RecoveryRecord): string {
|
|
62
|
-
return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
|
|
66
|
-
if (records.length === 0) {
|
|
67
|
-
await rm(path, { force: true });
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
await mkdir(dirname(path), { recursive: true });
|
|
71
|
-
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
72
|
-
try {
|
|
73
|
-
const manifest: RecoveryManifest = {
|
|
74
|
-
version: RECOVERY_MANIFEST_VERSION,
|
|
75
|
-
records: [...records],
|
|
76
|
-
};
|
|
77
|
-
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
78
|
-
await rename(temporaryPath, path);
|
|
79
|
-
} finally {
|
|
80
|
-
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Merge retained artifacts into the durable manifest. */
|
|
85
|
-
export async function persistRecoveryRecords(
|
|
86
|
-
configPath: string,
|
|
87
|
-
records: readonly RecoveryRecord[],
|
|
88
|
-
): Promise<void> {
|
|
89
|
-
if (records.length === 0) return;
|
|
90
|
-
const path = getRecoveryManifestPath(configPath);
|
|
91
|
-
await withFileMutationQueue(path, async () => {
|
|
92
|
-
const merged = new Map<string, RecoveryRecord>();
|
|
93
|
-
for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
|
|
94
|
-
for (const record of records) merged.set(recoveryKey(record), record);
|
|
95
|
-
await writeManifest(path, [...merged.values()]);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function recoveryRecordFromFinalization(
|
|
100
|
-
runId: number,
|
|
101
|
-
finalization: WorktreeFinalization,
|
|
102
|
-
now = Date.now(),
|
|
103
|
-
): RecoveryRecord {
|
|
104
|
-
return {
|
|
105
|
-
runId,
|
|
106
|
-
createdAt: now,
|
|
107
|
-
integrated: finalization.integrated,
|
|
108
|
-
...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
|
|
109
|
-
...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
|
|
110
|
-
...(finalization.error ? { error: finalization.error } : {}),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Show retained recovery paths on every later session start until the user
|
|
115
|
-
* removes the artifacts. Stale records are pruned automatically. */
|
|
116
|
-
export async function announceRecoveryRecords(
|
|
117
|
-
configPath: string,
|
|
118
|
-
ctx: {
|
|
119
|
-
hasUI?: boolean;
|
|
120
|
-
ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
|
|
121
|
-
},
|
|
122
|
-
): Promise<void> {
|
|
123
|
-
if (ctx.hasUI === false) return;
|
|
124
|
-
const records = await readRecoveryRecords(configPath);
|
|
125
|
-
if (records.length === 0) return;
|
|
126
|
-
const live = records.filter((record) =>
|
|
127
|
-
(record.worktreePath ? existsSync(record.worktreePath) : false) ||
|
|
128
|
-
(record.patchPath ? existsSync(record.patchPath) : false),
|
|
129
|
-
);
|
|
130
|
-
if (live.length !== records.length) {
|
|
131
|
-
const path = getRecoveryManifestPath(configPath);
|
|
132
|
-
await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
|
|
133
|
-
}
|
|
134
|
-
for (const record of live) {
|
|
135
|
-
const paths = [
|
|
136
|
-
record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
|
|
137
|
-
record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
|
|
138
|
-
].filter(Boolean).join(" · ");
|
|
139
|
-
const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
|
|
140
|
-
ctx.ui.notify(
|
|
141
|
-
`pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
|
|
142
|
-
"error",
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
1
|
+
/** Durable handoff for worktree integration/cleanup failures across sessions. */
|
|
2
|
+
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import type { WorktreeFinalization } from "./worktree.ts";
|
|
9
|
+
|
|
10
|
+
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
11
|
+
const RECOVERY_MANIFEST_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export interface RecoveryRecord {
|
|
14
|
+
runId: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
integrated: boolean;
|
|
17
|
+
worktreePath?: string;
|
|
18
|
+
patchPath?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RecoveryManifest {
|
|
23
|
+
version: number;
|
|
24
|
+
records: RecoveryRecord[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getRecoveryManifestPath(configPath: string): string {
|
|
28
|
+
return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
32
|
+
if (!value || typeof value !== "object") return undefined;
|
|
33
|
+
const raw = value as Record<string, unknown>;
|
|
34
|
+
if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
|
|
35
|
+
if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
|
|
36
|
+
return {
|
|
37
|
+
runId: raw.runId,
|
|
38
|
+
createdAt: raw.createdAt,
|
|
39
|
+
integrated: raw.integrated === true,
|
|
40
|
+
...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
|
|
41
|
+
...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
|
|
42
|
+
...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
|
|
49
|
+
records?: unknown;
|
|
50
|
+
};
|
|
51
|
+
if (!Array.isArray(parsed.records)) return [];
|
|
52
|
+
return parsed.records.flatMap((record) => {
|
|
53
|
+
const normalized = normalizeRecord(record);
|
|
54
|
+
return normalized ? [normalized] : [];
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recoveryKey(record: RecoveryRecord): string {
|
|
62
|
+
return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
|
|
66
|
+
if (records.length === 0) {
|
|
67
|
+
await rm(path, { force: true });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await mkdir(dirname(path), { recursive: true });
|
|
71
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
const manifest: RecoveryManifest = {
|
|
74
|
+
version: RECOVERY_MANIFEST_VERSION,
|
|
75
|
+
records: [...records],
|
|
76
|
+
};
|
|
77
|
+
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
78
|
+
await rename(temporaryPath, path);
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Merge retained artifacts into the durable manifest. */
|
|
85
|
+
export async function persistRecoveryRecords(
|
|
86
|
+
configPath: string,
|
|
87
|
+
records: readonly RecoveryRecord[],
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (records.length === 0) return;
|
|
90
|
+
const path = getRecoveryManifestPath(configPath);
|
|
91
|
+
await withFileMutationQueue(path, async () => {
|
|
92
|
+
const merged = new Map<string, RecoveryRecord>();
|
|
93
|
+
for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
|
|
94
|
+
for (const record of records) merged.set(recoveryKey(record), record);
|
|
95
|
+
await writeManifest(path, [...merged.values()]);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function recoveryRecordFromFinalization(
|
|
100
|
+
runId: number,
|
|
101
|
+
finalization: WorktreeFinalization,
|
|
102
|
+
now = Date.now(),
|
|
103
|
+
): RecoveryRecord {
|
|
104
|
+
return {
|
|
105
|
+
runId,
|
|
106
|
+
createdAt: now,
|
|
107
|
+
integrated: finalization.integrated,
|
|
108
|
+
...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
|
|
109
|
+
...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
|
|
110
|
+
...(finalization.error ? { error: finalization.error } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Show retained recovery paths on every later session start until the user
|
|
115
|
+
* removes the artifacts. Stale records are pruned automatically. */
|
|
116
|
+
export async function announceRecoveryRecords(
|
|
117
|
+
configPath: string,
|
|
118
|
+
ctx: {
|
|
119
|
+
hasUI?: boolean;
|
|
120
|
+
ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
|
|
121
|
+
},
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
if (ctx.hasUI === false) return;
|
|
124
|
+
const records = await readRecoveryRecords(configPath);
|
|
125
|
+
if (records.length === 0) return;
|
|
126
|
+
const live = records.filter((record) =>
|
|
127
|
+
(record.worktreePath ? existsSync(record.worktreePath) : false) ||
|
|
128
|
+
(record.patchPath ? existsSync(record.patchPath) : false),
|
|
129
|
+
);
|
|
130
|
+
if (live.length !== records.length) {
|
|
131
|
+
const path = getRecoveryManifestPath(configPath);
|
|
132
|
+
await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
for (const record of live) {
|
|
135
|
+
const paths = [
|
|
136
|
+
record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
|
|
137
|
+
record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
|
|
138
|
+
].filter(Boolean).join(" · ");
|
|
139
|
+
const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
|
|
140
|
+
ctx.ui.notify(
|
|
141
|
+
`pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
|
|
142
|
+
"error",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -25,7 +25,6 @@ import { isRunActiveStatus, monitor } from "./monitor.ts";
|
|
|
25
25
|
import type { RpcRunControl } from "./rpc-run.ts";
|
|
26
26
|
import type { StartBackgroundInternal } from "./thread-lifecycle.ts";
|
|
27
27
|
import { isFailedResult, type SingleResult } from "./spawn.ts";
|
|
28
|
-
import type { ReviewMode } from "./workflow.ts";
|
|
29
28
|
import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
|
|
30
29
|
|
|
31
30
|
export type ThreadState =
|
|
@@ -51,15 +50,12 @@ export interface SubagentThread {
|
|
|
51
50
|
executionCwd: string;
|
|
52
51
|
thinkingLevel?: ThinkingLevel;
|
|
53
52
|
isolation: IsolationMode;
|
|
54
|
-
/** Dispatch-time gate intensity; "none" skips the automatic post-writer
|
|
55
|
-
* reviewer for this thread (kept across resumes and reloads). */
|
|
56
|
-
review?: ReviewMode;
|
|
57
53
|
worktree?: WorktreeIsolation;
|
|
58
54
|
state: ThreadState;
|
|
59
55
|
control: RpcRunControl;
|
|
60
56
|
queueController?: AbortController;
|
|
61
|
-
/** Resolves only after the current generation's
|
|
62
|
-
*
|
|
57
|
+
/** Resolves only after the current generation's child process, isolation
|
|
58
|
+
* finalization, and queue work have fully quiesced and released their
|
|
63
59
|
* concurrency slot. */
|
|
64
60
|
generationCompletion: Promise<void>;
|
|
65
61
|
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
@@ -122,8 +118,7 @@ export interface SubagentRuntime {
|
|
|
122
118
|
/** Resume setup that has claimed a thread but has not yet enqueued its
|
|
123
119
|
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
124
120
|
preflightOperations: Set<Promise<void>>;
|
|
125
|
-
/** Every session directory retained for this parent session
|
|
126
|
-
* managed-workflow internals that are not directly controllable. */
|
|
121
|
+
/** Every session directory retained for this parent session. */
|
|
127
122
|
sessionDirs: Set<string>;
|
|
128
123
|
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
129
124
|
retireThreadSession: (thread: SubagentThread) => void;
|
|
@@ -148,8 +143,6 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
148
143
|
// Computing this at delivery (emit) time — not when the item was
|
|
149
144
|
// pushed — reflects the current monitor state, since finishing runs
|
|
150
145
|
// are removed from the monitor before their completion is pushed.
|
|
151
|
-
// Managed-workflow parents remain "running" through reviewer and
|
|
152
|
-
// documenter stages, so they are included without a special case.
|
|
153
146
|
const active = monitor
|
|
154
147
|
.getRuns()
|
|
155
148
|
.filter((run) => isRunActiveStatus(run.status))
|