@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/dist/prune.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, join, sep } from "node:path";
|
|
3
|
+
import { createHerdrClient } from "@henryqw/pi-herdr";
|
|
4
|
+
import { runGit } from "./git-process.js";
|
|
5
|
+
import { runProcess } from "./process.js";
|
|
6
|
+
import { inspectWorktreeDirty, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
|
|
7
|
+
const HERDR_TIMEOUT_MS = 30_000;
|
|
8
|
+
const IN_PROGRESS_MARKERS = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];
|
|
9
|
+
async function requireGit(run, args, cwd) {
|
|
10
|
+
const result = await run(args, cwd);
|
|
11
|
+
if (result.code !== 0)
|
|
12
|
+
throw new Error(`git ${args[0]} failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
13
|
+
return result.stdout.trimEnd();
|
|
14
|
+
}
|
|
15
|
+
const within = (directory, path) => path === directory || path.startsWith(`${directory}${sep}`);
|
|
16
|
+
/** Every pane in every Herdr workspace with its shell and foreground working directories. */
|
|
17
|
+
export async function listHerdrPanes(cwd) {
|
|
18
|
+
const response = await createHerdrClient(runProcess).json(["pane", "list"], { cwd, timeoutMs: HERDR_TIMEOUT_MS });
|
|
19
|
+
const result = response.result;
|
|
20
|
+
if (result?.type !== "pane_list" || !Array.isArray(result.panes))
|
|
21
|
+
throw new Error("Herdr pane list response is malformed.");
|
|
22
|
+
return result.panes.map((pane) => {
|
|
23
|
+
const cwds = [pane.cwd, pane.foreground_cwd].filter((cwd) => typeof cwd === "string");
|
|
24
|
+
if (typeof pane.pane_id !== "string" || !cwds.length)
|
|
25
|
+
throw new Error("Herdr listed a pane without an ID or working directory.");
|
|
26
|
+
return { paneId: pane.pane_id, cwds };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function snapshot(root, mainCwd, run) {
|
|
30
|
+
const checkouts = [];
|
|
31
|
+
let current;
|
|
32
|
+
for (const field of (await requireGit(run, ["worktree", "list", "--porcelain", "-z"], root)).split("\0")) {
|
|
33
|
+
if (field.startsWith("worktree "))
|
|
34
|
+
checkouts.push(current = { path: field.slice("worktree ".length), locked: false, missing: false });
|
|
35
|
+
else if (current && field.startsWith("branch refs/heads/"))
|
|
36
|
+
current.branch = field.slice("branch refs/heads/".length);
|
|
37
|
+
else if (current && (field === "locked" || field.startsWith("locked ")))
|
|
38
|
+
current.locked = true;
|
|
39
|
+
else if (current && (field === "prunable" || field.startsWith("prunable ")))
|
|
40
|
+
current.missing = true;
|
|
41
|
+
}
|
|
42
|
+
if (!checkouts[0])
|
|
43
|
+
throw new Error("Primary Git worktree is unavailable.");
|
|
44
|
+
const branches = new Map();
|
|
45
|
+
for (const line of (await requireGit(run, ["for-each-ref", "--format=%(refname:lstrip=2) %(objectname)", "refs/heads/"], root)).split("\n")) {
|
|
46
|
+
const [branch, tip] = line.split(" ");
|
|
47
|
+
if (branch && tip && OWNED_BRANCH_PATTERN.test(branch))
|
|
48
|
+
branches.set(branch, tip);
|
|
49
|
+
}
|
|
50
|
+
const targets = [{ label: "Main HEAD", head: await requireGit(run, ["rev-parse", "--verify", "HEAD^{commit}"], mainCwd) }];
|
|
51
|
+
const origin = await run(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], root);
|
|
52
|
+
if (origin.code === 0) {
|
|
53
|
+
const ref = origin.stdout.trim();
|
|
54
|
+
targets.push({ label: ref.replace(/^refs\/remotes\//, ""), head: await requireGit(run, ["rev-parse", "--verify", `${ref}^{commit}`], root) });
|
|
55
|
+
}
|
|
56
|
+
return { primary: checkouts[0].path, checkouts, branches, targets };
|
|
57
|
+
}
|
|
58
|
+
async function classify(snap, branch, mainCwd, ownership, panes, run, root) {
|
|
59
|
+
const tip = snap.branches.get(branch);
|
|
60
|
+
const name = basename(branch);
|
|
61
|
+
const ownedPath = join(snap.primary, WORKTREES_DIRNAME, name);
|
|
62
|
+
// A detached or switched owned checkout no longer names the branch; find it by its owned path too.
|
|
63
|
+
const checkout = snap.checkouts.find((item) => item.branch === branch) ?? snap.checkouts.find((item) => item.path === ownedPath);
|
|
64
|
+
const blockers = [];
|
|
65
|
+
if (checkout && checkout.branch !== branch)
|
|
66
|
+
blockers.push(`owned checkout is ${checkout.branch ? `on branch ${checkout.branch}` : "detached"}; inspect it manually`);
|
|
67
|
+
let ignored = [];
|
|
68
|
+
const owner = ownership.owner(name);
|
|
69
|
+
if (owner)
|
|
70
|
+
blockers.push(owner);
|
|
71
|
+
if (ownership.lease)
|
|
72
|
+
blockers.push(ownership.lease);
|
|
73
|
+
let merged;
|
|
74
|
+
for (const target of snap.targets) {
|
|
75
|
+
const result = await run(["merge-base", "--is-ancestor", tip, target.head], root);
|
|
76
|
+
if (result.code === 0) {
|
|
77
|
+
merged = target.label;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (result.code !== 1)
|
|
81
|
+
throw new Error(`git merge-base failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
|
|
82
|
+
}
|
|
83
|
+
if (!merged)
|
|
84
|
+
blockers.push(`branch has commits not in ${snap.targets.map(({ label }) => label).join(" or ")}`);
|
|
85
|
+
if (checkout) {
|
|
86
|
+
const path = checkout.path;
|
|
87
|
+
if (path !== ownedPath)
|
|
88
|
+
blockers.push("checkout is not at its owned .worktrees path");
|
|
89
|
+
if (checkout.locked)
|
|
90
|
+
blockers.push("checkout is locked");
|
|
91
|
+
if (checkout.missing || !existsSync(path))
|
|
92
|
+
blockers.push("checkout directory is missing; inspect it, then run git worktree prune");
|
|
93
|
+
else {
|
|
94
|
+
const real = realpathSync.native(path);
|
|
95
|
+
if (within(real, realpathSync.native(mainCwd)))
|
|
96
|
+
blockers.push("Main runs inside the checkout");
|
|
97
|
+
const gitDir = await requireGit(run, ["rev-parse", "--absolute-git-dir"], path);
|
|
98
|
+
const markers = IN_PROGRESS_MARKERS.filter((marker) => existsSync(join(gitDir, marker)));
|
|
99
|
+
if (markers.length)
|
|
100
|
+
blockers.push(`Git operation in progress (${markers.join(", ")})`);
|
|
101
|
+
const dirty = await inspectWorktreeDirty(path, run, false);
|
|
102
|
+
if (dirty.failure)
|
|
103
|
+
blockers.push(`checkout could not be inspected: ${dirty.failure}`);
|
|
104
|
+
else if (dirty.dirty)
|
|
105
|
+
blockers.push("checkout has tracked or untracked changes");
|
|
106
|
+
else if (dirty.initializedSubmodules)
|
|
107
|
+
blockers.push("checkout has initialized submodules; Git cannot remove it without force, so remove it manually");
|
|
108
|
+
if (typeof panes === "string")
|
|
109
|
+
blockers.push(`Herdr unavailable (${panes}); cannot prove no pane uses the checkout`);
|
|
110
|
+
else {
|
|
111
|
+
const used = panes.filter((pane) => pane.cwds.some((cwd) => within(path, cwd) || within(real, cwd)));
|
|
112
|
+
if (used.length)
|
|
113
|
+
blockers.push(`Herdr pane ${used.map(({ paneId }) => paneId).join(", ")} works inside the checkout`);
|
|
114
|
+
}
|
|
115
|
+
ignored = (await requireGit(run, ["status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal"], path))
|
|
116
|
+
.split("\0").filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
branch, tip, ...(checkout ? { path: checkout.path } : {}), prunable: !blockers.length, ignored,
|
|
121
|
+
reason: blockers.length ? blockers.join("; ") : `merged into ${merged}; ${checkout ? "clean checkout" : "no checkout"}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const existing = (snap) => snap.checkouts.filter((item) => !item.missing && existsSync(item.path)).map(({ path }) => path);
|
|
125
|
+
async function evidence(root, mainCwd, guards, run) {
|
|
126
|
+
const snap = await snapshot(root, mainCwd, run);
|
|
127
|
+
const ownership = await guards.ownership(existing(snap));
|
|
128
|
+
let panes;
|
|
129
|
+
try {
|
|
130
|
+
panes = await guards.panes();
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
panes = (error instanceof Error ? error.message : String(error)).slice(0, 200);
|
|
134
|
+
}
|
|
135
|
+
return { snap, ownership, panes };
|
|
136
|
+
}
|
|
137
|
+
/** Read-only review of every pi-subagent worker or integration branch and its checkout. */
|
|
138
|
+
export async function inspectPruneCandidates(root, mainCwd, guards, run = runGit) {
|
|
139
|
+
const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
|
|
140
|
+
const items = [];
|
|
141
|
+
for (const branch of [...snap.branches.keys()].sort())
|
|
142
|
+
items.push(await classify(snap, branch, mainCwd, ownership, panes, run, root));
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
async function removeOne(root, mainCwd, reviewed, allowIgnored, guards, run) {
|
|
146
|
+
const skip = (reason) => ({ outcome: "skipped", item: reviewed, reason });
|
|
147
|
+
const fail = (reason) => ({ outcome: "failed", item: reviewed, reason });
|
|
148
|
+
const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
|
|
149
|
+
if (snap.branches.get(reviewed.branch) !== reviewed.tip)
|
|
150
|
+
return skip("branch was deleted or moved since review");
|
|
151
|
+
const fresh = await classify(snap, reviewed.branch, mainCwd, ownership, panes, run, root);
|
|
152
|
+
if (!fresh.prunable)
|
|
153
|
+
return skip(fresh.reason);
|
|
154
|
+
if (fresh.path !== reviewed.path)
|
|
155
|
+
return skip("checkout registration changed since review");
|
|
156
|
+
if (fresh.ignored.some((entry) => !allowIgnored || !reviewed.ignored.includes(entry)))
|
|
157
|
+
return skip("ignored content was not approved or changed since review");
|
|
158
|
+
const stale = () => guards.current !== undefined && !guards.current();
|
|
159
|
+
if (fresh.path) {
|
|
160
|
+
if (stale())
|
|
161
|
+
return skip("session or branch changed before removal; reopen /subagent prune");
|
|
162
|
+
// Git removes ignored content without --force; the checks above approved it.
|
|
163
|
+
const removed = await run(["worktree", "remove", fresh.path], root);
|
|
164
|
+
if (removed.code !== 0)
|
|
165
|
+
return fail(`git worktree remove failed: ${removed.stderr.trim().slice(0, 200)}`);
|
|
166
|
+
if (existsSync(fresh.path))
|
|
167
|
+
return fail("git worktree remove succeeded but the checkout remains");
|
|
168
|
+
}
|
|
169
|
+
if (stale())
|
|
170
|
+
return skip(`${fresh.path ? "checkout removed; " : ""}session or branch changed before branch deletion; branch kept`);
|
|
171
|
+
const deleted = await run(["update-ref", "-d", `refs/heads/${reviewed.branch}`, reviewed.tip], root);
|
|
172
|
+
if (deleted.code !== 0)
|
|
173
|
+
return fail(`${fresh.path ? "checkout removed; " : ""}branch kept: ${deleted.stderr.trim().slice(0, 200)}`);
|
|
174
|
+
return { outcome: "removed", item: reviewed, reason: fresh.reason };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
|
|
178
|
+
* branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
|
|
179
|
+
* them were in the review. Returns the repository checkouts that still exist.
|
|
180
|
+
*/
|
|
181
|
+
export async function removePruneCandidates(root, mainCwd, reviewed, allowIgnored, guards, run = runGit) {
|
|
182
|
+
const removals = [];
|
|
183
|
+
for (const item of reviewed) {
|
|
184
|
+
try {
|
|
185
|
+
removals.push(await removeOne(root, mainCwd, item, allowIgnored, guards, run));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
removals.push({ outcome: "failed", item, reason: (error instanceof Error ? error.message : String(error)).slice(0, 300) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { removals, checkouts: existing(await snapshot(root, mainCwd, run)) };
|
|
192
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { EphemeralSubagentExecutor } from "./ephemeral.ts";
|
|
2
|
+
import type { PruneOwnership } from "./prune.ts";
|
|
2
3
|
import { IntegrationGit } from "./integration-git.ts";
|
|
3
4
|
import { type AllocationKind, type CheckCommand, type ChangesetTaskRequest, type CleanupKind, type ExecuteRequest, type ExecutionPolicySnapshot, type HostAllocationIntent, type HostAllocationPlan, type ModelClass, type ReviewEvidence, type ResumeRequest, type RunState, type TaskAttempt, type TaskRequest, type TaskState, type WorktreeAllocationIntent, type WorktreeAllocationPlan, type WorkspaceIdentity } from "./schema.ts";
|
|
4
5
|
import { FileRunStore } from "./store.ts";
|
|
6
|
+
/** One durable follow-up per attempt; the runner itself never commits, stages, stashes, or discards. */
|
|
7
|
+
export declare const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
|
|
5
8
|
export declare const CLEANUP_SAFETY_BUDGET_MS = 30000;
|
|
6
9
|
export declare const TERMINATION_SAFETY_BUDGET_MS = 15000;
|
|
7
10
|
export declare const STATUS_INSPECTION_BUDGET_MS = 5000;
|
|
@@ -65,6 +68,11 @@ export type WorkerResult = {
|
|
|
65
68
|
} | {
|
|
66
69
|
outcome: "blocked" | "not_prompted";
|
|
67
70
|
diagnostic: string;
|
|
71
|
+
}
|
|
72
|
+
/** The exact turn finished in its valid owned worktree with tracked or untracked changes. */
|
|
73
|
+
| {
|
|
74
|
+
outcome: "uncommitted";
|
|
75
|
+
diagnostic: string;
|
|
68
76
|
} | {
|
|
69
77
|
outcome: "unknown" | "interrupted";
|
|
70
78
|
diagnostic: string;
|
|
@@ -314,6 +322,11 @@ export declare class IsolatedRunner {
|
|
|
314
322
|
invalidIds: string[];
|
|
315
323
|
leaseBusy: boolean;
|
|
316
324
|
}>;
|
|
325
|
+
/** Prune evidence across checkouts. A non-terminal state owns every resource name its saved text
|
|
326
|
+
* mentions; an unreadable state owns every name its raw text mentions, or every name when unreadable. */
|
|
327
|
+
pruneOwnership(checkouts: readonly string[]): Promise<PruneOwnership>;
|
|
328
|
+
/** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
|
|
329
|
+
reconcilePrunedAborts(checkouts: readonly string[], names: readonly string[]): Promise<string[]>;
|
|
317
330
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
318
331
|
drainFollowups(root: string, requestId: string, taskId: string): string[];
|
|
319
332
|
queueFollowup(root: string, requestId: string, taskId: string, instruction: string): string;
|
package/dist/runner.js
CHANGED
|
@@ -7,8 +7,11 @@ import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
|
|
|
7
7
|
const TRUNCATION_MARKER = "\n[truncated]";
|
|
8
8
|
const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
|
|
9
9
|
const CHANGESET_TASK_PROMPT_MAX_BYTES = 96 * 1024;
|
|
10
|
+
const CORRECTION_DELIVERY_RESERVE_BYTES = 1024;
|
|
10
11
|
const MAX_WORKER_PROMPTS = 32;
|
|
11
12
|
const MAX_QUEUED_FOLLOWUPS = 16;
|
|
13
|
+
/** One durable follow-up per attempt; the runner itself never commits, stages, stashes, or discards. */
|
|
14
|
+
export const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
|
|
12
15
|
export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
|
|
13
16
|
export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
|
|
14
17
|
export const STATUS_INSPECTION_BUDGET_MS = 5_000;
|
|
@@ -369,6 +372,9 @@ function exactCommandResults(results, checks) {
|
|
|
369
372
|
&& result.args.every((arg, argIndex) => arg === check.args[argIndex]);
|
|
370
373
|
});
|
|
371
374
|
}
|
|
375
|
+
function isCommitNudge(prompt) {
|
|
376
|
+
return prompt.kind === "followup" && prompt.instruction === COMMIT_NUDGE_INSTRUCTION;
|
|
377
|
+
}
|
|
372
378
|
function correctionEligible(request, attempt) {
|
|
373
379
|
const initial = attempt.prompts[0];
|
|
374
380
|
if (initial?.kind !== "initial" || attempt.prompts.some((prompt) => prompt.kind === "correction" || prompt.status === "ambiguous")
|
|
@@ -377,11 +383,13 @@ function correctionEligible(request, attempt) {
|
|
|
377
383
|
const latestPrompt = attempt.prompts.at(-1);
|
|
378
384
|
if (latestPrompt.status !== "settled")
|
|
379
385
|
return false;
|
|
386
|
+
// A commit nudge continues its turn; it never consumes the correction window.
|
|
387
|
+
const assignments = attempt.prompts.filter((prompt) => !isCommitNudge(prompt)).length;
|
|
380
388
|
if (!attempt.candidate) {
|
|
381
|
-
return
|
|
389
|
+
return assignments === 1 && !attempt.preliminaryChecks && Boolean(initial.failure?.trim());
|
|
382
390
|
}
|
|
383
391
|
const checks = attempt.preliminaryChecks;
|
|
384
|
-
return
|
|
392
|
+
return assignments === 1 && Boolean(checks
|
|
385
393
|
&& checks.phase === "preliminary"
|
|
386
394
|
&& exactCommandResults(checks.results, request.checks)
|
|
387
395
|
&& sameIdentity(checks.candidate, attempt.candidate)
|
|
@@ -403,10 +411,10 @@ export function buildChangesetTaskPrompt(input) {
|
|
|
403
411
|
&& (typeof input.failure !== "string" || !input.failure.trim() || input.failure.trim() !== input.failure || input.failure.includes("\0"))) {
|
|
404
412
|
throw new Error("correction failure must be a non-empty exact string.");
|
|
405
413
|
}
|
|
406
|
-
if (input.kind === "followup"
|
|
414
|
+
if ((input.kind === "followup" || input.instruction !== undefined)
|
|
407
415
|
&& (typeof input.instruction !== "string" || !input.instruction.trim() || input.instruction.trim() !== input.instruction
|
|
408
416
|
|| input.instruction.includes("\0") || input.instruction.length > 32_000)) {
|
|
409
|
-
throw new Error("follow-up instruction must be non-empty exact text of at most 32000 characters
|
|
417
|
+
throw new Error(`${input.kind === "followup" ? "follow-up" : "correction"} instruction must be non-empty exact text of at most 32000 characters.`);
|
|
410
418
|
}
|
|
411
419
|
const checks = input.task.checks.map((check) => JSON.stringify({ command: check.command, args: check.args })).join("\n");
|
|
412
420
|
const upstreamTaskData = input.contexts.length
|
|
@@ -440,6 +448,7 @@ export function buildChangesetTaskPrompt(input) {
|
|
|
440
448
|
"Required checks (direct command/argv):",
|
|
441
449
|
checks,
|
|
442
450
|
...(input.kind === "correction" ? ["", "Correction failure:", input.failure] : []),
|
|
451
|
+
...(input.kind === "correction" && input.instruction ? ["", "Main's instruction:", input.instruction] : []),
|
|
443
452
|
"",
|
|
444
453
|
"Work only in the exact worktree above. Commit the complete result and leave that worktree clean.",
|
|
445
454
|
].join("\n");
|
|
@@ -572,6 +581,55 @@ export class IsolatedRunner {
|
|
|
572
581
|
}
|
|
573
582
|
return { requests, invalidIds: inventory.invalidIds, leaseBusy };
|
|
574
583
|
}
|
|
584
|
+
/** Prune evidence across checkouts. A non-terminal state owns every resource name its saved text
|
|
585
|
+
* mentions; an unreadable state owns every name its raw text mentions, or every name when unreadable. */
|
|
586
|
+
async pruneOwnership(checkouts) {
|
|
587
|
+
const owners = [];
|
|
588
|
+
let lease;
|
|
589
|
+
for (const checkout of checkouts) {
|
|
590
|
+
const root = realpathSync.native(checkout);
|
|
591
|
+
if (!lease && await this.store.hasProductiveRunLease(root))
|
|
592
|
+
lease = `productive lease is held by a live run in ${root}`;
|
|
593
|
+
const { states, invalidIds } = await this.store.list(root);
|
|
594
|
+
for (const { state } of states) {
|
|
595
|
+
if (!terminal(state))
|
|
596
|
+
owners.push({ label: `owned by ${state.status} request ${state.request.id}`, text: JSON.stringify(state) });
|
|
597
|
+
}
|
|
598
|
+
for (const id of invalidIds) {
|
|
599
|
+
let text;
|
|
600
|
+
try {
|
|
601
|
+
text = await this.store.readRaw(root, id);
|
|
602
|
+
}
|
|
603
|
+
catch { /* It may own any name. */ }
|
|
604
|
+
owners.push({ label: `unreadable state ${id} in ${root} may own it`, text });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return { ...(lease ? { lease } : {}), owner: (name) => owners.find(({ text }) => text === undefined || text.includes(name))?.label };
|
|
608
|
+
}
|
|
609
|
+
/** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
|
|
610
|
+
async reconcilePrunedAborts(checkouts, names) {
|
|
611
|
+
const lines = [];
|
|
612
|
+
for (const checkout of checkouts) {
|
|
613
|
+
const root = realpathSync.native(checkout);
|
|
614
|
+
const { requests, states } = await this.listRequests(root);
|
|
615
|
+
for (const [index, state] of states.entries()) {
|
|
616
|
+
const id = state.request.id;
|
|
617
|
+
const text = JSON.stringify(state);
|
|
618
|
+
if (requests[index].status !== "aborted · retained" || !names.some((name) => text.includes(name)))
|
|
619
|
+
continue;
|
|
620
|
+
try {
|
|
621
|
+
await this.abort(id, root);
|
|
622
|
+
const after = (await this.listRequests(root)).requests.find((request) => request.id === id);
|
|
623
|
+
lines.push(after?.status === "aborted" ? `Reconciled cleanup of aborted request ${id}.`
|
|
624
|
+
: `Aborted request ${id} still has pending cleanup; inspect subagent_status.`);
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
lines.push(`Aborted request ${id} could not be reconciled: ${errorText(error)}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return lines;
|
|
632
|
+
}
|
|
575
633
|
canFollowup(root, requestId, taskId) {
|
|
576
634
|
const active = this.activeControl(realpathSync.native(root), requestId, taskId);
|
|
577
635
|
return active !== undefined && active.task.status === "working" && !active.control.isSealed;
|
|
@@ -757,6 +815,8 @@ export class IsolatedRunner {
|
|
|
757
815
|
throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
|
|
758
816
|
if (request.action === "verify")
|
|
759
817
|
throw new Error(`Text task ${task.taskId} cannot be verified.`);
|
|
818
|
+
if (request.instruction !== undefined)
|
|
819
|
+
throw new Error(`Text task ${task.taskId} retry cannot take a worker instruction.`);
|
|
760
820
|
if (!textRetryEligible(state, task)) {
|
|
761
821
|
const attempt = task.attempts.at(-1);
|
|
762
822
|
if ((attempt && !["failed", "superseded"].includes(attempt.status)) || task.attempts.length >= 2) {
|
|
@@ -771,7 +831,7 @@ export class IsolatedRunner {
|
|
|
771
831
|
if (task.status !== "needs_attention")
|
|
772
832
|
throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
|
|
773
833
|
if (request.action === "retry")
|
|
774
|
-
return await this.retry(handle, task, scope);
|
|
834
|
+
return await this.retry(handle, task, scope, request.instruction);
|
|
775
835
|
return await this.verifyRetainedTask(handle, task, scope);
|
|
776
836
|
}
|
|
777
837
|
catch (error) {
|
|
@@ -2179,6 +2239,8 @@ export class IsolatedRunner {
|
|
|
2179
2239
|
let kind = initialKind;
|
|
2180
2240
|
let failure = task.failure;
|
|
2181
2241
|
let instruction = initialInstruction;
|
|
2242
|
+
// A nudge keeps its dirty turn's exact clean pre-prompt lineage.
|
|
2243
|
+
let nudgeFrom;
|
|
2182
2244
|
for (;;) {
|
|
2183
2245
|
if (kind === "followup" && instruction === undefined) {
|
|
2184
2246
|
throw new Error("Follow-up worker execution requires an exact queued instruction.");
|
|
@@ -2195,11 +2257,12 @@ export class IsolatedRunner {
|
|
|
2195
2257
|
}
|
|
2196
2258
|
let preCandidate;
|
|
2197
2259
|
try {
|
|
2198
|
-
preCandidate = runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectTaskCandidate({
|
|
2260
|
+
preCandidate = nudgeFrom ?? runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectTaskCandidate({
|
|
2199
2261
|
root: state.root,
|
|
2200
2262
|
task: request,
|
|
2201
2263
|
attempt,
|
|
2202
2264
|
}, context)), "Pre-prompt candidate identity");
|
|
2265
|
+
nudgeFrom = undefined;
|
|
2203
2266
|
}
|
|
2204
2267
|
catch (error) {
|
|
2205
2268
|
this.rethrowStopped(error);
|
|
@@ -2233,7 +2296,7 @@ export class IsolatedRunner {
|
|
|
2233
2296
|
kind,
|
|
2234
2297
|
status: "submitting",
|
|
2235
2298
|
preCandidate,
|
|
2236
|
-
...(
|
|
2299
|
+
...(instruction !== undefined ? { instruction } : {}),
|
|
2237
2300
|
at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
|
|
2238
2301
|
};
|
|
2239
2302
|
attempt.prompts.push(prompt);
|
|
@@ -2278,6 +2341,19 @@ export class IsolatedRunner {
|
|
|
2278
2341
|
return;
|
|
2279
2342
|
}
|
|
2280
2343
|
prompt.status = "settled";
|
|
2344
|
+
if (worker.outcome === "uncommitted") {
|
|
2345
|
+
prompt.failure = boundedRuntimeDiagnostic(worker.diagnostic, "uncommitted worker diagnostic");
|
|
2346
|
+
if (attempt.prompts.some(isCommitNudge)) {
|
|
2347
|
+
this.attention(task, "The commit nudge was sent, but the worker turn still left uncommitted changes in its worktree; they are retained for inspection.");
|
|
2348
|
+
await this.saveProductive(handle);
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
await this.saveProductive(handle);
|
|
2352
|
+
kind = "followup";
|
|
2353
|
+
instruction = COMMIT_NUDGE_INSTRUCTION;
|
|
2354
|
+
nudgeFrom = preCandidate;
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2281
2357
|
if (worker.outcome === "blocked") {
|
|
2282
2358
|
failure = boundedRuntimeDiagnostic(worker.diagnostic, "blocked worker diagnostic");
|
|
2283
2359
|
prompt.failure = failure;
|
|
@@ -2522,8 +2598,11 @@ export class IsolatedRunner {
|
|
|
2522
2598
|
await this.saveProductive(handle);
|
|
2523
2599
|
return this.response(handle.state);
|
|
2524
2600
|
}
|
|
2525
|
-
async retry(handle, task, scope) {
|
|
2601
|
+
async retry(handle, task, scope, instruction) {
|
|
2526
2602
|
const attempt = task.attempts.at(-1);
|
|
2603
|
+
if (instruction !== undefined && !attempt?.prompts.length) {
|
|
2604
|
+
throw new Error("A retry instruction requires a same-worker correction of a prompted attempt.");
|
|
2605
|
+
}
|
|
2527
2606
|
if (!attempt) {
|
|
2528
2607
|
for (const pending of handle.state.tasks) {
|
|
2529
2608
|
if (pending.status === "needs_attention" && pending.attempts.length === 0) {
|
|
@@ -2538,11 +2617,26 @@ export class IsolatedRunner {
|
|
|
2538
2617
|
if (attempt.prompts.some((prompt) => prompt.status === "ambiguous")) {
|
|
2539
2618
|
throw new Error("An ambiguous delivered prompt is never replayed.");
|
|
2540
2619
|
}
|
|
2541
|
-
|
|
2620
|
+
const request = changesetTaskRequest(handle.state, task.taskId);
|
|
2621
|
+
if (!this.correctionAllowed(handle.state, request, attempt)) {
|
|
2542
2622
|
throw new Error("The same-agent correction is unavailable or already used.");
|
|
2543
2623
|
}
|
|
2624
|
+
// Build the correction before it is recorded: an oversized prompt must not use up the only correction.
|
|
2625
|
+
// The reserve covers the worktree path and turn identity the host adds before delivery.
|
|
2626
|
+
const assignment = buildChangesetTaskPrompt({
|
|
2627
|
+
goal: handle.state.request.goal,
|
|
2628
|
+
contexts: resolveTextTaskContexts(handle.state, request),
|
|
2629
|
+
task: request,
|
|
2630
|
+
kind: "correction",
|
|
2631
|
+
worktreeCwd: allocationByKind(attempt, "agent")?.worktreeCwd ?? "",
|
|
2632
|
+
failure: task.failure,
|
|
2633
|
+
...(instruction === undefined ? {} : { instruction }),
|
|
2634
|
+
});
|
|
2635
|
+
if (Buffer.byteLength(assignment, "utf8") + CORRECTION_DELIVERY_RESERVE_BYTES > CHANGESET_TASK_PROMPT_MAX_BYTES) {
|
|
2636
|
+
throw new Error(`Worker assignment exceeds ${CHANGESET_TASK_PROMPT_MAX_BYTES} bytes with delivery overhead; shorten the retry instruction.`);
|
|
2637
|
+
}
|
|
2544
2638
|
task.status = "working";
|
|
2545
|
-
await this.driveWorkerSafely(handle, task, scope, "correction");
|
|
2639
|
+
await this.driveWorkerSafely(handle, task, scope, "correction", instruction);
|
|
2546
2640
|
}
|
|
2547
2641
|
else {
|
|
2548
2642
|
if (attempt.termination || allocationByKind(attempt, "agent")) {
|
package/dist/schema.d.ts
CHANGED
|
@@ -121,6 +121,7 @@ export declare const ResumeRequestSchema: Type.TUnion<[Type.TObject<{
|
|
|
121
121
|
id: Type.TString;
|
|
122
122
|
action: Type.TLiteral<"retry">;
|
|
123
123
|
taskId: Type.TString;
|
|
124
|
+
instruction: Type.TOptional<Type.TString>;
|
|
124
125
|
}>, Type.TObject<{
|
|
125
126
|
id: Type.TString;
|
|
126
127
|
action: Type.TLiteral<"verify">;
|
|
@@ -133,6 +134,7 @@ export declare const ResumeRequestParameters: Type.TObject<{
|
|
|
133
134
|
id: Type.TString;
|
|
134
135
|
action: Type.TUnion<[Type.TLiteral<"retry">, Type.TLiteral<"verify">, Type.TLiteral<"finalize">]>;
|
|
135
136
|
taskId: Type.TOptional<Type.TString>;
|
|
137
|
+
instruction: Type.TOptional<Type.TString>;
|
|
136
138
|
}>;
|
|
137
139
|
export type IdOnly = Static<typeof IdOnlySchema>;
|
|
138
140
|
export type CheckCommand = Static<typeof CheckCommandSchema>;
|
|
@@ -2389,6 +2391,10 @@ export declare const StatusOutputSchema: Type.TObject<{
|
|
|
2389
2391
|
scope: Type.TUnion<[Type.TLiteral<"task">, Type.TLiteral<"final">, Type.TLiteral<"request">]>;
|
|
2390
2392
|
taskId: Type.TOptional<Type.TString>;
|
|
2391
2393
|
failure: Type.TOptional<Type.TString>;
|
|
2394
|
+
failedReview: Type.TOptional<Type.TObject<{
|
|
2395
|
+
verdict: Type.TString;
|
|
2396
|
+
verdictTruncated: Type.TBoolean;
|
|
2397
|
+
}>>;
|
|
2392
2398
|
}>>;
|
|
2393
2399
|
createdAt: Type.TInteger;
|
|
2394
2400
|
updatedAt: Type.TInteger;
|
|
@@ -2421,6 +2427,7 @@ export declare const StatusOutputSchema: Type.TObject<{
|
|
|
2421
2427
|
id: Type.TString;
|
|
2422
2428
|
action: Type.TLiteral<"retry">;
|
|
2423
2429
|
taskId: Type.TString;
|
|
2430
|
+
instruction: Type.TOptional<Type.TString>;
|
|
2424
2431
|
}>, Type.TObject<{
|
|
2425
2432
|
id: Type.TString;
|
|
2426
2433
|
action: Type.TLiteral<"verify">;
|
package/dist/schema.js
CHANGED
|
@@ -62,7 +62,7 @@ export const ExecuteRequestSchema = Type.Object({
|
|
|
62
62
|
}, { additionalProperties: false });
|
|
63
63
|
export const IdOnlySchema = Type.Object({ id: IdSchema }, { additionalProperties: false });
|
|
64
64
|
export const ResumeRequestSchema = Type.Union([
|
|
65
|
-
Type.Object({ id: IdSchema, action: Type.Literal("retry"), taskId: IdSchema }, { additionalProperties: false }),
|
|
65
|
+
Type.Object({ id: IdSchema, action: Type.Literal("retry"), taskId: IdSchema, instruction: Type.Optional(TextSchema) }, { additionalProperties: false }),
|
|
66
66
|
Type.Object({ id: IdSchema, action: Type.Literal("verify"), taskId: IdSchema }, { additionalProperties: false }),
|
|
67
67
|
Type.Object({ id: IdSchema, action: Type.Literal("finalize") }, { additionalProperties: false }),
|
|
68
68
|
]);
|
|
@@ -75,6 +75,7 @@ export const ResumeRequestParameters = Type.Object({
|
|
|
75
75
|
ResumeRequestSchema.anyOf[2].properties.action,
|
|
76
76
|
]),
|
|
77
77
|
taskId: Type.Optional(IdSchema),
|
|
78
|
+
instruction: ResumeRequestSchema.anyOf[0].properties.instruction,
|
|
78
79
|
}, { additionalProperties: false });
|
|
79
80
|
const AllocationLifecycleFields = {
|
|
80
81
|
generation: Type.Integer({ minimum: 1, maximum: 2 }),
|
|
@@ -466,6 +467,7 @@ export const StatusOutputSchema = Type.Object({
|
|
|
466
467
|
scope: Type.Union([Type.Literal("task"), Type.Literal("final"), Type.Literal("request")]),
|
|
467
468
|
taskId: Type.Optional(IdSchema),
|
|
468
469
|
failure: Type.Optional(Type.String()),
|
|
470
|
+
failedReview: Type.Optional(Type.Object({ verdict: Type.String(), verdictTruncated: Type.Boolean() })),
|
|
469
471
|
})),
|
|
470
472
|
createdAt: TimestampSchema,
|
|
471
473
|
updatedAt: TimestampSchema,
|
|
@@ -635,7 +637,10 @@ export function parseResumeRequest(value) {
|
|
|
635
637
|
if (!Check(ResumeRequestSchema, value)) {
|
|
636
638
|
throw schemaValidationError("subagent_resume request must match one strict action", Errors(ResumeRequestSchema, value));
|
|
637
639
|
}
|
|
638
|
-
|
|
640
|
+
const request = value;
|
|
641
|
+
if ("instruction" in request && request.instruction !== undefined)
|
|
642
|
+
requireExactAllocationText(request.instruction, "Retry instruction");
|
|
643
|
+
return request;
|
|
639
644
|
}
|
|
640
645
|
export function sameIdentity(left, right) {
|
|
641
646
|
return left.branch === right.branch && left.head === right.head && left.index === right.index && left.tree === right.tree;
|
|
@@ -1080,11 +1085,11 @@ export function parseRunState(value) {
|
|
|
1080
1085
|
}
|
|
1081
1086
|
for (let promptIndex = 0; promptIndex < attempt.prompts.length; promptIndex += 1) {
|
|
1082
1087
|
const prompt = attempt.prompts[promptIndex];
|
|
1083
|
-
if (
|
|
1084
|
-
throw new Error(`
|
|
1088
|
+
if (prompt.kind === "followup" ? prompt.instruction === undefined : prompt.kind === "initial" && prompt.instruction !== undefined) {
|
|
1089
|
+
throw new Error(`Prompt history for ${definition.id} has invalid instruction evidence.`);
|
|
1085
1090
|
}
|
|
1086
1091
|
if (prompt.instruction !== undefined) {
|
|
1087
|
-
requireExactAllocationText(prompt.instruction, `
|
|
1092
|
+
requireExactAllocationText(prompt.instruction, `Prompt instruction for ${definition.id}`);
|
|
1088
1093
|
}
|
|
1089
1094
|
if (promptIndex === 0)
|
|
1090
1095
|
continue;
|
package/dist/store.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export declare class FileRunStore {
|
|
|
48
48
|
states: RunStateHandle[];
|
|
49
49
|
invalidIds: string[];
|
|
50
50
|
}>;
|
|
51
|
+
/** Raw text of a state file, for evidence when it does not parse. */
|
|
52
|
+
readRaw(root: string, id: string): Promise<string>;
|
|
51
53
|
private handle;
|
|
52
54
|
}
|
|
53
55
|
export {};
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, open, readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { lstat, mkdir, open, readdir, realpath } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
|
|
6
6
|
import { check, lock } from "proper-lockfile";
|
|
@@ -272,8 +272,13 @@ export class FileRunStore {
|
|
|
272
272
|
const entries = await this.stateEntries(root);
|
|
273
273
|
const states = [];
|
|
274
274
|
const invalidIds = [];
|
|
275
|
-
for (const entry of entries
|
|
275
|
+
for (const entry of entries) {
|
|
276
276
|
const id = entry.name.slice(0, -5);
|
|
277
|
+
// A symlink, directory, or other non-regular entry is unreadable state, never absent state.
|
|
278
|
+
if (!entry.isFile()) {
|
|
279
|
+
invalidIds.push(id);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
277
282
|
try {
|
|
278
283
|
states.push(await this.load(root, id));
|
|
279
284
|
}
|
|
@@ -283,6 +288,13 @@ export class FileRunStore {
|
|
|
283
288
|
}
|
|
284
289
|
return { states, invalidIds };
|
|
285
290
|
}
|
|
291
|
+
/** Raw text of a state file, for evidence when it does not parse. */
|
|
292
|
+
async readRaw(root, id) {
|
|
293
|
+
const path = this.statePath(root, id);
|
|
294
|
+
if (!(await lstat(path)).isFile())
|
|
295
|
+
throw new Error(`State entry ${id} is not a regular file.`);
|
|
296
|
+
return await readTextFileBounded(path, STATE_MAX_BYTES);
|
|
297
|
+
}
|
|
286
298
|
handle(state, path) {
|
|
287
299
|
return new RunStateHandle(state, path, async (contents) => {
|
|
288
300
|
await this.assertSafeDestination(state.root, path);
|
package/dist/worktree.d.ts
CHANGED
|
@@ -24,6 +24,9 @@ export type WorktreePayload = {
|
|
|
24
24
|
commits?: number;
|
|
25
25
|
dirty?: boolean;
|
|
26
26
|
};
|
|
27
|
+
export declare const WORKTREES_DIRNAME = ".worktrees";
|
|
28
|
+
/** Branches `createChildWorktree` creates for workers and integration checkouts, at `<primary>/.worktrees/<branch basename>`. */
|
|
29
|
+
export declare const OWNED_BRANCH_PATTERN: RegExp;
|
|
27
30
|
export declare class WorktreeSetupError extends Error {
|
|
28
31
|
name: string;
|
|
29
32
|
readonly worktree: WorktreeInfo;
|
package/dist/worktree.js
CHANGED
|
@@ -3,8 +3,10 @@ import { existsSync, lstatSync } from "node:fs";
|
|
|
3
3
|
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { runGit } from "./git-process.js";
|
|
6
|
-
const WORKTREES_DIRNAME = ".worktrees";
|
|
6
|
+
export const WORKTREES_DIRNAME = ".worktrees";
|
|
7
7
|
const BRANCH_NAMESPACE = "pi-subagent";
|
|
8
|
+
/** Branches `createChildWorktree` creates for workers and integration checkouts, at `<primary>/.worktrees/<branch basename>`. */
|
|
9
|
+
export const OWNED_BRANCH_PATTERN = new RegExp(`^${BRANCH_NAMESPACE}/subagent-[0-9a-f]{24}$`);
|
|
8
10
|
export class WorktreeSetupError extends Error {
|
|
9
11
|
name = "WorktreeSetupError";
|
|
10
12
|
worktree;
|
package/docs/orchestration.md
CHANGED
|
@@ -112,7 +112,7 @@ The user's request can authorize the declared local graph, checks, review, and M
|
|
|
112
112
|
|
|
113
113
|
`/subagent` opens a native picker for direct recovery and isolated inspection; locally owned active changesets may queue an optional same-worker revision or drain all still-pending instructions into the native editor and submit one replacement. Cancel after draining leaves them withdrawn; claimed instructions are unaffected. Pi's global Option+Up is unchanged. There is no guaranteed post-completion editing window; late follow-ups fail visibly. After readiness, use `subagent_stage revise` with an exact candidate, generation, current `expectedTip`, and instruction when a same-worker correction remains allowed. Material scope growth, missing authorization, user-owned conflicts, or consequential external actions still require a new decision. pi-subagent never pushes, publishes, deploys, or opens a pull request.
|
|
114
114
|
|
|
115
|
-
`subagent_status` reports ready candidates with `taskId`, `attempt`, and `tip`, plus each generation's number, stage lineage, combined tip, checks, promotion, and retained worktree. Use those exact identities (including branch, HEAD, index, and tree) rather than guessed commits:
|
|
115
|
+
`subagent_status` puts failed review verdicts from every task's latest attempt (also while siblings run), the final gate, and each generation into its model-visible text within one shared 32 KiB budget and counts omitted verdicts; its structured projection carries each verdict up to 32 KiB and sets `verdictTruncated` when it is cut; asynchronous follow-ups keep a short preview and point Main to `subagent_status`. It reports ready candidates with `taskId`, `attempt`, and `tip`, plus each generation's number, stage lineage, combined tip, checks, promotion, and retained worktree. Use those exact identities (including branch, HEAD, index, and tree) rather than guessed commits:
|
|
116
116
|
|
|
117
117
|
- `subagent_stage`: `{ id, action: "stage" | "resolve" | "reject" | "revise", generation, taskId, attempt, candidate, expectedTip, instruction? }`. `candidate` is the ready candidate's `tip`; `expectedTip` is Main's recorded identity for the first stage or the previous stage's exact tip. `resolve` reconciles a pending/conflicted merge; `reject` and `revise` arbitrate a candidate without changing Main.
|
|
118
118
|
- `subagent_integrate`: `{ id, action: "advance" | "validate" | "correct" | "promote" | "reconcile" | "cleanup", generation, expectedTip }`. Use the generation's exact `combinedTip`. `advance` dispatches ready dependents on that snapshot, `correct` records the single clean committed correction after a definitive failed check/review, `reconcile` inspects an interrupted validation or promotion without replaying an uncertain promotion, and `cleanup` completes proved post-promotion cleanup.
|
|
@@ -129,7 +129,9 @@ State version 5 is stored privately under:
|
|
|
129
129
|
<agent-dir>/config/pi-subagent/state/<repository-hash>/<request-id>.json
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
Strict parsing rejects unknown properties, malformed evidence, invalid lineage, and old v4 state without migration; preserve old files and recover their work manually. Writes are atomic and repository productive work uses a durable process lease. `delegate_task` and `subagent_resume` acknowledge only after durable state is saved, then continue productive work asynchronously. A durably recorded worker attention state sends an immediate once-per-transition Pi follow-up to the launching session, even while siblings run; the wave also reports its final outcome. Isolated workers persist a private exact Pi session under the owned process lease so a proved final answer without a changed clean commit becomes attention rather than an indefinitely pending prompt. Missing or mismatched session proof cannot establish completion and never authorizes prompt replay. `subagent_status` is read-only and remains the recovery authority if a follow-up is missed. `subagent_resume` accepts only the continuation reported by state. `subagent_abort` performs exact worker termination and conservative teardown. Without a retained candidate, it removes only owned checkouts still clean and unchanged from their pinned base, including ignored-file checks. Active productive execution defers cleanup until it settles. Repeating abort on an aborted request reconciles pending cleanup without replaying uncertain termination. Dirty or committed work and uncertain allocations stay retained with visible blockers; `/subagent recover` includes aborted requests with retained resources.
|
|
132
|
+
Strict parsing rejects unknown properties, malformed evidence, invalid lineage, and old v4 state without migration; preserve old files and recover their work manually. Writes are atomic and repository productive work uses a durable process lease. `delegate_task` and `subagent_resume` acknowledge only after durable state is saved, then continue productive work asynchronously. A durably recorded worker attention state sends an immediate once-per-transition Pi follow-up to the launching session, even while siblings run; the wave also reports its final outcome. Isolated workers persist a private exact Pi session under the owned process lease so a proved final answer without a changed clean commit becomes attention rather than an indefinitely pending prompt. Missing or mismatched session proof cannot establish completion and never authorizes prompt replay. When a proved finished turn leaves tracked or untracked changes in a valid owned worktree, the runner sends the same worker one commit reminder per attempt, saved as a follow-up prompt record before delivery like any other prompt. It does not use a correction or a dispatch attempt. The runner never commits, stages, stashes, or discards; a commit from the reminder goes through the normal candidate path, and a worktree still dirty after it needs attention. `subagent_status` is read-only and remains the recovery authority if a follow-up is missed. `subagent_resume` accepts only the continuation reported by state. A `retry` that sends a same-worker correction may add an `instruction` (at most 32000 exact characters, no NUL), appended to the correction prompt as Main's instruction after the saved failure and saved in its prompt record before delivery; other actions reject it. `subagent_abort` performs exact worker termination and conservative teardown. Without a retained candidate, it removes only owned checkouts still clean and unchanged from their pinned base, including ignored-file checks. Active productive execution defers cleanup until it settles. Repeating abort on an aborted request reconciles pending cleanup without replaying uncertain termination. Dirty or committed work and uncertain allocations stay retained with visible blockers; `/subagent recover` includes aborted requests with retained resources.
|
|
133
|
+
|
|
134
|
+
`/subagent prune` reviews every `pi-subagent/subagent-<24 hex>` branch in the repository and its `.worktrees/` checkout. An item is removable only when its tip is an ancestor of Main's HEAD or the local `origin` default-branch ref (no fetch), the checkout is still on its own branch (a detached or switched owned checkout is found by path and blocks), has no tracked or untracked changes, no initialized submodules, and no in-progress Git operation, no non-terminal or unreadable state in any of the repository's checkouts records its name (a non-regular state entry counts as unreadable and may own any name), no productive lease is live, and no Herdr pane works inside the checkout (unavailable Herdr refuses). Removal needs interactive confirmation; checkouts with ignored files need a second confirmation that names each ignored entry in full, and a checkout whose list does not fit is left out; that approval covers each entry with all its contents, and a new or changed top-level ignored entry skips the checkout. Prunable items are listed first, and only items shown in full in both dialogs can be removed; an over-long list ends with an explicit `+N more not shown` line instead of a silent cut. The confirming session's ownership is rechecked immediately before each `git worktree remove` and branch deletion. Each item is re-verified immediately before `git worktree remove`, and its branch is deleted with a compare-and-swap on the reviewed tip. Aborted requests that recorded a removed resource are then reconciled by the guarded abort path, which proves absence; requests it cannot reconcile are reported. State files are never edited directly.
|
|
133
135
|
|
|
134
136
|
Text attempt history records dispatches, not pre-dispatch inspection failures. Such failures retain task-level attention without adding an attempt or rewriting earlier execution diagnostics. A recorded interrupted dispatch still consumes an attempt. Verification requires the same passing preliminary checks, judgment, and ownership as candidate retention; readiness and the verified candidate are saved together.
|
|
135
137
|
|