@ferris1225/pi-subagents 4.1.13 → 4.1.16
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 +333 -291
- package/agents/cleaner.md +24 -30
- package/agents/documenter.md +23 -20
- package/agents/explorer.md +7 -2
- package/agents/reviewer.md +82 -77
- package/agents/worker.md +45 -37
- package/package.json +1 -1
- package/src/announcements.ts +59 -54
- package/src/background.ts +26 -10
- package/src/completion.ts +7 -1
- package/src/config.ts +1 -1
- package/src/dispatch.ts +133 -133
- package/src/durable.ts +85 -19
- package/src/format.ts +179 -167
- package/src/monitor.ts +4 -2
- package/src/prompt.ts +14 -27
- package/src/runtime.ts +18 -14
- package/src/setup.ts +3 -3
- package/src/spawn.ts +650 -642
- package/src/temp-hygiene.ts +0 -28
- package/src/thread-lifecycle.ts +85 -99
- package/src/tools.ts +712 -708
- package/src/widget.ts +9 -0
- package/src/workflow.ts +199 -248
- package/src/worktree.ts +64 -37
package/src/dispatch.ts
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `subagent` tool: dispatches explorer/worker/cleaner/documenter/reviewer agents as isolated pi
|
|
3
3
|
* child processes, single or parallel. Owns the public dispatch contract,
|
|
4
|
-
* per-run status tracking, managed worker/cleaner → reviewer
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* thread generations, final integration, and completion ownership live in
|
|
8
|
-
* thread-lifecycle.ts.
|
|
4
|
+
* per-run status tracking, the managed worker/cleaner → reviewer gate, and
|
|
5
|
+
* internal step launching. Stable thread generations, final integration, and
|
|
6
|
+
* completion ownership live in thread-lifecycle.ts.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
12
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
11
|
import { Text } from "@earendil-works/pi-tui";
|
|
14
|
-
import { resolve } from "node:path";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
15
13
|
import { Type } from "typebox";
|
|
16
|
-
import { discoverAgents, resolveAgentTools, type AgentConfig } from "./agents.ts";
|
|
17
|
-
import { getStateRoot } from "./durable.ts";
|
|
14
|
+
import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
|
|
18
15
|
import { loadConfig } from "./config.ts";
|
|
19
16
|
import { formatUsage, queuedResult } from "./format.ts";
|
|
20
17
|
import {
|
|
21
|
-
buildFinalDocumenterBrief,
|
|
22
18
|
buildFinalReviewBrief,
|
|
23
|
-
|
|
19
|
+
buildReReviewBrief,
|
|
20
|
+
buildReviewerFixBrief,
|
|
21
|
+
MAX_REVIEW_FIX_ROUNDS,
|
|
24
22
|
type ChainStep,
|
|
25
23
|
type ManagedWorkflowOutcome,
|
|
26
24
|
} from "./workflow.ts";
|
|
@@ -36,6 +34,7 @@ import {
|
|
|
36
34
|
import type { SubagentRuntime } from "./runtime.ts";
|
|
37
35
|
import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
|
|
38
36
|
import {
|
|
37
|
+
getProjectRoot,
|
|
39
38
|
getResultOutput,
|
|
40
39
|
isFailedResult,
|
|
41
40
|
reviewVerdict,
|
|
@@ -65,11 +64,6 @@ const IsolationSchema = Type.Optional(
|
|
|
65
64
|
StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
|
|
66
65
|
);
|
|
67
66
|
|
|
68
|
-
const ADVISORY_DESCRIPTION =
|
|
69
|
-
"Report-only reviewer dispatch: findings return to you for the decision; a verdict (if emitted anyway) never starts the auto-fix chain. Use when re-verifying work you already fixed yourself.";
|
|
70
|
-
|
|
71
|
-
const AdvisorySchema = Type.Optional(Type.Boolean({ description: ADVISORY_DESCRIPTION }));
|
|
72
|
-
|
|
73
67
|
const TaskItem = Type.Object({
|
|
74
68
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
75
69
|
task: Type.String({
|
|
@@ -78,7 +72,6 @@ const TaskItem = Type.Object({
|
|
|
78
72
|
}),
|
|
79
73
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
80
74
|
isolation: IsolationSchema,
|
|
81
|
-
advisory: AdvisorySchema,
|
|
82
75
|
});
|
|
83
76
|
|
|
84
77
|
const SubagentParams = Type.Object({
|
|
@@ -89,22 +82,48 @@ const SubagentParams = Type.Object({
|
|
|
89
82
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
|
90
83
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
91
84
|
isolation: IsolationSchema,
|
|
92
|
-
advisory: AdvisorySchema,
|
|
93
85
|
});
|
|
94
86
|
|
|
95
|
-
|
|
87
|
+
/** Roles that default to worktree isolation in parallel dispatches even when
|
|
88
|
+
* the live catalog cannot be consulted (render-only call sites). Custom
|
|
89
|
+
* write-capable agents join them via isWriteCapableAgent on the execute path. */
|
|
90
|
+
const WORKTREE_DEFAULT_AGENTS = new Set(["worker", "cleaner", "documenter"]);
|
|
91
|
+
|
|
92
|
+
/** Resolve the default isolation for a dispatch. Parallel write-capable agents
|
|
93
|
+
* get a detached worktree: shared writers serialize on the repository lane, so
|
|
94
|
+
* defaulting them to shared would turn one parallel batch into a convoy that
|
|
95
|
+
* also parks process slots. Explicit requests always win. */
|
|
96
|
+
export function defaultIsolationMode(
|
|
97
|
+
mode: "single" | "parallel",
|
|
98
|
+
agentName: string,
|
|
99
|
+
requested?: IsolationMode,
|
|
100
|
+
writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName),
|
|
101
|
+
): IsolationMode {
|
|
96
102
|
if (requested) return requested;
|
|
97
|
-
return mode === "parallel" &&
|
|
103
|
+
return mode === "parallel" && writeCapable ? "worktree" : "shared";
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
function workflowStageStatus(result: SingleResult): WorkflowStageStatus {
|
|
106
|
+
function workflowStageStatus(result: SingleResult, relation?: string): WorkflowStageStatus {
|
|
101
107
|
if (isFailedResult(result)) return "failed";
|
|
108
|
+
if (relation === "review fix") return "done";
|
|
102
109
|
if (result.agent !== "reviewer") return "done";
|
|
103
110
|
const verdict = reviewVerdict(getResultOutput(result));
|
|
104
111
|
if (verdict === "fail") return "changes";
|
|
105
112
|
return verdict === "pass" ? "done" : "failed";
|
|
106
113
|
}
|
|
107
114
|
|
|
115
|
+
/** The runtime-granted write continuation of a failed gate: same reviewer
|
|
116
|
+
* role, model, and retained session, but the read-only boundary is lifted for
|
|
117
|
+
* this one stage so it applies its own fix instructions. One line only: the
|
|
118
|
+
* full fix-stage contract is already in the retained session's prompt. */
|
|
119
|
+
function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
|
|
120
|
+
return {
|
|
121
|
+
...agent,
|
|
122
|
+
tools: undefined,
|
|
123
|
+
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as specified, verify, and report what changed; never edit during a review and never emit a verdict here.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
109
128
|
// Latest dispatch environment. The dispatcher is created once per process so
|
|
110
129
|
// restored threads can resume before any dispatch has run; each execute
|
|
@@ -183,25 +202,43 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
183
202
|
(mode: "single" | "parallel", background = false) =>
|
|
184
203
|
(results: SingleResult[]): SubagentDetails => ({ mode, results, background });
|
|
185
204
|
|
|
205
|
+
/** Pacing note appended to dispatch confirmations whenever runs are actually
|
|
206
|
+
* waiting: states the real slot capacity so ordinary queueing is never
|
|
207
|
+
* mistaken for a hard dispatch limit. Empty when everything is running. */
|
|
208
|
+
const queuePacingNote = (): string => {
|
|
209
|
+
const runs = monitor.getRuns();
|
|
210
|
+
const waiting = runs.filter((run) => run.status === "queued").length;
|
|
211
|
+
if (waiting === 0) return "";
|
|
212
|
+
const running = runs.filter((run) => run.status === "running" || run.status === "interrupting").length;
|
|
213
|
+
return ` Pacing: ${running} running · ${waiting} waiting for a free process slot (capacity ${runtime.backgroundQueue.capacity}); waiting runs start automatically as slots free — keep dispatching independent units.`;
|
|
214
|
+
};
|
|
215
|
+
|
|
186
216
|
/** Launch one workflow-internal child in a fresh model context. It sees the
|
|
187
217
|
* parent's exact repository/worktree state and is registered by its own id,
|
|
188
|
-
* but never enters top-level lifecycle policy or completion delivery.
|
|
218
|
+
* but never enters top-level lifecycle policy or completion delivery.
|
|
219
|
+
* `stage` continues a retained session (the reviewer fix stage) and/or
|
|
220
|
+
* replaces the resolved agent (lifting the reviewer read-only boundary). */
|
|
189
221
|
const launchInWorkflow = async (
|
|
190
222
|
request: ManagedWorkflowRequest,
|
|
191
223
|
agentName: string,
|
|
192
224
|
task: string,
|
|
193
225
|
meta: RunChainMeta,
|
|
226
|
+
stage: {
|
|
227
|
+
agentOverride?: AgentConfig;
|
|
228
|
+
session?: { sessionId: string; sessionDir: string };
|
|
229
|
+
} = {},
|
|
194
230
|
): Promise<{ runId: number; result: SingleResult }> => {
|
|
195
231
|
const discoveredAgent = request.agents.find((candidate) => candidate.name === agentName);
|
|
196
232
|
if (!discoveredAgent) {
|
|
197
233
|
throw new Error(`Managed workflow requires enabled agent "${agentName}", but discovery did not provide it.`);
|
|
198
234
|
}
|
|
235
|
+
const boundaryAgent = stage.agentOverride ?? discoveredAgent;
|
|
199
236
|
const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
|
|
200
|
-
resolveAgentTools({ ...candidate, tools:
|
|
201
|
-
const agent = resolveLiveAgentTools(
|
|
202
|
-
// Workflow policy (
|
|
203
|
-
//
|
|
204
|
-
//
|
|
237
|
+
resolveAgentTools({ ...candidate, tools: boundaryAgent.tools }, runtime.getActiveTools());
|
|
238
|
+
const agent = resolveLiveAgentTools(boundaryAgent);
|
|
239
|
+
// Workflow policy (agents) stays fixed for the chain, but model/thinking
|
|
240
|
+
// routes are re-read per stage so config edits apply to stages that have
|
|
241
|
+
// not launched yet.
|
|
205
242
|
const stageConfig = await loadConfig(runtime.configPath).catch(() => request.config);
|
|
206
243
|
const resolvedRoute = resolveDispatchModelRoute(agent, stageConfig, request.ctx);
|
|
207
244
|
const route = request.isolation === "worktree"
|
|
@@ -229,7 +266,10 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
229
266
|
onLive,
|
|
230
267
|
makeDetails: makeDetails("single", true),
|
|
231
268
|
idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
|
|
232
|
-
sessionRoot:
|
|
269
|
+
sessionRoot: join(getProjectRoot(runtime.configPath, request.executionCwd), "sessions"),
|
|
270
|
+
...(stage.session
|
|
271
|
+
? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
|
|
272
|
+
: {}),
|
|
233
273
|
},
|
|
234
274
|
route.mainFallbackRef,
|
|
235
275
|
);
|
|
@@ -303,62 +343,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
303
343
|
status: workflowStageStatus(initialStepResult),
|
|
304
344
|
}];
|
|
305
345
|
let reviewStage: WorkflowStage | undefined;
|
|
306
|
-
if (
|
|
346
|
+
if (enabled("reviewer")) {
|
|
307
347
|
reviewStage = { agent: "reviewer", relation: "review", status: "pending" };
|
|
308
348
|
workflowStages.push(reviewStage);
|
|
309
349
|
}
|
|
310
|
-
let documentationStage: WorkflowStage | undefined;
|
|
311
|
-
if (enabled("documenter")) {
|
|
312
|
-
documentationStage = { agent: "documenter", relation: "docs", status: "pending" };
|
|
313
|
-
workflowStages.push(documentationStage);
|
|
314
|
-
}
|
|
315
350
|
const publishWorkflowStages = (): void => {
|
|
316
351
|
monitor.setWorkflowStages(request.parentRunId, workflowStages);
|
|
317
352
|
};
|
|
318
|
-
const insertBeforeDocumentation = (stage: WorkflowStage): void => {
|
|
319
|
-
const documentationIndex = documentationStage
|
|
320
|
-
? workflowStages.indexOf(documentationStage)
|
|
321
|
-
: -1;
|
|
322
|
-
if (documentationIndex === -1) workflowStages.push(stage);
|
|
323
|
-
else workflowStages.splice(documentationIndex, 0, stage);
|
|
324
|
-
};
|
|
325
|
-
const removeDocumentationStage = (): void => {
|
|
326
|
-
if (!documentationStage) return;
|
|
327
|
-
const index = workflowStages.indexOf(documentationStage);
|
|
328
|
-
if (index !== -1) workflowStages.splice(index, 1);
|
|
329
|
-
documentationStage = undefined;
|
|
330
|
-
publishWorkflowStages();
|
|
331
|
-
};
|
|
332
353
|
publishWorkflowStages();
|
|
333
354
|
|
|
334
355
|
const launchStep = async (
|
|
335
356
|
agentName: string,
|
|
336
357
|
task: string,
|
|
337
358
|
relation: string,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
359
|
+
stage: WorkflowStage,
|
|
360
|
+
stageOptions: {
|
|
361
|
+
agentOverride?: AgentConfig;
|
|
362
|
+
session?: { sessionId: string; sessionDir: string };
|
|
342
363
|
} = {},
|
|
343
364
|
): Promise<SingleResult> => {
|
|
344
365
|
if (!enabled(agentName)) {
|
|
345
366
|
throw new Error(`Managed workflow cannot launch disabled or missing agent "${agentName}".`);
|
|
346
367
|
}
|
|
347
|
-
const stage: WorkflowStage = projection.stage ?? {
|
|
348
|
-
agent: agentName,
|
|
349
|
-
relation: projection.timelineRelation ?? relation,
|
|
350
|
-
status: "pending",
|
|
351
|
-
};
|
|
352
|
-
if (!projection.stage) insertBeforeDocumentation(stage);
|
|
353
368
|
stage.status = "active";
|
|
354
369
|
publishWorkflowStages();
|
|
355
370
|
try {
|
|
356
371
|
const step = await launchInWorkflow(request, agentName, task, {
|
|
357
372
|
groupId: request.groupId,
|
|
358
|
-
relationLabel:
|
|
373
|
+
relationLabel: relation,
|
|
359
374
|
parentRunId: request.parentRunId,
|
|
360
|
-
});
|
|
361
|
-
stage.status = workflowStageStatus(step.result);
|
|
375
|
+
}, stageOptions);
|
|
376
|
+
stage.status = workflowStageStatus(step.result, relation);
|
|
362
377
|
publishWorkflowStages();
|
|
363
378
|
request.rememberLatest(step.result);
|
|
364
379
|
steps.push({ ...step, relation });
|
|
@@ -370,68 +385,51 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
370
385
|
}
|
|
371
386
|
};
|
|
372
387
|
|
|
373
|
-
/** Run the low-cost final documentation sync only when the terminal REVIEW_PASS
|
|
374
|
-
* reports drift or omits the marker. A failed process, missing verdict, or
|
|
375
|
-
* REVIEW_FAIL never writes docs. With no reviewer, retain the conservative
|
|
376
|
-
* writer → documenter fallback. */
|
|
377
|
-
const runFinalDocumentation = async (
|
|
378
|
-
lastWriterResult: SingleResult | undefined,
|
|
379
|
-
finalReviewResult: SingleResult | undefined,
|
|
380
|
-
): Promise<void> => {
|
|
381
|
-
if (!canContinue() || !enabled("documenter")) return;
|
|
382
|
-
if (lastWriterResult?.agent === "documenter") {
|
|
383
|
-
removeDocumentationStage();
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
if (finalReviewResult) {
|
|
387
|
-
if (isFailedResult(finalReviewResult)) {
|
|
388
|
-
removeDocumentationStage();
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
const reviewOutput = getResultOutput(finalReviewResult);
|
|
392
|
-
if (
|
|
393
|
-
reviewVerdict(reviewOutput) !== "pass" ||
|
|
394
|
-
documentationDisposition(reviewOutput) === "clean"
|
|
395
|
-
) {
|
|
396
|
-
removeDocumentationStage();
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
documentationStage ??= { agent: "documenter", relation: "docs", status: "pending" };
|
|
401
|
-
if (!workflowStages.includes(documentationStage)) workflowStages.push(documentationStage);
|
|
402
|
-
await launchStep(
|
|
403
|
-
"documenter",
|
|
404
|
-
buildFinalDocumenterBrief(lastWriterResult, finalReviewResult),
|
|
405
|
-
"final documentation sync",
|
|
406
|
-
{ stage: documentationStage },
|
|
407
|
-
);
|
|
408
|
-
};
|
|
409
|
-
|
|
410
388
|
try {
|
|
411
389
|
// Park/stop/shutdown may win after the top-level child settles but
|
|
412
390
|
// before this continuation starts. Preserve that stable checkpoint and
|
|
413
391
|
// never create an already-aborted downstream child.
|
|
414
|
-
if (!canContinue()) return {
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
await runFinalDocumentation(undefined, initialStepResult);
|
|
419
|
-
} else if (enabled("reviewer")) {
|
|
420
|
-
// A failing gate is not a workflow continuation: the REVIEW_FAIL
|
|
421
|
-
// report and its fix instructions are delivered to the main agent,
|
|
422
|
-
// which owns the fix decision.
|
|
423
|
-
const gateReview = await launchStep(
|
|
392
|
+
if (!canContinue()) return { steps };
|
|
393
|
+
if (reviewStage) {
|
|
394
|
+
const discoveredReviewer = request.agents.find((candidate) => candidate.name === "reviewer")!;
|
|
395
|
+
let gateReview = await launchStep(
|
|
424
396
|
"reviewer",
|
|
425
|
-
buildFinalReviewBrief(initialStepResult
|
|
397
|
+
buildFinalReviewBrief(initialStepResult),
|
|
426
398
|
"final review",
|
|
427
|
-
|
|
399
|
+
reviewStage,
|
|
428
400
|
);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
401
|
+
// The failing gate owns its fixes: the same retained
|
|
402
|
+
// reviewer session applies its own fix instructions with write access,
|
|
403
|
+
// then a converging re-review verifies the fixes. The cap only stops
|
|
404
|
+
// pathological burn and hands the still-failing gate to the main agent.
|
|
405
|
+
for (let round = 1; round <= MAX_REVIEW_FIX_ROUNDS; round++) {
|
|
406
|
+
const gateSession = gateReview.sessionId && gateReview.sessionDir
|
|
407
|
+
? { sessionId: gateReview.sessionId, sessionDir: gateReview.sessionDir }
|
|
408
|
+
: undefined;
|
|
409
|
+
if (reviewVerdict(getResultOutput(gateReview)) !== "fail" || !gateSession || !canContinue()) break;
|
|
410
|
+
const fixStage: WorkflowStage = { agent: "reviewer", relation: "fix", status: "pending" };
|
|
411
|
+
workflowStages.push(fixStage);
|
|
412
|
+
publishWorkflowStages();
|
|
413
|
+
const fixResult = await launchStep(
|
|
414
|
+
"reviewer",
|
|
415
|
+
buildReviewerFixBrief(getResultOutput(gateReview)),
|
|
416
|
+
"review fix",
|
|
417
|
+
fixStage,
|
|
418
|
+
{ agentOverride: withReviewerFixStageAgent(discoveredReviewer), session: gateSession },
|
|
419
|
+
);
|
|
420
|
+
if (isFailedResult(fixResult) || !canContinue()) break;
|
|
421
|
+
const reReviewStage: WorkflowStage = { agent: "reviewer", relation: "review", status: "pending" };
|
|
422
|
+
workflowStages.push(reReviewStage);
|
|
423
|
+
publishWorkflowStages();
|
|
424
|
+
gateReview = await launchStep(
|
|
425
|
+
"reviewer",
|
|
426
|
+
buildReReviewBrief(fixResult, round),
|
|
427
|
+
round === 1 ? "re-review" : `re-review ${round}`,
|
|
428
|
+
reReviewStage,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
433
431
|
}
|
|
434
|
-
return {
|
|
432
|
+
return { steps };
|
|
435
433
|
} finally {
|
|
436
434
|
removeWorkflowGroup(request.groupId);
|
|
437
435
|
}
|
|
@@ -456,15 +454,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
456
454
|
name: "subagent",
|
|
457
455
|
label: "Subagent",
|
|
458
456
|
description: [
|
|
459
|
-
"Dispatch enabled
|
|
460
|
-
"
|
|
461
|
-
"
|
|
462
|
-
"
|
|
463
|
-
"A
|
|
464
|
-
"Use subagent_control to resume a parked or settled thread's retained context by stable run id; use subagent_stop for destructive cancellation.",
|
|
457
|
+
"Dispatch enabled agents as isolated leaf Pi child processes, singly or in parallel. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
|
|
458
|
+
"Put every genuinely independent unit in one `tasks` array (no per-call cap). Process slots scale with the machine; when all slots are busy the extra runs simply wait and start automatically as slots free — waiting is pacing, never a rejection or a limit on how much you may dispatch.",
|
|
459
|
+
"Every parallel write-capable agent (worker, cleaner, documenter, custom writers) defaults to a detached Git worktree, so writers run concurrently; shared mode serializes same-repository writers. Explicit `shared` keeps the caller's checkout; setup failure never silently falls back to shared.",
|
|
460
|
+
"Successful worker/cleaner runs get one automatic reviewer gate; a failing gate is fixed by the reviewer itself in a write-enabled continuation of the same session and re-reviewed in bounded, converging rounds. 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.",
|
|
461
|
+
"A configured child-model failure continues the retained session on the current main model. Resume a parked or settled thread with subagent_control by run id; use subagent_stop for destructive cancellation.",
|
|
465
462
|
].join(" "),
|
|
466
463
|
promptSnippet:
|
|
467
|
-
"Dispatch isolated background agents for
|
|
464
|
+
"Dispatch isolated background agents for recon, implementation, cleanup, docs, or review; never blocks your turn, and REVIEW_FAIL findings return to you to fix.",
|
|
468
465
|
parameters: SubagentParams,
|
|
469
466
|
|
|
470
467
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -523,21 +520,27 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
523
520
|
};
|
|
524
521
|
}
|
|
525
522
|
|
|
526
|
-
// Sub-agents
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
523
|
+
// Sub-agents run detached from the foreground turn: the editor stays
|
|
524
|
+
// available and completion messages later wake the main agent. The turn is
|
|
525
|
+
// NOT terminated here — the model can keep dispatching independent units
|
|
526
|
+
// or do its own work, and the background queue paces how many child
|
|
527
|
+
// processes actually run at once, so no per-call task cap is enforced.
|
|
530
528
|
if (params.tasks && params.tasks.length > 0) {
|
|
531
529
|
const results: SingleResult[] = [];
|
|
532
530
|
// Preserve caller order (and deterministic completion batching) while
|
|
533
531
|
// preparing each isolated filesystem before its queue entry can start.
|
|
534
532
|
for (const item of params.tasks) {
|
|
533
|
+
const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
|
|
535
534
|
results.push(await startBackground(
|
|
536
535
|
item.agent,
|
|
537
536
|
item.task,
|
|
538
537
|
item.cwd,
|
|
539
|
-
defaultIsolationMode(
|
|
540
|
-
|
|
538
|
+
defaultIsolationMode(
|
|
539
|
+
"parallel",
|
|
540
|
+
item.agent,
|
|
541
|
+
item.isolation as IsolationMode | undefined,
|
|
542
|
+
catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
|
|
543
|
+
),
|
|
541
544
|
));
|
|
542
545
|
}
|
|
543
546
|
const startedRuns = results.filter((result) => result.exitCode === -1);
|
|
@@ -558,15 +561,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
558
561
|
throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
|
|
559
562
|
}
|
|
560
563
|
const text = [
|
|
561
|
-
`Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}.
|
|
564
|
+
`Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. They run in the background and never block you — dispatch more independent units now or keep working; each result resumes you automatically when you are idle.`,
|
|
562
565
|
...(failureLines.length > 0
|
|
563
566
|
? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
|
|
564
567
|
: []),
|
|
565
|
-
].join("\n");
|
|
568
|
+
].join("\n") + queuePacingNote();
|
|
566
569
|
return {
|
|
567
570
|
content: [{ type: "text", text }],
|
|
568
571
|
details: makeDetails("parallel", true)(results),
|
|
569
|
-
terminate: true,
|
|
570
572
|
};
|
|
571
573
|
}
|
|
572
574
|
|
|
@@ -575,16 +577,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
575
577
|
params.task as string,
|
|
576
578
|
params.cwd,
|
|
577
579
|
defaultIsolationMode("single", params.agent as string, params.isolation as IsolationMode | undefined),
|
|
578
|
-
{ advisoryReview: params.advisory === true },
|
|
579
580
|
);
|
|
580
581
|
if (result.exitCode !== -1) {
|
|
581
582
|
throw new Error(getResultOutput(result));
|
|
582
583
|
}
|
|
583
584
|
const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
|
|
584
585
|
return {
|
|
585
|
-
content: [{ type: "text", text: `Started ${runRef} in the background.
|
|
586
|
+
content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle.${queuePacingNote()}` }],
|
|
586
587
|
details: makeDetails("single", true)([result]),
|
|
587
|
-
terminate: true,
|
|
588
588
|
};
|
|
589
589
|
|
|
590
590
|
},
|
package/src/durable.ts
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Durable thread state: a manifest next to the config that lets
|
|
3
|
-
*
|
|
2
|
+
* Durable thread state: a manifest next to the config that lets interrupted
|
|
3
|
+
* (parked) sub-agent threads survive pi reloads and restarts, plus the durable
|
|
4
4
|
* state root that keeps their retained sessions and isolated worktrees out of
|
|
5
5
|
* the OS temp directory.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Only parked threads are ever recorded: a thread that settles normally drops
|
|
8
|
+
* its record, so the manifest file exists exactly while unfinished work needs
|
|
9
|
+
* it and disappears on its own. Records are small path/state snapshots, never
|
|
10
|
+
* full transcripts; the retained Pi session files and worktrees they point at
|
|
11
|
+
* remain the actual context. Writes are atomic (tmp+rename) and serialized
|
|
12
|
+
* through the same withFileMutationQueue as the recovery manifest.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import { existsSync } from "node:fs";
|
|
16
|
+
import { existsSync, type Dirent, readdirSync, statSync } from "node:fs";
|
|
15
17
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
16
18
|
import { dirname, join } from "node:path";
|
|
17
19
|
import type { UsageStats } from "./rpc-run.ts";
|
|
18
20
|
import type { SubagentThread } from "./runtime.ts";
|
|
19
|
-
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
21
|
+
import { getResultOutput, isFailedResult, getProjectRoot, PROJECT_ROOTS_DIR_NAME, type SingleResult } from "./spawn.ts";
|
|
20
22
|
import {
|
|
23
|
+
isPathInside,
|
|
21
24
|
restoreWorktreeIsolation,
|
|
22
25
|
type IsolationMode,
|
|
23
26
|
normalizeWorktreeSnapshot,
|
|
@@ -27,11 +30,16 @@ import {
|
|
|
27
30
|
|
|
28
31
|
export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
|
|
29
32
|
const THREADS_MANIFEST_VERSION = 1;
|
|
30
|
-
export const STATE_DIR_NAME = "pi-subagents-state";
|
|
31
33
|
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
|
|
34
|
+
/** Project directories whose newest file has not been touched for this long
|
|
35
|
+
* are deleted wholesale on load, so per-project sessions/worktrees/results
|
|
36
|
+
* can never accumulate forever. Parked threads' manifest references always
|
|
37
|
+
* win over the age rule. */
|
|
38
|
+
export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
|
|
39
|
+
|
|
40
|
+
/** Fixed retention: parked work (which may hold unintegrated changes) stops
|
|
41
|
+
* being resumable after a month. Older manifests may still carry settled
|
|
42
|
+
* records from previous versions; restore discards them on sight. */
|
|
35
43
|
export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
36
44
|
|
|
37
45
|
/** Result excerpts are for status display after restore, not full transcripts. */
|
|
@@ -74,10 +82,6 @@ interface ThreadsManifest {
|
|
|
74
82
|
records: ThreadRecord[];
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
export function getStateRoot(configPath: string): string {
|
|
78
|
-
return join(dirname(configPath), STATE_DIR_NAME);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
85
|
export function getThreadsManifestPath(configPath: string): string {
|
|
82
86
|
return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
|
|
83
87
|
}
|
|
@@ -297,7 +301,7 @@ async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
|
|
|
297
301
|
}
|
|
298
302
|
|
|
299
303
|
/** Drop records past their retention age along with their artifacts. Runs at
|
|
300
|
-
* extension load; the fixed
|
|
304
|
+
* extension load; the fixed age honors the no-config-knobs policy. */
|
|
301
305
|
export async function pruneThreadRecords(
|
|
302
306
|
configPath: string,
|
|
303
307
|
now = Date.now(),
|
|
@@ -309,8 +313,7 @@ export async function pruneThreadRecords(
|
|
|
309
313
|
let changed = false;
|
|
310
314
|
const kept: ThreadRecord[] = [];
|
|
311
315
|
for (const record of records) {
|
|
312
|
-
|
|
313
|
-
if (now - record.updatedAt <= maxAge) {
|
|
316
|
+
if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
|
|
314
317
|
kept.push(record);
|
|
315
318
|
continue;
|
|
316
319
|
}
|
|
@@ -334,3 +337,66 @@ export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<st
|
|
|
334
337
|
}
|
|
335
338
|
return paths;
|
|
336
339
|
}
|
|
340
|
+
|
|
341
|
+
/** Newest modification time anywhere under root (directories count via their
|
|
342
|
+
* own entries); undefined when root cannot be read. */
|
|
343
|
+
function newestMtimeMs(root: string, now: number = Date.now()): number | undefined {
|
|
344
|
+
let newest: number | undefined;
|
|
345
|
+
const stack: string[] = [root];
|
|
346
|
+
while (stack.length > 0) {
|
|
347
|
+
const dir = stack.pop()!;
|
|
348
|
+
let entries: Dirent[];
|
|
349
|
+
try {
|
|
350
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
const path = join(dir, entry.name);
|
|
356
|
+
let mtime: number;
|
|
357
|
+
try {
|
|
358
|
+
mtime = statSync(path).mtimeMs;
|
|
359
|
+
} catch {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (mtime > 0 && mtime <= now && (newest === undefined || mtime > newest)) newest = mtime;
|
|
363
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) stack.push(path);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return newest;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Delete project directories under the ferris-pi-subagents root that have
|
|
370
|
+
* been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path the
|
|
371
|
+
* threads manifest still references is never touched, so parked work outlives
|
|
372
|
+
* the age rule. Returns the removed directory names. */
|
|
373
|
+
export async function pruneStaleProjectRoots(configPath: string, options: { now?: number } = {}): Promise<string[]> {
|
|
374
|
+
const now = options.now ?? Date.now();
|
|
375
|
+
const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
|
|
376
|
+
const referenced = referencedDurablePaths(records);
|
|
377
|
+
const root = join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
|
|
378
|
+
let projects: Dirent[];
|
|
379
|
+
try {
|
|
380
|
+
projects = readdirSync(root, { withFileTypes: true });
|
|
381
|
+
} catch {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
const removed: string[] = [];
|
|
385
|
+
for (const project of projects) {
|
|
386
|
+
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
387
|
+
const projectDir = join(root, project.name);
|
|
388
|
+
if (containsReferencedPath(projectDir, referenced)) continue;
|
|
389
|
+
const newest = newestMtimeMs(projectDir, now);
|
|
390
|
+
if (newest === undefined || now - newest <= PROJECT_ROOT_MAX_AGE_MS) continue;
|
|
391
|
+
await rm(projectDir, { recursive: true, force: true }).catch(() => undefined);
|
|
392
|
+
if (!existsSync(projectDir)) removed.push(project.name);
|
|
393
|
+
}
|
|
394
|
+
return removed;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function containsReferencedPath(projectDir: string, referenced: ReadonlySet<string>): boolean {
|
|
398
|
+
for (const path of referenced) {
|
|
399
|
+
if (isPathInside(projectDir, path)) return true;
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|