@henryqw/pi-subagent 27.0.0 → 27.1.1
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/CONTEXT.md +2 -2
- package/README.md +67 -54
- package/dist/herdr-runtime.js +3 -1
- package/dist/integration-git.js +3 -3
- package/dist/prune.d.ts +50 -0
- package/dist/prune.js +192 -0
- package/dist/runner.d.ts +13 -0
- package/dist/runner.js +104 -10
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +10 -5
- package/dist/store.d.ts +2 -0
- package/dist/store.js +14 -2
- package/dist/worktree.d.ts +3 -0
- package/dist/worktree.js +3 -1
- package/docs/orchestration.md +4 -2
- package/extensions/isolated.ts +113 -36
- package/extensions/result-transport.ts +22 -2
- package/extensions/subagent-command.ts +88 -2
- package/extensions/subagent.ts +15 -5
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +2 -2
package/extensions/isolated.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
type DirectProcessRunner,
|
|
13
14
|
} from "../dist/git-runtime.js";
|
|
14
15
|
import { HerdrHostRuntime } from "../dist/herdr-runtime.js";
|
|
16
|
+
import { inspectPruneCandidates, listHerdrPanes, removePruneCandidates, type PruneGuards, type PruneItem } from "../dist/prune.js";
|
|
15
17
|
import { runProcess as directRunProcess } from "../dist/process.js";
|
|
16
18
|
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
|
|
17
19
|
import { IsolatedRunner, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
@@ -36,10 +38,12 @@ import {
|
|
|
36
38
|
} from "../dist/schema.js";
|
|
37
39
|
import { FileRunStore } from "../dist/store.js";
|
|
38
40
|
import { workerOutputForMain } from "./result-transport.ts";
|
|
39
|
-
import type { IsolatedInventory } from "./subagent-command.ts";
|
|
41
|
+
import type { IsolatedInventory, PrunePlan } from "./subagent-command.ts";
|
|
40
42
|
|
|
41
43
|
const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
|
|
42
44
|
const PUBLIC_EVIDENCE_MAX_BYTES = 512;
|
|
45
|
+
/** Tool results return failed review verdicts in full up to this bound; follow-ups keep the short preview. */
|
|
46
|
+
const PUBLIC_REVIEW_VERDICT_MAX_BYTES = 32 * 1024;
|
|
43
47
|
const WORKSPACE_WIDGET_KEY = "pi-subagent-workspaces";
|
|
44
48
|
const WIDGET_FIELD_MAX_CHARS = 32;
|
|
45
49
|
const MAX_WORKSPACE_WIDGET_LINES = 6;
|
|
@@ -175,10 +179,25 @@ function updateWorkspaceWidgetSafely(ctx: ExtensionContext, state: RunState, row
|
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
/** Join report lines within a follow-up budget, naming how many were omitted. */
|
|
183
|
+
function boundedReport(lines: readonly string[], omitted: string): string {
|
|
184
|
+
const report: string[] = [];
|
|
185
|
+
let bytes = 0;
|
|
186
|
+
let skipped = 0;
|
|
187
|
+
for (const line of lines.flatMap((entry) => entry.split("\n"))) {
|
|
188
|
+
const size = Buffer.byteLength(`${line}\n`, "utf8");
|
|
189
|
+
if (bytes + size > 7_900) { skipped++; continue; }
|
|
190
|
+
report.push(line);
|
|
191
|
+
bytes += size;
|
|
192
|
+
}
|
|
193
|
+
if (skipped) report.push(`[${skipped} ${omitted}]`);
|
|
194
|
+
return report.join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function boundedPublicText(value: string, maxBytes = PUBLIC_EVIDENCE_MAX_BYTES): string {
|
|
198
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
199
|
+
let end = Math.min(value.length, maxBytes);
|
|
200
|
+
while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > maxBytes) end -= 1;
|
|
182
201
|
return `${value.slice(0, end)}\n[truncated]`;
|
|
183
202
|
}
|
|
184
203
|
|
|
@@ -199,16 +218,18 @@ function publicFailedCheck(evidence: CheckBatchEvidence | undefined) {
|
|
|
199
218
|
};
|
|
200
219
|
}
|
|
201
220
|
|
|
202
|
-
function publicFailedReview(evidence: ReviewEvidence | undefined) {
|
|
221
|
+
function publicFailedReview(evidence: ReviewEvidence | undefined, verdictMaxBytes: number) {
|
|
203
222
|
if (!evidence || evidence.passed) return undefined;
|
|
223
|
+
const verdict = boundedPublicText(evidence.verdict, verdictMaxBytes);
|
|
204
224
|
return {
|
|
205
225
|
phase: evidence.phase,
|
|
206
|
-
verdict
|
|
226
|
+
verdict,
|
|
227
|
+
verdictTruncated: verdict !== evidence.verdict,
|
|
207
228
|
identityChanged: !sameIdentity(evidence.tip, evidence.identityAfter),
|
|
208
229
|
};
|
|
209
230
|
}
|
|
210
231
|
|
|
211
|
-
function publicTaskRecovery(task: RunState["tasks"][number]) {
|
|
232
|
+
function publicTaskRecovery(task: RunState["tasks"][number], verdictMaxBytes: number) {
|
|
212
233
|
if (task.kind === "text") {
|
|
213
234
|
const attempt = task.attempts.at(-1);
|
|
214
235
|
return {
|
|
@@ -225,7 +246,7 @@ function publicTaskRecovery(task: RunState["tasks"][number]) {
|
|
|
225
246
|
|
|
226
247
|
const attempt = task.attempts.at(-1);
|
|
227
248
|
const failedCheck = publicFailedCheck(attempt?.preliminaryChecks);
|
|
228
|
-
const failedReview = publicFailedReview(attempt?.preliminaryReview);
|
|
249
|
+
const failedReview = publicFailedReview(attempt?.preliminaryReview, verdictMaxBytes);
|
|
229
250
|
const cleanup = attempt?.cleanup
|
|
230
251
|
.filter((step) => step.status !== "completed")
|
|
231
252
|
.map((step) => ({
|
|
@@ -254,14 +275,14 @@ function publicTaskRecovery(task: RunState["tasks"][number]) {
|
|
|
254
275
|
};
|
|
255
276
|
}
|
|
256
277
|
|
|
257
|
-
function publicNeedsAttention(state: RunState, preferredTaskId?: string) {
|
|
278
|
+
function publicNeedsAttention(state: RunState, verdictMaxBytes: number, preferredTaskId?: string) {
|
|
258
279
|
if (state.status !== "needs_attention") return undefined;
|
|
259
280
|
const task = state.tasks.find((candidate) => candidate.status === "needs_attention" && candidate.taskId === preferredTaskId)
|
|
260
281
|
?? state.tasks.find((candidate) => candidate.status === "needs_attention");
|
|
261
|
-
if (task) return publicTaskRecovery(task);
|
|
282
|
+
if (task) return publicTaskRecovery(task, verdictMaxBytes);
|
|
262
283
|
if (state.final.failure || state.final.checks?.passed === false || state.final.review?.passed === false) {
|
|
263
284
|
const failedCheck = publicFailedCheck(state.final.checks);
|
|
264
|
-
const failedReview = publicFailedReview(state.final.review);
|
|
285
|
+
const failedReview = publicFailedReview(state.final.review, verdictMaxBytes);
|
|
265
286
|
return {
|
|
266
287
|
scope: "final" as const,
|
|
267
288
|
...(state.final.failure ? { failure: boundedPublicText(state.final.failure) } : {}),
|
|
@@ -275,8 +296,8 @@ function publicNeedsAttention(state: RunState, preferredTaskId?: string) {
|
|
|
275
296
|
};
|
|
276
297
|
}
|
|
277
298
|
|
|
278
|
-
function publicState(state: RunState, preferredTaskId?: string) {
|
|
279
|
-
const needsAttention = publicNeedsAttention(state, preferredTaskId);
|
|
299
|
+
function publicState(state: RunState, verdictMaxBytes: number, preferredTaskId?: string) {
|
|
300
|
+
const needsAttention = publicNeedsAttention(state, verdictMaxBytes, preferredTaskId);
|
|
280
301
|
return {
|
|
281
302
|
version: state.version,
|
|
282
303
|
id: state.request.id,
|
|
@@ -313,7 +334,7 @@ function publicState(state: RunState, preferredTaskId?: string) {
|
|
|
313
334
|
...(combinedTip ? { combinedTip } : {}),
|
|
314
335
|
...(correction ? { correction } : {}),
|
|
315
336
|
...(checks ? { checked: checks.passed, failedCheck: publicFailedCheck(checks) } : {}),
|
|
316
|
-
...(review ? { reviewed: review.passed, failedReview: publicFailedReview(review) } : {}),
|
|
337
|
+
...(review ? { reviewed: review.passed, failedReview: publicFailedReview(review, verdictMaxBytes) } : {}),
|
|
317
338
|
...(promotion ? { promotion: promotion.status, ...(promotion.mainAfter ? { mainAfter: promotion.mainAfter } : {}),
|
|
318
339
|
...(promotion.failure ? { promotionFailure: boundedPublicText(promotion.failure) } : {}) } : {}),
|
|
319
340
|
...(failure ? { failure: boundedPublicText(failure) } : {}),
|
|
@@ -329,6 +350,42 @@ function publicState(state: RunState, preferredTaskId?: string) {
|
|
|
329
350
|
};
|
|
330
351
|
}
|
|
331
352
|
|
|
353
|
+
/** Model-visible failed review verdicts from every task, the final gate, and each generation, within one shared budget.
|
|
354
|
+
* Pi shows the model `content`, not `structuredContent`; task reviews appear even while sibling tasks still run. */
|
|
355
|
+
function failedReviewText(state: RunState): string {
|
|
356
|
+
const reviews = [
|
|
357
|
+
...state.tasks.flatMap((task) => {
|
|
358
|
+
const review = task.kind === "changeset" ? task.attempts.at(-1)?.preliminaryReview : undefined;
|
|
359
|
+
return review && !review.passed ? [{ label: `Task ${task.taskId}`, review }] : [];
|
|
360
|
+
}),
|
|
361
|
+
...(state.final.review && !state.final.review.passed ? [{ label: "Final", review: state.final.review }] : []),
|
|
362
|
+
...state.integration.generations.flatMap(({ number, review }) => review && !review.passed ? [{ label: `Integration generation ${number}`, review }] : []),
|
|
363
|
+
].reduce<{ label: string; review: ReviewEvidence }[]>((merged, entry) => {
|
|
364
|
+
// Show an identical verdict once, but keep every scope that failed with it.
|
|
365
|
+
const same = merged.find(({ review }) => review.verdict === entry.review.verdict);
|
|
366
|
+
if (same) same.label = `${same.label}, ${entry.label}`;
|
|
367
|
+
else merged.push({ ...entry });
|
|
368
|
+
return merged;
|
|
369
|
+
}, []);
|
|
370
|
+
let budget = PUBLIC_REVIEW_VERDICT_MAX_BYTES;
|
|
371
|
+
let omitted = 0;
|
|
372
|
+
const parts: string[] = [];
|
|
373
|
+
for (const { label, review } of reviews) {
|
|
374
|
+
if (budget <= 0) { omitted++; continue; }
|
|
375
|
+
const verdict = boundedPublicText(review.verdict, budget);
|
|
376
|
+
budget -= Buffer.byteLength(verdict, "utf8");
|
|
377
|
+
parts.push(`\n\n${label}: failed ${review.phase} review verdict${verdict !== review.verdict ? " (truncated)" : ""}:\n${workerOutputForMain(verdict)}`);
|
|
378
|
+
}
|
|
379
|
+
if (omitted) parts.push(`\n\n${omitted} more failed review verdict(s) omitted from this text; structuredContent lists each one.`);
|
|
380
|
+
return parts.join("");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function hasTruncatedVerdict(state: ReturnType<typeof publicState>): boolean {
|
|
384
|
+
const attention = state.needsAttention;
|
|
385
|
+
return Boolean(attention && "failedReview" in attention && attention.failedReview?.verdictTruncated)
|
|
386
|
+
|| state.integration.generations.some((generation) => generation.failedReview?.verdictTruncated);
|
|
387
|
+
}
|
|
388
|
+
|
|
332
389
|
function inspectionNotices({ state, main, continuation }: RunResponse): string[] {
|
|
333
390
|
const lines = [
|
|
334
391
|
`Isolated request ${state.request.id}: ${state.status}; accepted: ${state.accepted}; final: ${state.final.status}; Main: ${main?.status ?? "not checked"}.`,
|
|
@@ -338,7 +395,7 @@ function inspectionNotices({ state, main, continuation }: RunResponse): string[]
|
|
|
338
395
|
? `Main inspection unavailable: ${boundedPublicText(main.failure)}; expected: ${JSON.stringify(main.expected)}`
|
|
339
396
|
: `Main ${main.status}: ${JSON.stringify({ expected: main.expected, actual: main.actual })}`);
|
|
340
397
|
if (state.integration.refresh) lines.push(`Integration refresh: ${state.integration.refresh.status}${state.integration.refresh.failure ? `; blockage: ${boundedPublicText(state.integration.refresh.failure)}` : ""}.`);
|
|
341
|
-
const attention = publicNeedsAttention(state);
|
|
398
|
+
const attention = publicNeedsAttention(state, PUBLIC_EVIDENCE_MAX_BYTES);
|
|
342
399
|
if (attention) lines.push(`Reported blockage: ${JSON.stringify({ scope: attention.scope, failure: attention.failure, failedCheck: "failedCheck" in attention ? attention.failedCheck : undefined, failedReview: "failedReview" in attention ? attention.failedReview : undefined })}`);
|
|
343
400
|
if (state.final.failure) lines.push(`Final blockage: ${boundedPublicText(state.final.failure)}`);
|
|
344
401
|
for (const task of state.tasks) {
|
|
@@ -404,7 +461,7 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
404
461
|
return {
|
|
405
462
|
content: [{ type: "text" as const, text }],
|
|
406
463
|
details: {
|
|
407
|
-
state: publicState(response.state, preferredTaskId),
|
|
464
|
+
state: publicState(response.state, PUBLIC_REVIEW_VERDICT_MAX_BYTES, preferredTaskId),
|
|
408
465
|
...(response.main ? { main: response.main } : {}),
|
|
409
466
|
...(response.continuation ? { continuation: response.continuation } : {}),
|
|
410
467
|
},
|
|
@@ -415,6 +472,8 @@ export interface IsolatedSurface {
|
|
|
415
472
|
execute(request: ExecuteRequest, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
|
|
416
473
|
inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
|
|
417
474
|
recover(cwd: string): Promise<string>;
|
|
475
|
+
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
476
|
+
prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
|
|
418
477
|
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
419
478
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
420
479
|
enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
|
|
@@ -487,6 +546,10 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
487
546
|
pi.on("model_select", (event, ctx) => { latestCtx = { ...ctx, model: event.model } as ExtensionContext; });
|
|
488
547
|
pi.on("agent_settled", (_event, ctx) => { latestCtx = ctx; });
|
|
489
548
|
|
|
549
|
+
const pruneGuards = (root: string): PruneGuards => ({
|
|
550
|
+
ownership: async (checkouts) => await getComponents().runner.pruneOwnership(checkouts),
|
|
551
|
+
panes: async () => await listHerdrPanes(root),
|
|
552
|
+
});
|
|
490
553
|
const requireCurrent = (current: () => boolean) => {
|
|
491
554
|
if (!current() || sessionClosed) throw new Error("Session or branch changed; reopen /subagent.");
|
|
492
555
|
};
|
|
@@ -551,14 +614,17 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
551
614
|
}
|
|
552
615
|
const state = response?.state ?? latestState;
|
|
553
616
|
try {
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
617
|
+
const preview = state ? publicState(state, PUBLIC_EVIDENCE_MAX_BYTES, response?.continuation && "taskId" in response.continuation
|
|
618
|
+
? response.continuation.taskId : undefined) : undefined;
|
|
619
|
+
const details = preview ? {
|
|
620
|
+
state: preview,
|
|
557
621
|
...(response?.continuation ? { continuation: response.continuation } : {}),
|
|
558
622
|
} : { id };
|
|
559
623
|
pi.sendMessage({
|
|
560
624
|
customType: "pi-subagent-isolated-result",
|
|
561
|
-
content:
|
|
625
|
+
content: preview && hasTruncatedVerdict(preview)
|
|
626
|
+
? `${text}\n\nReview verdicts here are previews; run subagent_status for the full verdict.`
|
|
627
|
+
: text,
|
|
562
628
|
display: true,
|
|
563
629
|
details,
|
|
564
630
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
@@ -597,7 +663,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
597
663
|
reject(new Error(`Pi Subagent ${id} finished without a durable acknowledgement.`));
|
|
598
664
|
return;
|
|
599
665
|
}
|
|
600
|
-
deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
|
|
666
|
+
deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state, PUBLIC_EVIDENCE_MAX_BYTES))}`, response);
|
|
601
667
|
},
|
|
602
668
|
(error: unknown) => {
|
|
603
669
|
finish();
|
|
@@ -628,15 +694,17 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
628
694
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
629
695
|
latestCtx = ctx;
|
|
630
696
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
631
|
-
const
|
|
697
|
+
const response = await getComponents().runner.status(params.id, root, signal);
|
|
698
|
+
const result = toolResult(response, ctx, workspaceRowsByRequest);
|
|
699
|
+
const content = [{ type: "text" as const, text: `${result.content[0]!.text}${failedReviewText(response.state)}` }];
|
|
632
700
|
// The projection is plain JSON; its conditional fields are typed optional rather than absent.
|
|
633
|
-
return { ...result, structuredContent: result.details as JsonValue };
|
|
701
|
+
return { ...result, content, structuredContent: result.details as JsonValue };
|
|
634
702
|
},
|
|
635
703
|
});
|
|
636
704
|
pi.registerTool({
|
|
637
705
|
name: "subagent_resume",
|
|
638
706
|
label: "Subagent resume",
|
|
639
|
-
description: "Resume one unfinished isolated request without resetting its recorded policy or correction count.",
|
|
707
|
+
description: "Resume one unfinished isolated request without resetting its recorded policy or correction count. For a retry that sends a same-worker correction, optional instruction (at most 32000 characters) is appended to that prompt as Main's instruction after the saved check or review failure; use it to name the exact fixes, including the review findings the worker must address. Other actions reject instruction.",
|
|
640
708
|
parameters: ResumeRequestParameters,
|
|
641
709
|
prepareArguments: parseResumeRequest,
|
|
642
710
|
exposure: "model-only",
|
|
@@ -748,17 +816,26 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
748
816
|
leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
|
|
749
817
|
: "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
|
|
750
818
|
];
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
819
|
+
return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
|
|
820
|
+
},
|
|
821
|
+
async prunePlan(cwd) {
|
|
822
|
+
const root = await lookupRoot(cwd);
|
|
823
|
+
return { root, items: await inspectPruneCandidates(root, cwd, pruneGuards(root)) };
|
|
824
|
+
},
|
|
825
|
+
async prune(cwd, items, allowIgnored, current) {
|
|
826
|
+
requireCurrent(current);
|
|
827
|
+
const root = await lookupRoot(cwd);
|
|
828
|
+
const { removals, checkouts } = await removePruneCandidates(root, cwd, items, allowIgnored,
|
|
829
|
+
{ ...pruneGuards(root), current: () => current() && !sessionClosed });
|
|
830
|
+
const removed = removals.filter(({ outcome }) => outcome === "removed");
|
|
831
|
+
const reconciled = removed.length
|
|
832
|
+
? await getComponents().runner.reconcilePrunedAborts(checkouts, removed.map(({ item }) => basename(item.branch))) : [];
|
|
833
|
+
const count = (outcome: string) => removals.filter((item) => item.outcome === outcome).length;
|
|
834
|
+
return boundedReport([
|
|
835
|
+
`Pi Subagent prune in ${root}: ${removed.length} removed, ${count("skipped")} skipped, ${count("failed")} failed.`,
|
|
836
|
+
...removals.map(({ outcome, item, reason }) => `${outcome}: ${item.path ?? "(no checkout)"} · ${item.branch} · ${reason}`),
|
|
837
|
+
...reconciled,
|
|
838
|
+
], "prune report lines omitted.");
|
|
762
839
|
},
|
|
763
840
|
async inspect(root, requestId) {
|
|
764
841
|
const response = await getComponents().runner.status(requestId, root);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capEphemeralSubagentOutput } from "@henryqw/pi-subagent";
|
|
1
|
+
import { capEphemeralSubagentOutput, formatDuration } from "@henryqw/pi-subagent";
|
|
2
2
|
import type { WorkflowEntry, WorkflowMode } from "./workflow.ts";
|
|
3
3
|
|
|
4
4
|
const EVIDENCE_PREVIEW_CODE_POINTS = 256;
|
|
@@ -22,6 +22,9 @@ export type WorkflowTransportEntry = {
|
|
|
22
22
|
role: WorkflowEntry["delegation"]["role"];
|
|
23
23
|
model?: string;
|
|
24
24
|
thinkingLevel?: string;
|
|
25
|
+
/** Measured session tokens; absent when usage was unavailable. */
|
|
26
|
+
tokens?: number;
|
|
27
|
+
elapsedMs?: number;
|
|
25
28
|
status: WorkflowTransportStatus;
|
|
26
29
|
assistantOutput?: string;
|
|
27
30
|
failure?: string;
|
|
@@ -76,6 +79,21 @@ export function displaySummary(text: string): string {
|
|
|
76
79
|
return Array.from(normalized).slice(0, 160).join("");
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
export function compactTokens(tokens: number): string {
|
|
83
|
+
return `${tokens < 1_000 ? tokens : `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`} tok`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Launched workers carry a model; workers that never launched show no usage. */
|
|
87
|
+
function usageFor(entry: WorkflowTransportEntry): string {
|
|
88
|
+
if (entry.model === undefined) return "";
|
|
89
|
+
return [
|
|
90
|
+
entry.model,
|
|
91
|
+
...(entry.thinkingLevel === undefined ? [] : [`thinking ${entry.thinkingLevel}`]),
|
|
92
|
+
entry.tokens === undefined ? "tokens unknown" : compactTokens(entry.tokens),
|
|
93
|
+
...(entry.elapsedMs === undefined ? [] : [formatDuration(entry.elapsedMs)]),
|
|
94
|
+
].map((part) => ` · ${part}`).join("");
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
export type WorkflowEntryStatusPresentation = { glyph: string; fallback: string };
|
|
80
98
|
|
|
81
99
|
export const ENTRY_STATUS_PRESENTATION = {
|
|
@@ -120,7 +138,7 @@ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly Workf
|
|
|
120
138
|
...positioned.map(({ entry, position }) => {
|
|
121
139
|
const { glyph, fallback } = ENTRY_STATUS_PRESENTATION[entry.status];
|
|
122
140
|
const summary = displaySummary(sourceFor(entry) ?? "") || fallback;
|
|
123
|
-
return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role} — ${summary}`;
|
|
141
|
+
return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role}${usageFor(entry)} — ${summary}`;
|
|
124
142
|
}),
|
|
125
143
|
...(evidence.length ? [
|
|
126
144
|
"Results:",
|
|
@@ -146,6 +164,8 @@ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly Workf
|
|
|
146
164
|
...(source === undefined ? {} : { summary: displaySummary(source) }),
|
|
147
165
|
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
148
166
|
...(entry.thinkingLevel === undefined ? {} : { thinkingLevel: entry.thinkingLevel }),
|
|
167
|
+
...(entry.tokens === undefined ? {} : { tokens: entry.tokens }),
|
|
168
|
+
...(entry.elapsedMs === undefined ? {} : { elapsedMs: entry.elapsedMs }),
|
|
149
169
|
};
|
|
150
170
|
}),
|
|
151
171
|
},
|
|
@@ -49,6 +49,22 @@ export interface IsolatedInventory {
|
|
|
49
49
|
invalidIds: readonly string[];
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** One pi-subagent branch and its checkout as reviewed by /subagent prune. */
|
|
53
|
+
export interface PruneItem {
|
|
54
|
+
branch: string;
|
|
55
|
+
tip: string;
|
|
56
|
+
path?: string;
|
|
57
|
+
prunable: boolean;
|
|
58
|
+
/** Why the item is prunable, or every exact blocker. */
|
|
59
|
+
reason: string;
|
|
60
|
+
/** Ignored entries that removing the checkout deletes. */
|
|
61
|
+
ignored: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
export interface PrunePlan {
|
|
64
|
+
root: string;
|
|
65
|
+
items: readonly PruneItem[];
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
/** Mutation adapters MUST check current() synchronously alongside their live ownership
|
|
53
69
|
* validation and queue operation. They must not await between those operations. */
|
|
54
70
|
export interface SubagentCommandAdapter {
|
|
@@ -57,6 +73,10 @@ export interface SubagentCommandAdapter {
|
|
|
57
73
|
stopDirect(task: DirectTask, entryId: string, current: () => boolean): Promise<string>;
|
|
58
74
|
isolated(cwd: string, current: () => boolean): Promise<IsolatedInventory>;
|
|
59
75
|
recover(cwd: string): Promise<string>;
|
|
76
|
+
/** Read-only review of pi-subagent worktrees and branches in the canonical repository. */
|
|
77
|
+
prunePlan(cwd: string): Promise<PrunePlan>;
|
|
78
|
+
/** Re-verify and remove reviewed prunable items; returns a bounded report. */
|
|
79
|
+
prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
|
|
60
80
|
/** Read-only Role catalog report with routes and per-file load errors. */
|
|
61
81
|
roles(ctx: ExtensionContext): string;
|
|
62
82
|
inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ ref: string; tabId: string; name: string; sessionFile: string }>;
|
|
@@ -73,6 +93,39 @@ const errorText = (error: unknown): string => clean(error instanceof Error ? err
|
|
|
73
93
|
const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
|
|
74
94
|
const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
|
|
75
95
|
|
|
96
|
+
const ignoredSummary = ({ ignored }: PruneItem) => !ignored.length ? "none"
|
|
97
|
+
: `${ignored.length} (${ignored.slice(0, 5).join(", ")}${ignored.length > 5 ? `, +${ignored.length - 5} more` : ""})`;
|
|
98
|
+
const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
|
|
99
|
+
const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
|
|
100
|
+
|
|
101
|
+
/** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
|
|
102
|
+
function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number): { text: string; shown: T[] } {
|
|
103
|
+
const lines = [header];
|
|
104
|
+
const shown: T[] = [];
|
|
105
|
+
let size = [...header].length;
|
|
106
|
+
const reserve = [...NOT_SHOWN].length + 32;
|
|
107
|
+
for (const item of items) {
|
|
108
|
+
const text = line(item);
|
|
109
|
+
const length = [...text].length + 1;
|
|
110
|
+
if (size + length + reserve > max) break;
|
|
111
|
+
lines.push(text);
|
|
112
|
+
size += length;
|
|
113
|
+
shown.push(item);
|
|
114
|
+
}
|
|
115
|
+
if (shown.length < items.length) lines.push(`+${items.length - shown.length} more not shown; ${NOT_SHOWN}`);
|
|
116
|
+
return { text: lines.join("\n"), shown };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Prunable items come first so a long blocked list cannot hide them; only shown items may be removed. */
|
|
120
|
+
function pruneReview({ root, items }: PrunePlan): { text: string; shown: PruneItem[] } {
|
|
121
|
+
if (!items.length) return { text: `No pi-subagent worktrees or branches in ${root}.`, shown: [] };
|
|
122
|
+
const prunable = items.filter((item) => item.prunable).length;
|
|
123
|
+
return fitItems(`Subagent prune review in ${root}: ${prunable} prunable, ${items.length - prunable} blocked.`,
|
|
124
|
+
[...items.filter((item) => item.prunable), ...items.filter((item) => !item.prunable)],
|
|
125
|
+
(item) => clean(`${item.prunable ? "✓ prunable" : "✗ blocked"} · ${item.path ?? "(no checkout)"} · ${item.branch} · ${item.tip.slice(0, 12)} · ${item.reason} · ignored: ${ignoredSummary(item)}`, 600),
|
|
126
|
+
ROLES_REPORT_CODE_POINTS);
|
|
127
|
+
}
|
|
128
|
+
|
|
76
129
|
type Choice<T> = { label: string; value: T };
|
|
77
130
|
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
|
|
78
131
|
// Numeric suffixes are UI-only. Never parse display text into an identity.
|
|
@@ -84,10 +137,10 @@ async function choose<T>(ctx: ExtensionContext, title: string, items: readonly C
|
|
|
84
137
|
|
|
85
138
|
export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentCommandAdapter): void {
|
|
86
139
|
pi.registerCommand("subagent", {
|
|
87
|
-
description: "Inspect direct and isolated work, list Roles with /subagent roles,
|
|
140
|
+
description: "Inspect direct and isolated work, list Roles with /subagent roles, recover orphaned isolated requests with /subagent recover, or remove merged leftover worktrees with /subagent prune",
|
|
88
141
|
handler: async (args, ctx) => {
|
|
89
142
|
const command = args.trim();
|
|
90
|
-
if (command &&
|
|
143
|
+
if (command && !["prune", "recover", "roles"].includes(command)) throw new Error("Usage: /subagent [prune|recover|roles]");
|
|
91
144
|
if (command === "roles") {
|
|
92
145
|
const report = clean(adapter.roles(ctx), ROLES_REPORT_CODE_POINTS, true);
|
|
93
146
|
if (ctx.hasUI) ctx.ui.notify(report, "info");
|
|
@@ -121,6 +174,39 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
121
174
|
ctx.ui.notify("Session or branch changed; reopen /subagent in the current context.", "warning");
|
|
122
175
|
return false;
|
|
123
176
|
};
|
|
177
|
+
if (command === "prune") {
|
|
178
|
+
const plan = await adapter.prunePlan(ctx.cwd);
|
|
179
|
+
const reviewed = pruneReview(plan);
|
|
180
|
+
const review = clean(reviewed.text, ROLES_REPORT_CODE_POINTS, true);
|
|
181
|
+
if (!ctx.hasUI) {
|
|
182
|
+
pi.sendMessage({ customType: "pi-subagent-prune", content: `${review}\nReport only: removal requires interactive confirmation.`, display: true });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!valid()) return;
|
|
186
|
+
ctx.ui.notify(review, "info");
|
|
187
|
+
const prunable = reviewed.shown.filter((item) => item.prunable);
|
|
188
|
+
if (!prunable.length) return;
|
|
189
|
+
const confirmed = await ctx.ui.confirm("Prune subagent worktrees", `Remove all ${prunable.length} prunable item(s) listed above? Each is re-verified first; unique commits, tracked or untracked changes, and unlisted items are never removed.`);
|
|
190
|
+
if (!valid() || !confirmed) return;
|
|
191
|
+
const ignored = prunable.filter((item) => item.ignored.length);
|
|
192
|
+
// The header uses the full count's width so the shown count always fits.
|
|
193
|
+
const ignoredList = fitItems(`Also remove ${ignored.length} checkout(s) with ignored files? This permanently deletes these ignored entries and everything inside them:`,
|
|
194
|
+
// Approval names every ignored entry; a checkout whose full list does not fit is left out of this run.
|
|
195
|
+
ignored, (item) => clean(`${item.path}: ${item.ignored.join(", ")}`, Number.MAX_SAFE_INTEGER), PRUNE_IGNORED_CONFIRM_CODE_POINTS);
|
|
196
|
+
const approvable = ignoredList.shown;
|
|
197
|
+
const allowIgnored = approvable.length > 0 && await ctx.ui.confirm("Delete ignored content",
|
|
198
|
+
clean(ignoredList.text.replace(`Also remove ${ignored.length} checkout(s)`, `Also remove ${approvable.length} checkout(s)`), PRUNE_IGNORED_CONFIRM_CODE_POINTS, true));
|
|
199
|
+
if (!valid()) return;
|
|
200
|
+
const kept = ignored.length - (allowIgnored ? approvable.length : 0);
|
|
201
|
+
if (kept) ctx.ui.notify(`Kept ${kept} checkout(s) with ignored content; their deletion was not approved.`, "info");
|
|
202
|
+
const selected = prunable.filter((item) => !item.ignored.length || (allowIgnored && approvable.includes(item)));
|
|
203
|
+
if (!selected.length) return;
|
|
204
|
+
try {
|
|
205
|
+
const report = await adapter.prune(ctx.cwd, selected, allowIgnored, current);
|
|
206
|
+
if (valid()) ctx.ui.notify(clean(report, ROLES_REPORT_CODE_POINTS, true), "info");
|
|
207
|
+
} catch (error) { if (valid()) ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error"); }
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
124
210
|
let history = false;
|
|
125
211
|
for (;;) {
|
|
126
212
|
if (!valid()) return;
|
package/extensions/subagent.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { registerCheckoutAdmission } from "./admission.ts";
|
|
|
8
8
|
import { registerIsolatedExtension } from "./isolated.ts";
|
|
9
9
|
import { directRef, registerSubagentCommand, type DirectDisplay, type DirectTask } from "./subagent-command.ts";
|
|
10
10
|
import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
|
|
11
|
-
import { ENTRY_STATUS_PRESENTATION, formatWorkflowResult, workerOutputForMain, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
|
|
11
|
+
import { compactTokens, ENTRY_STATUS_PRESENTATION, formatWorkflowResult, workerOutputForMain, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
|
|
12
12
|
import { DelegateTaskParameters, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, writesCheckout, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
|
|
13
13
|
import { assertSettledDirectSession, createDirectHerdr, DirectAllocationError, type DirectAllocation, type DirectHandle, type DirectTab, type DirectToolActivity } from "../dist/direct-herdr.js";
|
|
14
14
|
import { materializeTransientLaunch } from "../dist/launch-runtime.js";
|
|
@@ -55,7 +55,7 @@ function statusLabel(status: WidgetStatus): string {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function tokenLabel(tokens: number | undefined): string {
|
|
58
|
-
return tokens === undefined ? "— tok" :
|
|
58
|
+
return tokens === undefined ? "— tok" : compactTokens(tokens);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function activityLabel(activity: DirectToolActivity | undefined): string | undefined {
|
|
@@ -304,6 +304,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
304
304
|
},
|
|
305
305
|
isolated: isolatedSurface.inventory,
|
|
306
306
|
recover: (cwd) => isolatedSurface.recover(cwd),
|
|
307
|
+
prunePlan: (cwd) => isolatedSurface.prunePlan(cwd),
|
|
308
|
+
prune: (cwd, items, allowIgnored, current) => isolatedSurface.prune(cwd, items, allowIgnored, current),
|
|
307
309
|
async inspectInTab(root, id, ctx, current) {
|
|
308
310
|
const notices = await isolatedSurface.inspect(root, id);
|
|
309
311
|
if (!current()) throw new Error("Session or branch changed before status inspection.");
|
|
@@ -654,6 +656,11 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
654
656
|
id, index, role: delegation.role, status: "pending",
|
|
655
657
|
name: delegation.continueFrom === undefined ? delegation.name : `${delegation.name} (continued from ${delegation.continueFrom})`,
|
|
656
658
|
}]));
|
|
659
|
+
const startedAt = new Map<string, number>();
|
|
660
|
+
const measured = (id: string, tokens: number | undefined) => ({
|
|
661
|
+
...(tokens === undefined ? {} : { tokens }),
|
|
662
|
+
...(startedAt.has(id) ? { elapsedMs: Date.now() - startedAt.get(id)! } : {}),
|
|
663
|
+
});
|
|
657
664
|
const taskId = `direct-${++directSequence}-${Date.now().toString(36)}`;
|
|
658
665
|
const workflowNumber = nextWorkflow(ctx);
|
|
659
666
|
const ref = `D${workflowNumber}`;
|
|
@@ -717,6 +724,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
717
724
|
const prepared = await prepareDirectLaunch(role, latestCtx ?? ctx, {
|
|
718
725
|
...(modelClass === undefined ? {} : { modelClass }), ...(model === undefined ? {} : { model }) });
|
|
719
726
|
states.set(entry.id, { ...states.get(entry.id)!, model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
|
|
727
|
+
startedAt.set(entry.id, Date.now());
|
|
720
728
|
const display = displayOf(entry);
|
|
721
729
|
startWidgetItem(entry.id, taskId, display, prepared.model.id, prepared.thinkingLevel, ctx);
|
|
722
730
|
const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
|
|
@@ -795,24 +803,26 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
795
803
|
if (writerFailed) throw new Error("Previous direct task did not complete exactly; inspect recorded tabs before new shared-checkout work.");
|
|
796
804
|
handle = entry.index === 0 ? first : await launch(entry, entrySignal(entry));
|
|
797
805
|
const answer = await handle.answer(Math.floor(40 * 1024 / entries.length));
|
|
806
|
+
let tokens: number | undefined;
|
|
798
807
|
try {
|
|
799
|
-
|
|
808
|
+
tokens = await handle.usageTokens();
|
|
800
809
|
const item = widgetItems.get(entry.id);
|
|
801
810
|
if (item) { item.tokens = tokens; item.activity = handle.activity; }
|
|
802
811
|
} catch { /* Optional telemetry. */ }
|
|
803
|
-
states.set(entry.id, { ...states.get(entry.id)!, status: "succeeded", assistantOutput: answer });
|
|
812
|
+
states.set(entry.id, { ...states.get(entry.id)!, status: "succeeded", assistantOutput: answer, ...measured(entry.id, tokens) });
|
|
804
813
|
finishWidgetItem(entry.id, "success");
|
|
805
814
|
return answer;
|
|
806
815
|
} catch (error) {
|
|
807
816
|
if (writes) writerFailed = true;
|
|
808
817
|
const { assistantOutput: _partial, ...base } = states.get(entry.id)!;
|
|
809
818
|
const partialOutput = await handle?.partialText(PARTIAL_OUTPUT_CHARS).catch(() => undefined);
|
|
819
|
+
const tokens = await handle?.usageTokens().catch(() => undefined);
|
|
810
820
|
const message = (value: unknown) => value instanceof Error ? value.message : String(value);
|
|
811
821
|
const stopped = await stopProofs.get(entry.id)?.then(() => STOPPED_BY_USER,
|
|
812
822
|
(stopError: unknown) => `${STOPPED_BY_USER}; termination unproved: ${message(stopError)}`);
|
|
813
823
|
const tab = tabByEntry.get(entry.id);
|
|
814
824
|
const recover = tab && stopped !== STOPPED_BY_USER ? `recover from Herdr tab ${tab.tabId}, agent ${tab.name}, session ${tab.sessionFile}: ` : "";
|
|
815
|
-
states.set(entry.id, { ...base, status: "rejected", ...(partialOutput ? { partialOutput } : {}), failure: `${recover}${capOutput(stopped ?? message(error))}` });
|
|
825
|
+
states.set(entry.id, { ...base, status: "rejected", ...(partialOutput ? { partialOutput } : {}), ...measured(entry.id, tokens), failure: `${recover}${capOutput(stopped ?? message(error))}` });
|
|
816
826
|
finishWidgetItem(entry.id, "failure");
|
|
817
827
|
throw error;
|
|
818
828
|
} finally { release(); }
|
package/package.json
CHANGED
|
@@ -32,10 +32,10 @@ To build on a finished direct worker, add `continueFrom` with its reference (suc
|
|
|
32
32
|
|
|
33
33
|
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks, preserves declared order, and implies a scheduling dependency; do not repeat an edge in `dependsOn` and `contextFrom`.
|
|
34
34
|
|
|
35
|
-
Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
|
|
35
|
+
Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. If a worker's turn ends with uncommitted changes, the runner sends it one commit reminder per attempt; this uses no correction, and a worktree still dirty afterwards needs attention. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
|
|
36
36
|
|
|
37
37
|
A ready candidate does not integrate automatically. Each checked worker triggers a follow-up while its siblings continue; inspect `subagent_status` and immediately use `subagent_stage` with the exact `generation`, `taskId`, `attempt`, candidate `tip`, and `expectedTip` to select and merge it into the owned integration checkout. Stage and resolve are available during the active wave; wait for the wave to settle before rejecting or revising. Resolve conflicts in the integration checkout and verify with `resolve`; never edit Main to resolve them. For dependents, `subagent_integrate advance` acknowledges a durable background wave, then reports checked candidates as they finish. When selected work is staged and the wave settles, use `subagent_integrate validate` on the exact `combinedTip`: it acknowledges saved intent, runs full root checks and optional final judgment in the session-owned background, then sends a follow-up. Acknowledgement is not passing evidence; `promote` requires passing evidence and unchanged Main. One Main-authored committed correction to a definitively failed combined validation can be recorded with `correct`, then revalidated. Only proven promotion triggers selected-worker termination and verified cleanup. Failures, ambiguity, conflicts, interruption, child-limit exhaustion, or Main drift retain work and never waive checks or identity guards. The extension never pushes, opens a pull request, publishes, or deploys.
|
|
38
38
|
|
|
39
|
-
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. For interrupted validation, prove old coordinator/check processes stopped, then guarded `reconcile` classifies the exact generation; never resume a live lease. A `validation_failed` generation without checks, review, or a prior combined correction can `refresh` with identical `expectedMain`/`newMain`, the old exact combined `expectedTip`, and the next generation number. Main and the retained integration tip must be exact and clean. Restage immutable candidates explicitly and rerun all final gates in the new checkout; never replay old uncertain checks, infer passing evidence, or reset correction allowances. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
|
|
39
|
+
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. For leftover merged worker or integration checkouts, ask the user to run `/subagent prune`; it removes only reviewed, confirmed, merged, clean, unowned items. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. For a retry that sends a same-worker correction, pass `instruction` to name the exact fixes, including the review or check findings the worker must address; the correction prompt carries only a short saved failure. Follow-ups show a short preview of a failed review verdict; `subagent_status` returns it in full. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. For interrupted validation, prove old coordinator/check processes stopped, then guarded `reconcile` classifies the exact generation; never resume a live lease. A `validation_failed` generation without checks, review, or a prior combined correction can `refresh` with identical `expectedMain`/`newMain`, the old exact combined `expectedTip`, and the next generation number. Main and the retained integration tip must be exact and clean. Restage immutable candidates explicitly and rerun all final gates in the new checkout; never replay old uncertain checks, infer passing evidence, or reset correction allowances. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
|
|
40
40
|
|
|
41
41
|
All launches use the global policy in `config/pi-subagent/config.json`: productive concurrency, per-child turns/tokens, and idle timeout. Ephemeral children also have a hard runtime limit; isolated requests have a correction cap. Productive requests have no whole-run wall-clock deadline. Request fields cannot replenish or override limits.
|