@ferris1225/pi-subagents 4.1.7 → 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 +94 -65
- 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 +30 -67
- package/src/background.ts +25 -12
- package/src/config.ts +9 -170
- package/src/dispatch.ts +721 -747
- package/src/durable.ts +336 -0
- package/src/fixloop.ts +37 -37
- package/src/format.ts +1 -8
- package/src/index.ts +8 -1
- package/src/models.ts +16 -0
- package/src/monitor.ts +28 -29
- package/src/prompt.ts +7 -8
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +72 -50
- package/src/session-fork.ts +7 -2
- package/src/setup.ts +0 -41
- package/src/spawn.ts +32 -29
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1327
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/worktree.ts +144 -4
package/src/durable.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable thread state: a manifest next to the config that lets parked and
|
|
3
|
+
* settled sub-agent threads survive pi reloads and restarts, plus the durable
|
|
4
|
+
* state root that keeps their retained sessions and isolated worktrees out of
|
|
5
|
+
* the OS temp directory.
|
|
6
|
+
*
|
|
7
|
+
* Records are small path/state snapshots, never full transcripts; the retained
|
|
8
|
+
* Pi session files and worktrees they point at remain the actual context.
|
|
9
|
+
* Writes are atomic (tmp+rename) and serialized through the same
|
|
10
|
+
* withFileMutationQueue as the recovery manifest.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import type { UsageStats } from "./rpc-run.ts";
|
|
18
|
+
import type { SubagentThread } from "./runtime.ts";
|
|
19
|
+
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
20
|
+
import {
|
|
21
|
+
restoreWorktreeIsolation,
|
|
22
|
+
type IsolationMode,
|
|
23
|
+
normalizeWorktreeSnapshot,
|
|
24
|
+
worktreeSnapshot,
|
|
25
|
+
type WorktreeSnapshot,
|
|
26
|
+
} from "./worktree.ts";
|
|
27
|
+
|
|
28
|
+
export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
|
|
29
|
+
const THREADS_MANIFEST_VERSION = 1;
|
|
30
|
+
export const STATE_DIR_NAME = "pi-subagents-state";
|
|
31
|
+
|
|
32
|
+
/** Fixed retention: settled results stop being resumable after a week,
|
|
33
|
+
* parked work (which may hold unintegrated changes) after a month. */
|
|
34
|
+
export const SETTLED_RECORD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
35
|
+
export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
36
|
+
|
|
37
|
+
/** Result excerpts are for status display after restore, not full transcripts. */
|
|
38
|
+
const RESULT_SUMMARY_MAX_CHARS = 4_000;
|
|
39
|
+
|
|
40
|
+
export interface ThreadResultSummary {
|
|
41
|
+
agent: string;
|
|
42
|
+
task: string;
|
|
43
|
+
exitCode: number;
|
|
44
|
+
failed: boolean;
|
|
45
|
+
stopReason?: string;
|
|
46
|
+
usage: UsageStats;
|
|
47
|
+
model?: string;
|
|
48
|
+
thinking?: string;
|
|
49
|
+
output: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ThreadRecord {
|
|
53
|
+
runId: number;
|
|
54
|
+
createdAt: number;
|
|
55
|
+
updatedAt: number;
|
|
56
|
+
generation: number;
|
|
57
|
+
agentName: string;
|
|
58
|
+
task: string;
|
|
59
|
+
cwd: string;
|
|
60
|
+
executionCwd: string;
|
|
61
|
+
thinkingLevel?: string;
|
|
62
|
+
isolation: IsolationMode;
|
|
63
|
+
state: "parked" | "completed" | "failed";
|
|
64
|
+
elapsedMs: number;
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
sessionDir?: string;
|
|
67
|
+
worktree?: WorktreeSnapshot;
|
|
68
|
+
childPids: number[];
|
|
69
|
+
resultSummary?: ThreadResultSummary;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ThreadsManifest {
|
|
73
|
+
version: number;
|
|
74
|
+
records: ThreadRecord[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getStateRoot(configPath: string): string {
|
|
78
|
+
return join(dirname(configPath), STATE_DIR_NAME);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getThreadsManifestPath(configPath: string): string {
|
|
82
|
+
return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeUsage(value: unknown): UsageStats {
|
|
86
|
+
const raw = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
87
|
+
const num = (key: string): number => (typeof raw[key] === "number" && Number.isFinite(raw[key]) ? raw[key] : 0);
|
|
88
|
+
return {
|
|
89
|
+
input: num("input"),
|
|
90
|
+
output: num("output"),
|
|
91
|
+
cacheRead: num("cacheRead"),
|
|
92
|
+
cacheWrite: num("cacheWrite"),
|
|
93
|
+
cost: num("cost"),
|
|
94
|
+
contextTokens: num("contextTokens"),
|
|
95
|
+
turns: num("turns"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeResultSummary(value: unknown): ThreadResultSummary | undefined {
|
|
100
|
+
if (!value || typeof value !== "object") return undefined;
|
|
101
|
+
const raw = value as Record<string, unknown>;
|
|
102
|
+
if (typeof raw.agent !== "string" || !raw.agent) return undefined;
|
|
103
|
+
if (typeof raw.output !== "string") return undefined;
|
|
104
|
+
return {
|
|
105
|
+
agent: raw.agent,
|
|
106
|
+
task: typeof raw.task === "string" ? raw.task : raw.agent,
|
|
107
|
+
exitCode: typeof raw.exitCode === "number" ? raw.exitCode : 0,
|
|
108
|
+
failed: raw.failed === true,
|
|
109
|
+
...(typeof raw.stopReason === "string" && raw.stopReason ? { stopReason: raw.stopReason } : {}),
|
|
110
|
+
usage: normalizeUsage(raw.usage),
|
|
111
|
+
...(typeof raw.model === "string" && raw.model ? { model: raw.model } : {}),
|
|
112
|
+
...(typeof raw.thinking === "string" && raw.thinking ? { thinking: raw.thinking } : {}),
|
|
113
|
+
output: raw.output,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeRecord(value: unknown): ThreadRecord | undefined {
|
|
118
|
+
if (!value || typeof value !== "object") return undefined;
|
|
119
|
+
const raw = value as Record<string, unknown>;
|
|
120
|
+
if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
|
|
121
|
+
if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
|
|
122
|
+
if (typeof raw.updatedAt !== "number" || !Number.isFinite(raw.updatedAt)) return undefined;
|
|
123
|
+
if (typeof raw.agentName !== "string" || !raw.agentName) return undefined;
|
|
124
|
+
if (typeof raw.task !== "string" || !raw.task) return undefined;
|
|
125
|
+
if (typeof raw.cwd !== "string" || !raw.cwd) return undefined;
|
|
126
|
+
if (raw.isolation !== "shared" && raw.isolation !== "worktree") return undefined;
|
|
127
|
+
if (raw.state !== "parked" && raw.state !== "completed" && raw.state !== "failed") return undefined;
|
|
128
|
+
const worktree = raw.worktree === undefined ? undefined : normalizeWorktreeSnapshot(raw.worktree);
|
|
129
|
+
if (worktree === null) return undefined;
|
|
130
|
+
return {
|
|
131
|
+
runId: raw.runId,
|
|
132
|
+
createdAt: raw.createdAt,
|
|
133
|
+
updatedAt: raw.updatedAt,
|
|
134
|
+
generation: typeof raw.generation === "number" && Number.isInteger(raw.generation) && raw.generation >= 0 ? raw.generation : 0,
|
|
135
|
+
agentName: raw.agentName,
|
|
136
|
+
task: raw.task,
|
|
137
|
+
cwd: raw.cwd,
|
|
138
|
+
executionCwd: typeof raw.executionCwd === "string" && raw.executionCwd ? raw.executionCwd : raw.cwd,
|
|
139
|
+
...(typeof raw.thinkingLevel === "string" && raw.thinkingLevel ? { thinkingLevel: raw.thinkingLevel } : {}),
|
|
140
|
+
isolation: raw.isolation,
|
|
141
|
+
state: raw.state,
|
|
142
|
+
elapsedMs: typeof raw.elapsedMs === "number" && Number.isFinite(raw.elapsedMs) ? Math.max(0, raw.elapsedMs) : 0,
|
|
143
|
+
...(typeof raw.sessionId === "string" && raw.sessionId ? { sessionId: raw.sessionId } : {}),
|
|
144
|
+
...(typeof raw.sessionDir === "string" && raw.sessionDir ? { sessionDir: raw.sessionDir } : {}),
|
|
145
|
+
...(worktree ? { worktree } : {}),
|
|
146
|
+
childPids: Array.isArray(raw.childPids)
|
|
147
|
+
? raw.childPids.filter((pid): pid is number => typeof pid === "number" && Number.isInteger(pid) && pid > 0)
|
|
148
|
+
: [],
|
|
149
|
+
...(raw.resultSummary === undefined ? {} : { resultSummary: normalizeResultSummary(raw.resultSummary) }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(await readFile(getThreadsManifestPath(configPath), "utf8")) as {
|
|
156
|
+
records?: unknown;
|
|
157
|
+
};
|
|
158
|
+
if (!Array.isArray(parsed.records)) return [];
|
|
159
|
+
return parsed.records.flatMap((record) => {
|
|
160
|
+
const normalized = normalizeRecord(record);
|
|
161
|
+
return normalized ? [normalized] : [];
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function writeManifest(configPath: string, records: readonly ThreadRecord[]): Promise<void> {
|
|
169
|
+
const path = getThreadsManifestPath(configPath);
|
|
170
|
+
if (records.length === 0) {
|
|
171
|
+
await rm(path, { force: true });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await mkdir(dirname(path), { recursive: true });
|
|
175
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
176
|
+
try {
|
|
177
|
+
const manifest: ThreadsManifest = {
|
|
178
|
+
version: THREADS_MANIFEST_VERSION,
|
|
179
|
+
records: [...records],
|
|
180
|
+
};
|
|
181
|
+
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
182
|
+
await rename(temporaryPath, path);
|
|
183
|
+
} finally {
|
|
184
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function upsertThreadRecord(configPath: string, record: ThreadRecord): Promise<void> {
|
|
189
|
+
const path = getThreadsManifestPath(configPath);
|
|
190
|
+
await withFileMutationQueue(path, async () => {
|
|
191
|
+
const records = await readThreadRecords(configPath);
|
|
192
|
+
const index = records.findIndex((candidate) => candidate.runId === record.runId);
|
|
193
|
+
const merged: ThreadRecord = index === -1
|
|
194
|
+
? record
|
|
195
|
+
: { ...record, createdAt: records[index]!.createdAt };
|
|
196
|
+
if (index === -1) records.push(merged);
|
|
197
|
+
else records[index] = merged;
|
|
198
|
+
await writeManifest(configPath, records);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function removeThreadRecord(configPath: string, runId: number): Promise<void> {
|
|
203
|
+
const path = getThreadsManifestPath(configPath);
|
|
204
|
+
await withFileMutationQueue(path, async () => {
|
|
205
|
+
const records = await readThreadRecords(configPath);
|
|
206
|
+
const next = records.filter((record) => record.runId !== runId);
|
|
207
|
+
if (next.length === records.length) return;
|
|
208
|
+
await writeManifest(configPath, next);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function truncateSummary(text: string): string {
|
|
213
|
+
if (text.length <= RESULT_SUMMARY_MAX_CHARS) return text;
|
|
214
|
+
return `${text.slice(0, RESULT_SUMMARY_MAX_CHARS - 1)}…`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function summarizeResult(result: SingleResult): ThreadResultSummary | undefined {
|
|
218
|
+
if (!result) return undefined;
|
|
219
|
+
return {
|
|
220
|
+
agent: result.agent,
|
|
221
|
+
task: result.task,
|
|
222
|
+
exitCode: result.exitCode,
|
|
223
|
+
failed: isFailedResult(result),
|
|
224
|
+
...(result.stopReason ? { stopReason: result.stopReason } : {}),
|
|
225
|
+
usage: result.usage,
|
|
226
|
+
...(result.model ? { model: result.model } : {}),
|
|
227
|
+
...(result.thinking ? { thinking: result.thinking } : {}),
|
|
228
|
+
output: truncateSummary(getResultOutput(result)),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Project a live thread into its durable record. Only handles whose
|
|
233
|
+
* filesystem is still meaningful are persisted; finalized-and-removed
|
|
234
|
+
* worktrees keep just their checkpoint commit for continuation resumes. */
|
|
235
|
+
export function threadRecordFromThread(
|
|
236
|
+
thread: SubagentThread,
|
|
237
|
+
state: "parked" | "completed" | "failed",
|
|
238
|
+
previous?: ThreadRecord,
|
|
239
|
+
now = Date.now(),
|
|
240
|
+
): ThreadRecord {
|
|
241
|
+
const worktree = thread.worktree ? worktreeSnapshot(thread.worktree) : undefined;
|
|
242
|
+
return {
|
|
243
|
+
runId: thread.id,
|
|
244
|
+
createdAt: previous?.createdAt ?? now,
|
|
245
|
+
updatedAt: now,
|
|
246
|
+
generation: thread.generation,
|
|
247
|
+
agentName: thread.agentName,
|
|
248
|
+
task: thread.task,
|
|
249
|
+
cwd: thread.cwd,
|
|
250
|
+
executionCwd: thread.executionCwd,
|
|
251
|
+
...(thread.thinkingLevel ? { thinkingLevel: thread.thinkingLevel } : {}),
|
|
252
|
+
isolation: thread.isolation,
|
|
253
|
+
state,
|
|
254
|
+
elapsedMs: thread.elapsedMs,
|
|
255
|
+
...(thread.sessionId && thread.sessionDir ? { sessionId: thread.sessionId, sessionDir: thread.sessionDir } : {}),
|
|
256
|
+
...(worktree ? { worktree } : {}),
|
|
257
|
+
childPids: thread.control?.getChildPids?.() ?? [],
|
|
258
|
+
...(thread.lastResult ? { resultSummary: summarizeResult(thread.lastResult) } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Rebuild a displayable in-turn result from a persisted summary. The retained
|
|
263
|
+
* session holds the real context; this only lets subagent_wait/status show
|
|
264
|
+
* what the previous session's generation concluded. */
|
|
265
|
+
export function restoredResultFromSummary(record: ThreadRecord): SingleResult | undefined {
|
|
266
|
+
const summary = record.resultSummary;
|
|
267
|
+
if (!summary) return undefined;
|
|
268
|
+
return {
|
|
269
|
+
agent: summary.agent,
|
|
270
|
+
task: summary.task,
|
|
271
|
+
exitCode: summary.exitCode,
|
|
272
|
+
messages: summary.output
|
|
273
|
+
? [{
|
|
274
|
+
role: "assistant",
|
|
275
|
+
content: [{ type: "text", text: summary.output }],
|
|
276
|
+
stopReason: "stop",
|
|
277
|
+
} as SingleResult["messages"][number]]
|
|
278
|
+
: [],
|
|
279
|
+
stderr: "",
|
|
280
|
+
usage: summary.usage,
|
|
281
|
+
isolation: record.isolation,
|
|
282
|
+
...(summary.model ? { model: summary.model } : {}),
|
|
283
|
+
...(summary.thinking ? { thinking: summary.thinking } : {}),
|
|
284
|
+
...(summary.stopReason ? { stopReason: summary.stopReason } : {}),
|
|
285
|
+
...(record.sessionId && record.sessionDir ? { sessionId: record.sessionId, sessionDir: record.sessionDir } : {}),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
|
|
290
|
+
if (record.sessionDir) {
|
|
291
|
+
await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
292
|
+
}
|
|
293
|
+
if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
|
|
294
|
+
const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
|
|
295
|
+
await worktree?.discard().catch(() => undefined);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Drop records past their retention age along with their artifacts. Runs at
|
|
300
|
+
* extension load; the fixed ages honor the no-config-knobs policy. */
|
|
301
|
+
export async function pruneThreadRecords(
|
|
302
|
+
configPath: string,
|
|
303
|
+
now = Date.now(),
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
const path = getThreadsManifestPath(configPath);
|
|
306
|
+
await withFileMutationQueue(path, async () => {
|
|
307
|
+
const records = await readThreadRecords(configPath);
|
|
308
|
+
if (records.length === 0) return;
|
|
309
|
+
let changed = false;
|
|
310
|
+
const kept: ThreadRecord[] = [];
|
|
311
|
+
for (const record of records) {
|
|
312
|
+
const maxAge = record.state === "parked" ? PARKED_RECORD_MAX_AGE_MS : SETTLED_RECORD_MAX_AGE_MS;
|
|
313
|
+
if (now - record.updatedAt <= maxAge) {
|
|
314
|
+
kept.push(record);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
changed = true;
|
|
318
|
+
await discardRecordArtifacts(record);
|
|
319
|
+
}
|
|
320
|
+
if (changed) await writeManifest(configPath, kept);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Paths a manifest still references; used by the state-root sweep so
|
|
325
|
+
* freshly created-but-unrecorded directories are never touched. */
|
|
326
|
+
export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<string> {
|
|
327
|
+
const paths = new Set<string>();
|
|
328
|
+
for (const record of records) {
|
|
329
|
+
if (record.sessionDir) paths.add(record.sessionDir);
|
|
330
|
+
if (record.worktree) {
|
|
331
|
+
paths.add(record.worktree.tempDir);
|
|
332
|
+
if (existsSync(record.worktree.worktreePath)) paths.add(record.worktree.worktreePath);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return paths;
|
|
336
|
+
}
|
package/src/fixloop.ts
CHANGED
|
@@ -18,7 +18,13 @@
|
|
|
18
18
|
import { isWriteCapableAgent, type AgentConfig } from "./agents.ts";
|
|
19
19
|
import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
|
|
20
20
|
import { formatUsageCompact, sumUsage } from "./monitor.ts";
|
|
21
|
-
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Worker fixes allowed after REVIEW_FAIL: one fix, one re-review. Anything
|
|
24
|
+
* still unresolved is handed back to the main window instead of burning more
|
|
25
|
+
* rounds, keeping chain latency and cost bounded.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_FIX_ROUNDS = 1;
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* Whether a completed result should trigger the auto-fix loop instead of being
|
|
@@ -27,8 +33,7 @@ import type { SubagentsConfig } from "./config.ts";
|
|
|
27
33
|
* normally. Loop-internal re-review results never reach this path (they are
|
|
28
34
|
* awaited inside the loop, not delivered through the completion flow).
|
|
29
35
|
*/
|
|
30
|
-
export function shouldTriggerFixLoop(result: SingleResult
|
|
31
|
-
if (config.maxFixRounds <= 0) return false;
|
|
36
|
+
export function shouldTriggerFixLoop(result: SingleResult): boolean {
|
|
32
37
|
if (result.agent !== "reviewer") return false;
|
|
33
38
|
if (isFailedResult(result)) return false;
|
|
34
39
|
// A dispatch crash (spawn infra, delivery API, ...) is never a real review
|
|
@@ -97,8 +102,8 @@ export function canStartManagedWorkflow(
|
|
|
97
102
|
if (isWriteCapableAgent(agent)) return true;
|
|
98
103
|
if (agent.name === "reviewer") {
|
|
99
104
|
// Hold a stable diff snapshot against every discoverable writer even when
|
|
100
|
-
// this review is advisory
|
|
101
|
-
//
|
|
105
|
+
// this review is advisory. Classification happens only after the read-only
|
|
106
|
+
// child returns, too late to acquire the lane safely.
|
|
102
107
|
return availability.writer;
|
|
103
108
|
}
|
|
104
109
|
return false;
|
|
@@ -108,10 +113,10 @@ export function canStartManagedWorkflow(
|
|
|
108
113
|
* machine verdict is advisory and cannot start any write-capable child. */
|
|
109
114
|
export function getManagedWorkflowPlan(
|
|
110
115
|
result: SingleResult,
|
|
111
|
-
config: SubagentsConfig,
|
|
112
116
|
availability: WorkflowAgentAvailability,
|
|
117
|
+
advisoryReview = false,
|
|
113
118
|
): ManagedWorkflowPlan | undefined {
|
|
114
|
-
if (result.
|
|
119
|
+
if (result.dispatchFailed || isFailedResult(result)) return undefined;
|
|
115
120
|
if (result.agent === "worker" || result.agent === "cleaner") {
|
|
116
121
|
if (!availability.documenter && !availability.reviewer) return undefined;
|
|
117
122
|
return {
|
|
@@ -123,6 +128,9 @@ export function getManagedWorkflowPlan(
|
|
|
123
128
|
// owns the writer lane but delivers directly without an automatic code gate.
|
|
124
129
|
if (result.agent === "documenter") return undefined;
|
|
125
130
|
if (result.agent !== "reviewer") return undefined;
|
|
131
|
+
// An advisory dispatch never chains: the caller asked for a report, so even
|
|
132
|
+
// a stray gate verdict must be delivered rather than acted on.
|
|
133
|
+
if (advisoryReview) return undefined;
|
|
126
134
|
|
|
127
135
|
const output = getResultOutput(result);
|
|
128
136
|
const verdict = reviewVerdict(output);
|
|
@@ -135,7 +143,7 @@ export function getManagedWorkflowPlan(
|
|
|
135
143
|
) {
|
|
136
144
|
return { kind: "review-pass-sync", initialRelation: "pre-documentation review" };
|
|
137
145
|
}
|
|
138
|
-
if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result
|
|
146
|
+
if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result)) {
|
|
139
147
|
return { kind: "auto-fix", initialRelation: "initial review" };
|
|
140
148
|
}
|
|
141
149
|
return undefined;
|
|
@@ -145,11 +153,12 @@ export function getManagedWorkflowPlan(
|
|
|
145
153
|
* Build the worker task brief for one fix round from a reviewer's findings.
|
|
146
154
|
* The worker gets the full review text — findings plus their fix instructions
|
|
147
155
|
* — and closes every finding either by implementing the instruction or by
|
|
148
|
-
* shipping a sounder fix with an explicit per-finding pushback.
|
|
156
|
+
* shipping a sounder fix with an explicit per-finding pushback. The standing
|
|
157
|
+
* pushback and release-boundary contract lives in the worker system prompt;
|
|
158
|
+
* the brief carries only what is specific to this round.
|
|
149
159
|
*/
|
|
150
160
|
export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, maxRounds: number): string {
|
|
151
161
|
const review = getResultOutput(reviewerResult);
|
|
152
|
-
const remaining = maxRounds - round;
|
|
153
162
|
return [
|
|
154
163
|
`Auto-fix round ${round} of ${maxRounds} (triggered by a failed review).`,
|
|
155
164
|
``,
|
|
@@ -158,18 +167,12 @@ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, m
|
|
|
158
167
|
review,
|
|
159
168
|
`---`,
|
|
160
169
|
``,
|
|
161
|
-
`
|
|
162
|
-
`
|
|
163
|
-
`
|
|
164
|
-
`
|
|
165
|
-
`
|
|
166
|
-
`
|
|
167
|
-
`Do NOT commit, push, publish, tag, or release; do not bump versions. The parent chain still owns re-review and any conditional final documentation sync.`,
|
|
168
|
-
`After editing, run the project's format/build/tests when they exist and report`,
|
|
169
|
-
`exactly what you changed (paths + short rationale) plus any pushback, so a reviewer can verify.`,
|
|
170
|
-
remaining > 0
|
|
171
|
-
? `A reviewer will re-review your changes automatically after you finish.`
|
|
172
|
-
: `This is the last auto-fix round; the workflow conditionally runs any needed final documentation sync and then delivers.`,
|
|
170
|
+
`Close EVERY finding — there is no severity triage; all of them get fixed. Each finding carries a fix instruction:`,
|
|
171
|
+
`follow it, or ship a sounder fix and push back per finding with your reasoning.`,
|
|
172
|
+
`Do NOT refactor unrelated code. Synchronize any existing README/docs/examples/comments directly affected by your fixes.`,
|
|
173
|
+
`Run the project's format/build/tests when they exist, then report exactly what you changed (paths + short rationale)`,
|
|
174
|
+
`plus any pushback, so a reviewer can verify.`,
|
|
175
|
+
`A reviewer re-reviews your changes automatically after you finish; anything still unresolved after that re-review goes back to the main window.`,
|
|
173
176
|
].join("\n");
|
|
174
177
|
}
|
|
175
178
|
|
|
@@ -206,11 +209,11 @@ export function buildFinalDocumenterBrief(
|
|
|
206
209
|
`Final documentation sync: the review gate settled and you are the last managed stage before delivery.`,
|
|
207
210
|
``,
|
|
208
211
|
...reportSections,
|
|
209
|
-
`Inspect the actual git diff
|
|
210
|
-
`Apply every documentation note the reviews recorded, then synchronize stale README/docs, examples, API comments, docstrings
|
|
211
|
-
`
|
|
212
|
-
`
|
|
213
|
-
`
|
|
212
|
+
`Inspect the actual git diff and relevant implementation; the reports are only leads.`,
|
|
213
|
+
`Apply every documentation note the reviews recorded, then synchronize stale README/docs, examples, API comments, docstrings,`,
|
|
214
|
+
`and explanatory comments with the behavior that will be committed.`,
|
|
215
|
+
`Change documentation surfaces only; make zero edits when the diff creates no documentation drift.`,
|
|
216
|
+
`The workflow delivers directly after you; no fresh reviewer runs.`,
|
|
214
217
|
`Report exact documentation/comment paths changed, or state explicitly that no sync was needed.`,
|
|
215
218
|
].join("\n");
|
|
216
219
|
}
|
|
@@ -310,15 +313,13 @@ export function buildFinalReviewBrief(
|
|
|
310
313
|
`---`,
|
|
311
314
|
``,
|
|
312
315
|
`Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
|
|
313
|
-
`Remain read-only.
|
|
314
|
-
|
|
315
|
-
`A worker will implement your instructions unless it can justify a sounder fix and push back, so make each instruction specific enough to act on.`,
|
|
316
|
+
`Remain read-only. Attach a concrete fix instruction to EVERY gate finding: what to change, where, and how to verify the fix`,
|
|
317
|
+
`— a worker will implement your instructions unless it can justify a sounder fix and push back.`,
|
|
316
318
|
...(options.documenterPending
|
|
317
319
|
? [
|
|
318
|
-
`A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding
|
|
319
|
-
`
|
|
320
|
-
`DOCUMENTATION:
|
|
321
|
-
`Always emit exactly one of those standalone documentation lines; fail the gate only for code or test findings.`,
|
|
320
|
+
`A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding:`,
|
|
321
|
+
`record it under "## Documentation notes" and emit the standalone line DOCUMENTATION: NEEDED,`,
|
|
322
|
+
`or DOCUMENTATION: CLEAN when no documentation update is needed.`,
|
|
322
323
|
]
|
|
323
324
|
: [
|
|
324
325
|
`No documenter is pending, so documentation drift is an ordinary gate finding.`,
|
|
@@ -352,15 +353,14 @@ export function buildReReviewBrief(
|
|
|
352
353
|
review,
|
|
353
354
|
`---`,
|
|
354
355
|
``,
|
|
355
|
-
`The worker's report (what it changed, plus any pushback where it replaced your fix instruction
|
|
356
|
+
`The worker's report (what it changed, plus any pushback where it replaced your fix instruction):`,
|
|
356
357
|
`---`,
|
|
357
358
|
workerReport,
|
|
358
359
|
`---`,
|
|
359
360
|
``,
|
|
360
361
|
`Rule on EVERY previous finding: resolved, or still open. Judge the code as it now stands — a finding is`,
|
|
361
362
|
`resolved when the pending diff fixes it soundly, whether or not the worker followed your fix instruction.`,
|
|
362
|
-
`
|
|
363
|
-
`concretely refute its reasoning; never simply restate the finding for another round.`,
|
|
363
|
+
`Adjudicate each pushback once: accept the worker's fix unless you can concretely refute its reasoning.`,
|
|
364
364
|
`Run \`git diff\` to see what changed, then add NEW findings only for defects this round's edits introduced or exposed.`,
|
|
365
365
|
`Re-review never opens findings unrelated to this round's edits; issues the earlier review missed belong to a fresh gate.`,
|
|
366
366
|
`Do NOT re-open a finding you verified as resolved.`,
|
package/src/format.ts
CHANGED
|
@@ -100,11 +100,6 @@ export function formatCompletionBlock(
|
|
|
100
100
|
const startupRetryNote = result.startupRetries
|
|
101
101
|
? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
|
|
102
102
|
: "";
|
|
103
|
-
const relations = [
|
|
104
|
-
result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
|
|
105
|
-
(result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
|
|
106
|
-
].filter((value): value is string => Boolean(value));
|
|
107
|
-
const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
|
|
108
103
|
const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
|
|
109
104
|
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
|
|
110
105
|
if (result.isolation === "worktree") {
|
|
@@ -118,13 +113,11 @@ export function formatCompletionBlock(
|
|
|
118
113
|
? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
|
|
119
114
|
: "worktree · integration failed; recovery artifacts retained"
|
|
120
115
|
: "worktree · isolated";
|
|
121
|
-
lines.push(`Isolation: ${isolation}
|
|
116
|
+
lines.push(`Isolation: ${isolation}`);
|
|
122
117
|
if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
|
|
123
118
|
if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
|
|
124
119
|
if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
|
|
125
120
|
lines.push("");
|
|
126
|
-
} else if (relations.length > 0) {
|
|
127
|
-
lines.push(`Relation: ${relations.join(" · ")}`, "");
|
|
128
121
|
}
|
|
129
122
|
lines.push(text);
|
|
130
123
|
// Failed-tool diagnostics are deliberate opt-in via subagent_status: agents
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { buildDelegationDirective } from "./prompt.ts";
|
|
|
29
29
|
import { createRuntime } from "./runtime.ts";
|
|
30
30
|
import { runSetup } from "./setup.ts";
|
|
31
31
|
import { currentSubagentDepth } from "./spawn.ts";
|
|
32
|
+
import { bootstrapDurableState } from "./thread-lifecycle.ts";
|
|
32
33
|
import { registerLookupTools } from "./tools.ts";
|
|
33
34
|
import { clearActiveRunsWidget } from "./widget.ts";
|
|
34
35
|
|
|
@@ -77,6 +78,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
77
78
|
|
|
78
79
|
registerAnnouncements(pi, runtime);
|
|
79
80
|
|
|
81
|
+
// Durable bootstrap: restore parked/settled threads from the manifest so a
|
|
82
|
+
// reload or restart keeps status and resume working, then age out old
|
|
83
|
+
// records and sweep leaked temp/state directories. Fire-and-forget; every
|
|
84
|
+
// stage is best-effort and never blocks registration.
|
|
85
|
+
void bootstrapDurableState(runtime);
|
|
86
|
+
|
|
80
87
|
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
81
88
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
82
89
|
const config = await loadConfig(configPath);
|
|
@@ -86,7 +93,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
86
93
|
enabledNames: config.enabledAgents,
|
|
87
94
|
projectTrusted: ctx.isProjectTrusted?.() === true,
|
|
88
95
|
});
|
|
89
|
-
const directive = buildDelegationDirective(agents
|
|
96
|
+
const directive = buildDelegationDirective(agents);
|
|
90
97
|
if (!directive) return undefined;
|
|
91
98
|
return { systemPrompt: `${event.systemPrompt}\n${directive}` };
|
|
92
99
|
});
|
package/src/models.ts
CHANGED
|
@@ -86,6 +86,22 @@ export function findModelByRef(
|
|
|
86
86
|
return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Split persisted agent model overrides into the ones Pi still reports as
|
|
90
|
+
* available and the stale ones. Stale refs are dropped at session start (with
|
|
91
|
+
* a user notice) so the config never carries models that can no longer run. */
|
|
92
|
+
export function filterUnavailableModelOverrides(
|
|
93
|
+
agentModels: Record<string, string>,
|
|
94
|
+
models: readonly Model<Api>[],
|
|
95
|
+
): { kept: Record<string, string>; dropped: Array<{ agent: string; ref: string }> } {
|
|
96
|
+
const kept: Record<string, string> = {};
|
|
97
|
+
const dropped: Array<{ agent: string; ref: string }> = [];
|
|
98
|
+
for (const [agent, ref] of Object.entries(agentModels)) {
|
|
99
|
+
if (findModelByRef(models, ref)) kept[agent] = ref;
|
|
100
|
+
else dropped.push({ agent, ref });
|
|
101
|
+
}
|
|
102
|
+
return { kept, dropped };
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
/**
|
|
90
106
|
* Resolve one agent's runtime route:
|
|
91
107
|
*
|