@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/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 attempt.prompts.length === 1 && !attempt.preliminaryChecks && Boolean(initial.failure?.trim());
389
+ return assignments === 1 && !attempt.preliminaryChecks && Boolean(initial.failure?.trim());
382
390
  }
383
391
  const checks = attempt.preliminaryChecks;
384
- return attempt.prompts.length === 1 && Boolean(checks
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
- ...(kind === "followup" ? { instruction } : {}),
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
- if (!this.correctionAllowed(handle.state, changesetTaskRequest(handle.state, task.taskId), attempt)) {
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
- return value;
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 ((prompt.kind === "followup") !== (prompt.instruction !== undefined)) {
1084
- throw new Error(`Follow-up prompt history for ${definition.id} has invalid instruction evidence.`);
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, `Follow-up prompt instruction for ${definition.id}`);
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.filter((item) => item.isFile())) {
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);
@@ -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;
@@ -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