@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/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
|
@@ -20,11 +20,11 @@ import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } fro
|
|
|
20
20
|
import { formatUsageCompact, sumUsage } from "./monitor.ts";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Worker fixes allowed after REVIEW_FAIL
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
26
|
*/
|
|
27
|
-
export const MAX_FIX_ROUNDS =
|
|
27
|
+
export const MAX_FIX_ROUNDS = 1;
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Whether a completed result should trigger the auto-fix loop instead of being
|
|
@@ -114,8 +114,9 @@ export function canStartManagedWorkflow(
|
|
|
114
114
|
export function getManagedWorkflowPlan(
|
|
115
115
|
result: SingleResult,
|
|
116
116
|
availability: WorkflowAgentAvailability,
|
|
117
|
+
advisoryReview = false,
|
|
117
118
|
): ManagedWorkflowPlan | undefined {
|
|
118
|
-
if (result.
|
|
119
|
+
if (result.dispatchFailed || isFailedResult(result)) return undefined;
|
|
119
120
|
if (result.agent === "worker" || result.agent === "cleaner") {
|
|
120
121
|
if (!availability.documenter && !availability.reviewer) return undefined;
|
|
121
122
|
return {
|
|
@@ -127,6 +128,9 @@ export function getManagedWorkflowPlan(
|
|
|
127
128
|
// owns the writer lane but delivers directly without an automatic code gate.
|
|
128
129
|
if (result.agent === "documenter") return undefined;
|
|
129
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;
|
|
130
134
|
|
|
131
135
|
const output = getResultOutput(result);
|
|
132
136
|
const verdict = reviewVerdict(output);
|
|
@@ -149,11 +153,12 @@ export function getManagedWorkflowPlan(
|
|
|
149
153
|
* Build the worker task brief for one fix round from a reviewer's findings.
|
|
150
154
|
* The worker gets the full review text — findings plus their fix instructions
|
|
151
155
|
* — and closes every finding either by implementing the instruction or by
|
|
152
|
-
* 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.
|
|
153
159
|
*/
|
|
154
160
|
export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, maxRounds: number): string {
|
|
155
161
|
const review = getResultOutput(reviewerResult);
|
|
156
|
-
const remaining = maxRounds - round;
|
|
157
162
|
return [
|
|
158
163
|
`Auto-fix round ${round} of ${maxRounds} (triggered by a failed review).`,
|
|
159
164
|
``,
|
|
@@ -162,18 +167,12 @@ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, m
|
|
|
162
167
|
review,
|
|
163
168
|
`---`,
|
|
164
169
|
``,
|
|
165
|
-
`
|
|
166
|
-
`
|
|
167
|
-
`
|
|
168
|
-
`
|
|
169
|
-
`
|
|
170
|
-
`
|
|
171
|
-
`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.`,
|
|
172
|
-
`After editing, run the project's format/build/tests when they exist and report`,
|
|
173
|
-
`exactly what you changed (paths + short rationale) plus any pushback, so a reviewer can verify.`,
|
|
174
|
-
remaining > 0
|
|
175
|
-
? `A reviewer will re-review your changes automatically after you finish.`
|
|
176
|
-
: `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.`,
|
|
177
176
|
].join("\n");
|
|
178
177
|
}
|
|
179
178
|
|
|
@@ -210,11 +209,11 @@ export function buildFinalDocumenterBrief(
|
|
|
210
209
|
`Final documentation sync: the review gate settled and you are the last managed stage before delivery.`,
|
|
211
210
|
``,
|
|
212
211
|
...reportSections,
|
|
213
|
-
`Inspect the actual git diff
|
|
214
|
-
`Apply every documentation note the reviews recorded, then synchronize stale README/docs, examples, API comments, docstrings
|
|
215
|
-
`
|
|
216
|
-
`
|
|
217
|
-
`
|
|
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.`,
|
|
218
217
|
`Report exact documentation/comment paths changed, or state explicitly that no sync was needed.`,
|
|
219
218
|
].join("\n");
|
|
220
219
|
}
|
|
@@ -314,15 +313,13 @@ export function buildFinalReviewBrief(
|
|
|
314
313
|
`---`,
|
|
315
314
|
``,
|
|
316
315
|
`Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
|
|
317
|
-
`Remain read-only.
|
|
318
|
-
|
|
319
|
-
`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.`,
|
|
320
318
|
...(options.documenterPending
|
|
321
319
|
? [
|
|
322
|
-
`A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding
|
|
323
|
-
`
|
|
324
|
-
`DOCUMENTATION:
|
|
325
|
-
`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.`,
|
|
326
323
|
]
|
|
327
324
|
: [
|
|
328
325
|
`No documenter is pending, so documentation drift is an ordinary gate finding.`,
|
|
@@ -356,15 +353,14 @@ export function buildReReviewBrief(
|
|
|
356
353
|
review,
|
|
357
354
|
`---`,
|
|
358
355
|
``,
|
|
359
|
-
`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):`,
|
|
360
357
|
`---`,
|
|
361
358
|
workerReport,
|
|
362
359
|
`---`,
|
|
363
360
|
``,
|
|
364
361
|
`Rule on EVERY previous finding: resolved, or still open. Judge the code as it now stands — a finding is`,
|
|
365
362
|
`resolved when the pending diff fixes it soundly, whether or not the worker followed your fix instruction.`,
|
|
366
|
-
`
|
|
367
|
-
`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.`,
|
|
368
364
|
`Run \`git diff\` to see what changed, then add NEW findings only for defects this round's edits introduced or exposed.`,
|
|
369
365
|
`Re-review never opens findings unrelated to this round's edits; issues the earlier review missed belong to a fresh gate.`,
|
|
370
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);
|
package/src/monitor.ts
CHANGED
|
@@ -18,8 +18,8 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
|
18
18
|
// Types
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
|
-
export type RunStatus = "queued" | "running" | "
|
|
22
|
-
export type ContinuationKind = "resume-retained" | "resume-appended"
|
|
21
|
+
export type RunStatus = "queued" | "running" | "interrupting" | "parked" | "done" | "failed";
|
|
22
|
+
export type ContinuationKind = "resume-retained" | "resume-appended";
|
|
23
23
|
export type WorkflowStageStatus = "done" | "active" | "pending" | "changes" | "failed";
|
|
24
24
|
|
|
25
25
|
/** Ephemeral projection of one real or currently planned managed stage. It is
|
|
@@ -31,7 +31,7 @@ export interface WorkflowStage {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function isRunActiveStatus(status: RunStatus): boolean {
|
|
34
|
-
return status === "queued" || status === "running" || status === "
|
|
34
|
+
return status === "queued" || status === "running" || status === "interrupting";
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Durable integration projection of a worktree-isolated run: pending before
|
|
@@ -57,8 +57,6 @@ export interface RunView {
|
|
|
57
57
|
/** Short worktree-group identity (mkdtemp suffix) shared by every run inside
|
|
58
58
|
* one isolated worktree; changes when a continuation worktree is created. */
|
|
59
59
|
worktreeId?: string;
|
|
60
|
-
forkedFromRunId?: number;
|
|
61
|
-
forkChildRunIds?: number[];
|
|
62
60
|
status: RunStatus;
|
|
63
61
|
usage: UsageStats;
|
|
64
62
|
/** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
|
|
@@ -94,7 +92,6 @@ export interface RunChainMeta {
|
|
|
94
92
|
parentRunId?: number;
|
|
95
93
|
isolation?: IsolationMode;
|
|
96
94
|
worktreeId?: string;
|
|
97
|
-
forkedFromRunId?: number;
|
|
98
95
|
continuationKind?: ContinuationKind;
|
|
99
96
|
}
|
|
100
97
|
|
|
@@ -325,13 +322,10 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
|
|
|
325
322
|
return formatDuration(elapsedMilliseconds(run, now));
|
|
326
323
|
}
|
|
327
324
|
|
|
328
|
-
export function continuationLabel(kind: ContinuationKind | undefined
|
|
325
|
+
export function continuationLabel(kind: ContinuationKind | undefined): string | undefined {
|
|
329
326
|
switch (kind) {
|
|
330
327
|
case "resume-retained": return "resume: current objective";
|
|
331
328
|
case "resume-appended": return "resume: appended objective";
|
|
332
|
-
case "fork-retained": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: current objective`;
|
|
333
|
-
case "fork-appended": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: appended objective`;
|
|
334
|
-
case "retarget": return "retarget: replacement objective";
|
|
335
329
|
default: return undefined;
|
|
336
330
|
}
|
|
337
331
|
}
|
|
@@ -452,6 +446,26 @@ export class MonitorStore {
|
|
|
452
446
|
return this.nextId++;
|
|
453
447
|
}
|
|
454
448
|
|
|
449
|
+
/** Keep newly allocated ids above a restored id so reload-restored threads
|
|
450
|
+
* never collide with runs started in the current process. */
|
|
451
|
+
ensureNextIdAbove(id: number): void {
|
|
452
|
+
if (id >= this.nextId) this.nextId = id + 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Re-register a durable thread restored from a previous process. The row
|
|
456
|
+
* keeps its stable id and historical elapsed time. */
|
|
457
|
+
restoreRun(view: Pick<RunView, "id" | "agent" | "task" | "status"> & Partial<RunView>): void {
|
|
458
|
+
if (this.find(view.id)) return;
|
|
459
|
+
this.runs.push({
|
|
460
|
+
label: runLabel(view.task),
|
|
461
|
+
usage: emptyUsage(),
|
|
462
|
+
elapsedMs: 0,
|
|
463
|
+
...view,
|
|
464
|
+
});
|
|
465
|
+
this.ensureNextIdAbove(view.id);
|
|
466
|
+
this.notify();
|
|
467
|
+
}
|
|
468
|
+
|
|
455
469
|
addRun(agent: string, task: string, model?: string, thinking?: string, meta?: RunChainMeta): number {
|
|
456
470
|
const id = this.reserveRunId();
|
|
457
471
|
this.runs.push({
|
|
@@ -468,7 +482,6 @@ export class MonitorStore {
|
|
|
468
482
|
...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
|
|
469
483
|
...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
|
|
470
484
|
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined, ...(meta.worktreeId ? { worktreeId: meta.worktreeId } : {}) } : {}),
|
|
471
|
-
...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
|
|
472
485
|
...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
|
|
473
486
|
});
|
|
474
487
|
this.notify();
|
|
@@ -479,8 +492,8 @@ export class MonitorStore {
|
|
|
479
492
|
const run = this.find(id);
|
|
480
493
|
if (!run) return;
|
|
481
494
|
const previousStatus = run.status;
|
|
482
|
-
const wasExecuting = previousStatus === "running" || previousStatus === "
|
|
483
|
-
const isExecuting = status === "running" || status === "
|
|
495
|
+
const wasExecuting = previousStatus === "running" || previousStatus === "interrupting";
|
|
496
|
+
const isExecuting = status === "running" || status === "interrupting";
|
|
484
497
|
const now = Date.now();
|
|
485
498
|
run.status = status;
|
|
486
499
|
if (isExecuting && !wasExecuting) {
|
|
@@ -582,18 +595,8 @@ export class MonitorStore {
|
|
|
582
595
|
this.notify();
|
|
583
596
|
}
|
|
584
597
|
|
|
585
|
-
setForkRelation(sourceRunId: number, childRunId: number): void {
|
|
586
|
-
const source = this.find(sourceRunId);
|
|
587
|
-
if (source) {
|
|
588
|
-
source.forkChildRunIds ??= [];
|
|
589
|
-
if (!source.forkChildRunIds.includes(childRunId)) source.forkChildRunIds.push(childRunId);
|
|
590
|
-
}
|
|
591
|
-
const child = this.find(childRunId);
|
|
592
|
-
if (child) child.forkedFromRunId = sourceRunId;
|
|
593
|
-
this.notify();
|
|
594
|
-
}
|
|
595
598
|
|
|
596
|
-
/** Update the objective shown for a
|
|
599
|
+
/** Update the objective shown for a resumed generation. */
|
|
597
600
|
setTask(id: number, task: string): void {
|
|
598
601
|
const run = this.find(id);
|
|
599
602
|
if (!run) return;
|
|
@@ -706,7 +709,7 @@ export class MonitorStore {
|
|
|
706
709
|
summarize(run: RunView): string {
|
|
707
710
|
const usage = formatUsageCompact(run.usage);
|
|
708
711
|
const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
|
|
709
|
-
const continuation = continuationLabel(run.continuationKind
|
|
712
|
+
const continuation = continuationLabel(run.continuationKind);
|
|
710
713
|
if (continuation) parts.push(continuation);
|
|
711
714
|
if (run.relationLabel) parts.push(run.relationLabel);
|
|
712
715
|
if (!run.managedWorkflow && run.model) parts.push(run.model);
|
|
@@ -743,8 +746,6 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
|
|
|
743
746
|
switch (status) {
|
|
744
747
|
case "running":
|
|
745
748
|
return theme.fg("accent", "●");
|
|
746
|
-
case "steering":
|
|
747
|
-
return theme.fg("accent", "◆");
|
|
748
749
|
case "interrupting":
|
|
749
750
|
return theme.fg("warning", "◐");
|
|
750
751
|
case "parked":
|
|
@@ -765,8 +766,6 @@ export function statusLabel(status: RunStatus): string {
|
|
|
765
766
|
return "ready";
|
|
766
767
|
case "running":
|
|
767
768
|
return "running";
|
|
768
|
-
case "steering":
|
|
769
|
-
return "steering";
|
|
770
769
|
case "interrupting":
|
|
771
770
|
return "interrupting";
|
|
772
771
|
case "parked":
|