@maxiaochao/pi-toolkit 0.10.20 → 0.10.21

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/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 2026-10-10 - v0.10.21
2
+
3
+ - bundle herdr-subagents v0.4.7: fix blocked-run scheduling and limit interactive blocking to serial runs
4
+ - bundle the Herdr Subagents blocked-dispatch deadlock fix, serial-only interactive blocking, structured concurrent `missing_input` failures, and flexible run ID controls
5
+
1
6
  ## 2026-10-10 - v0.10.20
2
7
 
3
8
  - bundle herdr-subagents v0.4.6: reduce global prompt noise by keeping Subagent workflow guidance in tool descriptions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.20",
3
+ "version": "0.10.21",
4
4
  "description": "Personal pi coding agent toolkit: scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,3 +1,10 @@
1
+ ## 2026-10-10 - v0.4.7
2
+
3
+ - fix blocked-run scheduling and limit interactive blocking to serial runs
4
+ - prevent foreground runs from hanging behind a blocked dispatcher owner, return the blocking run ID immediately, and remove cancelled foreground calls from the queue while preserving background queuing
5
+ - limit interactive blocking to effectively serial runs; concurrent input requests become structured `missing_input` failures while independent siblings continue
6
+ - accept full run UUIDs, saved run-directory names, and unique UUID prefixes in run controls
7
+
1
8
  ## 2026-10-10 - v0.4.6
2
9
 
3
10
  - reduce global prompt noise by keeping Subagent workflow guidance in tool descriptions
@@ -64,7 +64,7 @@ herdr_subagents({
64
64
  })
65
65
  ```
66
66
 
67
- `tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues. The dispatcher starts batches FIFO, with one batch holding its active slot at a time; a failed foreground handoff releases that slot as described below. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
67
+ `tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues. The dispatcher starts batches FIFO, with one batch holding its active slot at a time; a failed foreground handoff releases that slot as described below. A blocked run retains the slot so its queued siblings can continue after an answer. While that slot is blocked, a new foreground submission fails immediately with the blocking run ID instead of waiting indefinitely; a background submission remains queued. Cancelling a foreground tool call while it is queued removes it from the queue immediately. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
68
68
 
69
69
  A foreground concurrent batch normally waits for the whole batch. If any task fails while siblings are still running, the call immediately returns the stable `runId` and that failure's details, then durably continues the same batch in the background without cancelling its siblings. The handed-off batch releases its dispatcher slot immediately, so a later submission can start while its surviving tasks are still running. The detached batch remains cancellable and is still joined during session shutdown. Its final aggregate is delivered exactly once through the same follow-up notification used by an explicit background run.
70
70
 
@@ -76,6 +76,8 @@ herdr_subagents_control({ action: "status", runId: "<run-id>" })
76
76
 
77
77
  The snapshot reports the aggregate run state and every task's state, including a blocked question when present. It checks durable run state before queued state, never returns unrelated runs, and does not wait or trigger a parent turn. Do not poll full `history` for progress.
78
78
 
79
+ Run controls accept the complete run UUID, the saved run-directory name, or any unique UUID prefix. Results always return the canonical complete UUID.
80
+
79
81
  A child missing required information reports `needs-input` with an exact question. The run becomes blocked, keeps its Herdr tab, pane, partial report, and Pi session, and returns control to the parent. Answer through the control tool to continue the original session:
80
82
 
81
83
  ```text
@@ -84,6 +86,10 @@ herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the
84
86
 
85
87
  Blocked is a normal control state, not a failed tool call. Only terminal failure, cancellation, or a partial aggregate is returned as a tool error.
86
88
 
89
+ Blocking is limited to effectively serial runs (`effectiveConcurrency: 1`), where task order can depend on the answer. In a concurrent read-only run, tasks must be independent: a task that requests input is recorded as `failed` with `failureKind: "missing_input"` and `requiredInput`, while its independent siblings continue. This prevents a blocked concurrent task from holding the parent tool call behind unrelated long-running siblings.
90
+
91
+ Only work owned by the current parent session's main dispatcher can remain blocked. Historical follow-ups that need more input settle as `missing_input` failures, and startup reconciliation cancels blocked work whose parent session disappeared; neither path creates an unowned blocked run.
92
+
87
93
  Cancel one active or queued run with `herdr_subagents_control({ action: "cancel", runId: "<run-id>" })`. Cancellation aborts its live child prompts, archives active or queued tasks as cancelled, closes its run tab, and leaves unrelated runs alone.
88
94
 
89
95
  While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, status, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and refreshes session usage from both file events and the running one-second timer.
@@ -17,6 +17,9 @@
17
17
  - Let a detached run later block or be explicitly cancelled and verify the newer dispatcher owner remains intact and each detached run emits exactly one final notification.
18
18
  - Shut down the parent while a handed-off sibling is active and verify it is cancelled and joined, durable queue state is removed, and no stale follow-up is sent.
19
19
  - Verify all-success foreground batches still wait for every task and explicit `background: true` submissions still return immediately.
20
+ - Verify a blocked dispatcher owner immediately rejects both newly submitted and already-queued foreground runs with its full run ID, keeps explicit background runs queued, and removes queued foreground runs when their caller cancels.
21
+ - Verify only effectively serial runs can block; concurrent input requests settle as structured `missing_input` failures, including owner-loss reconciliation, while independent siblings continue.
22
+ - Verify historical follow-ups cannot create unowned blocked work and owner-loss reconciliation cancels serial blocked work instead of reconstructing hidden dispatcher ownership.
20
23
  - Verify all paths use interactive Agent start/prompt without print mode, shell injection, markers, or pane reuse.
21
24
  - Force write-capable batches to serial execution while allowing bounded read-only parallelism, including three concurrent `analyst` tasks with independent task-level models.
22
25
  - Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
@@ -32,4 +35,4 @@
32
35
 
33
36
  ## Latest baseline
34
37
 
35
- All 194 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, lifecycle, attempt-isolation, and persona cases plus the extension-contract lifecycle simulations must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed a read-only package inspection with matching durable attempt/report IDs and automatic tab cleanup.
38
+ All 198 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, lifecycle, attempt-isolation, and persona cases plus the extension-contract lifecycle simulations must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed a read-only package inspection with matching durable attempt/report IDs and automatic tab cleanup.
@@ -195,12 +195,18 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
195
195
  pi.registerTool({
196
196
  name: "herdr_subagents",
197
197
  label: "Herdr Subagents",
198
- description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Foreground calls wait for the batch unless a task fails, when the run continues in the background and returns its run ID immediately. Explicit background calls always return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
198
+ description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Only effectively serial runs can pause for input; concurrent input requests become missing_input failures while independent siblings continue. Foreground calls wait for the batch unless a task fails, when the run continues in the background and returns its run ID immediately. Explicit background calls always return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
199
199
  promptSnippet: "Run focused work in visible Herdr subagents",
200
200
  parameters: HerdrSubagentsParams,
201
201
  prepareArguments: normalizeHerdrSubagentsArguments,
202
202
 
203
203
  async execute(_toolCallId, params: HerdrSubagentsRunParams, signal, onUpdate, ctx) {
204
+ const blockedForegroundResponse = (blockingRunId: string) => {
205
+ const text = `Cannot start a foreground Subagent run while blocked run ${blockingRunId} is waiting for input. Respond to or cancel that run first, or submit this run with background: true.`;
206
+ return { content: [{ type: "text" as const, text }], details: { errorMessage: text, blockingRunId }, isError: true };
207
+ };
208
+ const blockingRunId = dispatcher.snapshot().retainedRunId;
209
+ if (!params.background && blockingRunId) return blockedForegroundResponse(blockingRunId);
204
210
  const generation = sessionGeneration;
205
211
  const runId = randomUUID();
206
212
  const controller = new AbortController();
@@ -249,7 +255,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
249
255
  dispatcher.retain(runId);
250
256
  }
251
257
  return result;
252
- });
258
+ }, params.background ? undefined : { ...(signal ? { signal } : {}), onRetained: blockedForegroundResponse });
253
259
  runPromises.set(runId, promise);
254
260
  inFlight.add(promise);
255
261
  void promise.finally(() => {
@@ -369,11 +375,12 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
369
375
  const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
370
376
  fleet.sync(result.details);
371
377
  if (params.action === "respond") {
372
- const details = result.details as { status?: string };
378
+ const details = result.details as { runId?: string; status?: string };
373
379
  if (details.status === "blocked") fleet.sync(result.details);
374
380
  else if (isSettledRunStatus(details.status)) {
375
- fleet.clearRun(params.runId);
376
- if (params.runId) dispatcher.release(params.runId);
381
+ const canonicalRunId = details.runId ?? params.runId;
382
+ fleet.clearRun(canonicalRunId);
383
+ if (canonicalRunId) dispatcher.release(canonicalRunId);
377
384
  }
378
385
  }
379
386
  return result;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,11 +10,12 @@ import { writeJsonAtomic } from "./state.ts";
10
10
  import type { OwnerIdentity } from "./ownership.ts";
11
11
  import { ownerRecord } from "./ownership.ts";
12
12
  import { RunPaneAllocator } from "./run-pane-allocator.ts";
13
- import { applyReport, applyTaskStatus, projectTaskActivity, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
13
+ import { applyReport, applyTaskStatus, normalizeNeedsInputReport, projectTaskActivity, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
14
14
  import { reportProtocolPrompt } from "./protocol.ts";
15
15
  import { cleanupRunTab } from "./tab-cleanup.ts";
16
16
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
17
17
  import { isAbortError } from "./errors.ts";
18
+ import { attemptReportPath } from "./attempts.ts";
18
19
 
19
20
  export interface BatchTask {
20
21
  name: string;
@@ -51,6 +52,8 @@ export interface BatchTaskResult {
51
52
  documents: HerdrSubagentsDocument[];
52
53
  error?: string;
53
54
  question?: string;
55
+ failureKind?: "missing_input";
56
+ requiredInput?: string;
54
57
  paneId: string;
55
58
  recordDirectory: string;
56
59
  sessionFile: string;
@@ -68,6 +71,8 @@ export interface BatchResult {
68
71
  documents: HerdrSubagentsDocument[];
69
72
  recordDirectory: string;
70
73
  tabId?: string;
74
+ question?: string;
75
+ allowedActions?: Array<"respond" | "cancel">;
71
76
  }
72
77
 
73
78
  type TaskContext = {
@@ -151,7 +156,8 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
151
156
  const prompted = await promptLiveAgent({ herdr, task: { ...task, id: context.taskId, sessionFile: context.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: context.systemPromptFile, agentName, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => emit("running", `${task.name} appears stalled; it is still running.`) });
152
157
  agentName = prompted.agentName;
153
158
  context.taskRecord.currentAttemptId = prompted.attemptId;
154
- report = prompted.report;
159
+ report = normalizeNeedsInputReport(prompted.report, effectiveConcurrency);
160
+ if (report !== prompted.report) await Promise.all([writeJsonAtomic(context.reportFile, report), writeJsonAtomic(attemptReportPath(context.taskDirectory, prompted.attemptId), report)]);
155
161
  status = applyReport(context.taskRecord, report);
156
162
  } catch (error) {
157
163
  const settled = await settleAttemptFailure(context.taskRecord, context.reportFile, {
@@ -164,7 +170,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
164
170
  }
165
171
  Object.assign(context.taskRecord, { paneId, agentName });
166
172
  await writeJsonAtomic(context.taskFile, context.taskRecord);
167
- results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
173
+ results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), ...(report.failureKind ? { failureKind: report.failureKind } : {}), ...(report.requiredInput ? { requiredInput: report.requiredInput } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
168
174
  activity[index] = projectTaskActivity(context.taskRecord, index);
169
175
  settled += 1;
170
176
  emit("running", `${settled}/${tasks.length} tasks settled.`);
@@ -197,7 +203,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
197
203
  const summary = status === "cancelled" ? "Subagent batch cancelled." : blocked ? `${blocked.name} needs input: ${blocked.question}` : status === "partial" ? `${completed}/${tasks.length} tasks completed; ${failed} failed; ${cancelled} cancelled.` : failed ? `${completed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
198
204
  applyRunStatus(runRecord, status, { question: blocked?.question });
199
205
  await writeJsonAtomic(runFile, runRecord);
200
- return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId };
206
+ return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId, ...(blocked?.question ? { question: blocked.question, allowedActions: ["respond", "cancel"] } : {}) };
201
207
  } catch (error) {
202
208
  applyRunStatus(runRecord, options.signal?.aborted ? "cancelled" : "failed", { error: error instanceof Error ? error.message : String(error) });
203
209
  await writeJsonAtomic(runFile, runRecord);
@@ -199,7 +199,7 @@ async function respondToBlockedTaskLocked(cwd: string, runId: string, answer: st
199
199
  }
200
200
  await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
201
201
  if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError });
202
- return { ok: false, runId, label: String(run.label ?? "batch"), status: "cancelled", summary: "Subagent response cancelled.", documents: [], recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
202
+ return { ok: false, runId: run.id, label: String(run.label ?? "batch"), status: "cancelled", summary: "Subagent response cancelled.", documents: [], recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
203
203
  }
204
204
  turn.events.push({ type: "report", status, summary: report.summary, question: report.question, error: report.error, at: new Date().toISOString() });
205
205
  Object.assign(turn, { summary: report.summary, documents: report.documents ?? [] });
@@ -6,8 +6,9 @@ import { writeJsonAtomic } from "./state.ts";
6
6
  import { cleanupRunTab } from "./tab-cleanup.ts";
7
7
  import { RunPaneAllocator } from "./run-pane-allocator.ts";
8
8
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
9
- import { applyReport, applyTaskStatus, readRunRecord, readTaskRecord, settleAttemptFailure } from "./records.ts";
9
+ import { applyReport, applyTaskStatus, normalizeNeedsInputReport, readRunRecord, readTaskRecord, settleAttemptFailure } from "./records.ts";
10
10
  import { isAbortError } from "./errors.ts";
11
+ import { attemptReportPath } from "./attempts.ts";
11
12
 
12
13
  export interface ContinueQueuedRunOptions {
13
14
  cleanup?: boolean;
@@ -50,7 +51,8 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
50
51
  await options.onUpdate?.();
51
52
  const prompted = await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: join(directory, "system-prompt.md"), agentName, signal: options.signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) });
52
53
  task.currentAttemptId = prompted.attemptId;
53
- const report = prompted.report;
54
+ const report = normalizeNeedsInputReport(prompted.report, Number(run.effectiveConcurrency) || 1);
55
+ if (report !== prompted.report) await Promise.all([writeJsonAtomic(reportFile, report), writeJsonAtomic(attemptReportPath(directory, prompted.attemptId), report)]);
54
56
  applyReport(task, report);
55
57
  } catch (error) {
56
58
  await settleAttemptFailure(task, reportFile, {
@@ -3,10 +3,19 @@ interface QueuedRun<T> {
3
3
  execute: () => Promise<T>;
4
4
  resolve: (value: T) => void;
5
5
  reject: (error: unknown) => void;
6
+ signal?: AbortSignal;
7
+ onAbort?: () => void;
8
+ onRetained?: (retainedRunId: string) => T;
9
+ }
10
+
11
+ interface SubmitOptions<T> {
12
+ signal?: AbortSignal;
13
+ onRetained?: (retainedRunId: string) => T;
6
14
  }
7
15
 
8
16
  export interface DispatcherSnapshot {
9
17
  activeRunId?: string;
18
+ retainedRunId?: string;
10
19
  queuedRunIds: string[];
11
20
  }
12
21
 
@@ -19,15 +28,33 @@ export class RunDispatcher {
19
28
 
20
29
  constructor(onChange?: (snapshot: DispatcherSnapshot) => void) { this.onChange = onChange; }
21
30
 
22
- submit<T>(id: string, execute: () => Promise<T>): Promise<T> {
23
- const promise = new Promise<T>((resolve, reject) => this.queue.push({ id, execute, resolve: resolve as (value: unknown) => void, reject }));
31
+ submit<T>(id: string, execute: () => Promise<T>, options: SubmitOptions<T> = {}): Promise<T> {
32
+ const { signal, onRetained } = options;
33
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error(`Queued run '${id}' was cancelled.`));
34
+ if (this.retainedRunId && onRetained) return Promise.resolve(onRetained(this.retainedRunId));
35
+ const promise = new Promise<T>((resolve, reject) => {
36
+ const queued: QueuedRun<T> = { id, execute, resolve, reject, signal, onRetained };
37
+ queued.onAbort = () => {
38
+ const index = this.queue.indexOf(queued as QueuedRun<unknown>);
39
+ if (index < 0) return;
40
+ this.queue.splice(index, 1);
41
+ reject(signal?.reason ?? new Error(`Queued run '${id}' was cancelled.`));
42
+ this.changed();
43
+ };
44
+ signal?.addEventListener("abort", queued.onAbort, { once: true });
45
+ this.queue.push(queued as QueuedRun<unknown>);
46
+ });
24
47
  this.changed();
25
48
  void this.drain();
26
49
  return promise;
27
50
  }
28
51
 
29
52
  snapshot(): DispatcherSnapshot {
30
- return { ...(this.activeRunId ? { activeRunId: this.activeRunId } : {}), queuedRunIds: this.queue.map((run) => run.id) };
53
+ return {
54
+ ...(this.activeRunId ? { activeRunId: this.activeRunId } : {}),
55
+ ...(this.retainedRunId ? { retainedRunId: this.retainedRunId } : {}),
56
+ queuedRunIds: this.queue.map((run) => run.id),
57
+ };
31
58
  }
32
59
 
33
60
  pause(): void { this.paused = true; this.changed(); }
@@ -35,6 +62,14 @@ export class RunDispatcher {
35
62
  retain(id: string): void {
36
63
  if (this.activeRunId !== id) throw new Error(`Cannot retain inactive run '${id}'.`);
37
64
  this.retainedRunId = id;
65
+ for (let index = this.queue.length - 1; index >= 0; index -= 1) {
66
+ const queued = this.queue[index];
67
+ if (!queued.onRetained) continue;
68
+ this.queue.splice(index, 1);
69
+ if (queued.onAbort) queued.signal?.removeEventListener("abort", queued.onAbort);
70
+ try { queued.resolve(queued.onRetained(id)); }
71
+ catch (error) { queued.reject(error); }
72
+ }
38
73
  this.changed();
39
74
  }
40
75
  detach(id: string): boolean {
@@ -54,7 +89,10 @@ export class RunDispatcher {
54
89
  }
55
90
  cancelQueued(reason: Error): string[] {
56
91
  const cancelled = this.queue.splice(0);
57
- for (const run of cancelled) run.reject(reason);
92
+ for (const run of cancelled) {
93
+ if (run.onAbort) run.signal?.removeEventListener("abort", run.onAbort);
94
+ run.reject(reason);
95
+ }
58
96
  this.changed();
59
97
  return cancelled.map((run) => run.id);
60
98
  }
@@ -62,6 +100,7 @@ export class RunDispatcher {
62
100
  const index = this.queue.findIndex((run) => run.id === id);
63
101
  if (index < 0) return false;
64
102
  const [cancelled] = this.queue.splice(index, 1);
103
+ if (cancelled.onAbort) cancelled.signal?.removeEventListener("abort", cancelled.onAbort);
65
104
  cancelled.reject(reason);
66
105
  this.changed();
67
106
  return true;
@@ -73,6 +112,7 @@ export class RunDispatcher {
73
112
  if (this.activeRunId || this.paused) return;
74
113
  const run = this.queue.shift();
75
114
  if (!run) { this.changed(); return; }
115
+ if (run.onAbort) run.signal?.removeEventListener("abort", run.onAbort);
76
116
  this.activeRunId = run.id;
77
117
  this.changed();
78
118
  try { run.resolve(await run.execute()); }
@@ -8,7 +8,8 @@ import { cleanupRunTab } from "./tab-cleanup.ts";
8
8
  import type { OwnerIdentity } from "./ownership.ts";
9
9
  import { ownerRecord } from "./ownership.ts";
10
10
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
11
- import { applyReport, applyTaskStatus, isActiveStatus, projectTaskActivity, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
11
+ import { applyReport, applyTaskStatus, isActiveStatus, normalizeNeedsInputReport, projectTaskActivity, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
12
+ import { attemptReportPath } from "./attempts.ts";
12
13
  import { continuationSystemPrompt } from "./protocol.ts";
13
14
  import { isAbortError } from "./errors.ts";
14
15
  import { findRunDirectory } from "./run-locator.ts";
@@ -88,10 +89,13 @@ export function historyText(history: HistoricalRun[]): string {
88
89
  }
89
90
 
90
91
  export async function cleanSubagentRun(cwd: string, runId: string): Promise<boolean> {
91
- const run = (await listSubagentHistory(cwd)).find((candidate) => candidate.id === runId);
92
+ const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), runId);
93
+ if (!runDirectory) return false;
94
+ const canonicalRunId = (await readRunRecord(join(runDirectory, "run.json"))).id;
95
+ const run = (await listSubagentHistory(cwd)).find((candidate) => candidate.id === canonicalRunId);
92
96
  if (!run) return false;
93
- if (isActiveStatus(run.status)) throw new Error(`Subagent run '${runId}' is still active and cannot be cleaned.`);
94
- if (run.cleanupPendingTabIds?.length) throw new Error(`Subagent run '${runId}' is still waiting to clean up Herdr tabs ${run.cleanupPendingTabIds.join(", ")}.`);
97
+ if (isActiveStatus(run.status)) throw new Error(`Subagent run '${canonicalRunId}' is still active and cannot be cleaned.`);
98
+ if (run.cleanupPendingTabIds?.length) throw new Error(`Subagent run '${canonicalRunId}' is still waiting to clean up Herdr tabs ${run.cleanupPendingTabIds.join(", ")}.`);
95
99
  await rm(run.recordDirectory, { recursive: true, force: false });
96
100
  return true;
97
101
  }
@@ -102,13 +106,14 @@ export interface ResumeHistoricalTaskResult { runId: string; label: string; stat
102
106
  export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<ResumeHistoricalTaskResult> {
103
107
  const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), runId);
104
108
  if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
109
+ const canonicalRunId = (await readRunRecord(join(runDirectory, "run.json"))).id;
105
110
  const lockDirectory = join(runDirectory, ".resume.lock");
106
111
  try { await mkdir(lockDirectory); }
107
112
  catch (error) {
108
113
  if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Subagent run '${runId}' already has a historical resume in progress (lock: ${lockDirectory}).`);
109
114
  throw error;
110
115
  }
111
- try { return await resumeHistoricalTaskLocked(cwd, runId, taskNumber, prompt, options); }
116
+ try { return await resumeHistoricalTaskLocked(cwd, canonicalRunId, taskNumber, prompt, options); }
112
117
  finally { await rmdir(lockDirectory); }
113
118
  }
114
119
 
@@ -163,7 +168,11 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
163
168
  options.onUpdate?.({ runId, label: run.label, status: "running", summary: `${historical.name} is continuing.`, documents: [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity: runningActivity });
164
169
  const prompted = await promptLiveAgent({ herdr, task: { ...task, id: historical.id, sessionFile: historical.sessionFile }, paneId: tab.paneId, prompt, systemPromptFile: followupPrompt, agentName, signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${historical.name} appears stalled; it is still running.`) });
165
170
  task.currentAttemptId = prompted.attemptId;
166
- report = prompted.report;
171
+ report = normalizeNeedsInputReport(prompted.report, Number.POSITIVE_INFINITY);
172
+ if (report !== prompted.report) await Promise.all([
173
+ writeJsonAtomic(join(historical.recordDirectory, "report.json"), report),
174
+ writeJsonAtomic(attemptReportPath(historical.recordDirectory, prompted.attemptId), report),
175
+ ]);
167
176
  status = applyReport(task, report);
168
177
  } catch (error) {
169
178
  const settled = await settleAttemptFailure(task, join(historical.recordDirectory, "report.json"), {
@@ -4,7 +4,7 @@ import type { HerdrAutomation } from "./herdr.ts";
4
4
  import { isProcessAlive, type OwnerIdentity } from "./ownership.ts";
5
5
  import { writeJsonAtomic } from "./state.ts";
6
6
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
7
- import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, type RunRecord, type TaskStatus } from "./records.ts";
7
+ import { applyReport, applyTaskStatus, isActiveStatus, normalizeNeedsInputReport, persistFailureReport, readReport, readRunRecord, readTaskRecord, type RunRecord, type TaskStatus } from "./records.ts";
8
8
  import { cleanupRunTab } from "./tab-cleanup.ts";
9
9
  import { attemptReportPath } from "./attempts.ts";
10
10
 
@@ -110,7 +110,6 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
110
110
  continue;
111
111
  }
112
112
  if (isActiveStatus(task.status)) {
113
- let recoveredCurrentReport = false;
114
113
  const canonicalReportFile = join(tasksDirectory, taskEntry, "report.json");
115
114
  let currentAttemptReport: string | undefined;
116
115
  if (task.currentAttemptId) {
@@ -122,11 +121,12 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
122
121
  const reportFile = task.currentAttemptId ? currentAttemptReport : canonicalReportFile;
123
122
  if (reportFile && existsSync(reportFile) && (task.status === "running" || task.status === "starting")) {
124
123
  try {
125
- const report = await readReport(reportFile);
124
+ const submittedReport = await readReport(reportFile);
125
+ const report = normalizeNeedsInputReport(submittedReport, Number(run.effectiveConcurrency) || 1);
126
126
  if (task.currentAttemptId && report.attemptId !== task.currentAttemptId) throw new Error(`Report attempt '${report.attemptId ?? "unknown"}' does not match current attempt '${task.currentAttemptId}'.`);
127
127
  applyReport(task, report, report.reportedAt ?? new Date().toISOString());
128
- recoveredCurrentReport = reportFile !== canonicalReportFile;
129
- if (reportFile !== canonicalReportFile) await writeJsonAtomic(canonicalReportFile, report);
128
+ if (report !== submittedReport) await writeJsonAtomic(reportFile, report);
129
+ if (reportFile !== canonicalReportFile || report !== submittedReport) await writeJsonAtomic(canonicalReportFile, report);
130
130
  result.settledTasks += 1;
131
131
  } catch (error) {
132
132
  const detail = error instanceof Error ? error.message : String(error);
@@ -137,7 +137,19 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
137
137
  result.interruptedTasks += 1;
138
138
  }
139
139
  }
140
- if (differentOwner && isActiveStatus(task.status) && !(recoveredCurrentReport && task.status === "blocked")) {
140
+ if (differentOwner && isActiveStatus(task.status)) {
141
+ if (task.status === "blocked") {
142
+ const cancelledReport = {
143
+ status: "failed",
144
+ summary: "Blocked task was cancelled because its parent Pi session is no longer active.",
145
+ documents: [],
146
+ error: "Owning parent Pi session is no longer active.",
147
+ ...(task.currentAttemptId ? { attemptId: task.currentAttemptId } : {}),
148
+ reportedAt: new Date().toISOString(),
149
+ } as const;
150
+ await persistFailureReport(canonicalReportFile, cancelledReport, true);
151
+ if (currentAttemptReport) await writeJsonAtomic(currentAttemptReport, cancelledReport);
152
+ }
141
153
  applyTaskStatus(task, "cancelled", { error: "Owning parent Pi session is no longer active." });
142
154
  result.cancelledTasks += 1;
143
155
  result.interruptedTasks += 1;
@@ -22,6 +22,8 @@ export interface PersistedReport {
22
22
  attemptId?: string;
23
23
  error?: string;
24
24
  question?: string;
25
+ failureKind?: "missing_input";
26
+ requiredInput?: string;
25
27
  reportedAt?: string;
26
28
  }
27
29
 
@@ -45,6 +47,8 @@ export interface RunRecord {
45
47
  staleTabIds?: string[];
46
48
  question?: string;
47
49
  error?: string;
50
+ failureKind?: "missing_input";
51
+ requiredInput?: string;
48
52
  cleanupError?: string;
49
53
  cleanupPendingTabIds?: string[];
50
54
  }
@@ -178,6 +182,10 @@ export function parseReport(value: unknown, source = "structured report"): Persi
178
182
  if (report.status === "needs-input" && (typeof report.question !== "string" || !report.question.trim())) throw new Error(`Missing report question in ${source}.`);
179
183
  if (report.error !== undefined && typeof report.error !== "string") throw new Error(`Invalid report error in ${source}.`);
180
184
  if (report.question !== undefined && typeof report.question !== "string") throw new Error(`Invalid report question in ${source}.`);
185
+ if (report.failureKind !== undefined && report.failureKind !== "missing_input") throw new Error(`Invalid report failureKind in ${source}.`);
186
+ if (report.requiredInput !== undefined && (typeof report.requiredInput !== "string" || !report.requiredInput.trim())) throw new Error(`Invalid report requiredInput in ${source}.`);
187
+ if (report.failureKind === "missing_input" && report.status !== "failed") throw new Error(`missing_input must be a failed report in ${source}.`);
188
+ if (report.failureKind === "missing_input" && (typeof report.requiredInput !== "string" || !report.requiredInput.trim())) throw new Error(`Missing report requiredInput in ${source}.`);
181
189
  if (report.attemptId !== undefined && (typeof report.attemptId !== "string" || !report.attemptId.trim())) throw new Error(`Invalid report attemptId in ${source}.`);
182
190
  return report as unknown as PersistedReport;
183
191
  }
@@ -186,6 +194,18 @@ export async function readReport(path: string): Promise<PersistedReport> {
186
194
  return parseReport(await readObject(path), path);
187
195
  }
188
196
 
197
+ export function normalizeNeedsInputReport(report: PersistedReport, effectiveConcurrency: number): PersistedReport {
198
+ if (report.status !== "needs-input" || effectiveConcurrency <= 1) return report;
199
+ return parseReport({
200
+ ...report,
201
+ status: "failed",
202
+ error: `Missing required input: ${report.question}`,
203
+ failureKind: "missing_input",
204
+ requiredInput: report.question,
205
+ question: undefined,
206
+ }, "normalized concurrent input request");
207
+ }
208
+
189
209
  export async function persistFailureReport(path: string, report: PersistedReport, replaceExisting = false): Promise<string | undefined> {
190
210
  if (replaceExisting) {
191
211
  await writeJsonAtomic(path, parseReport(report, "generated failure report"));
@@ -264,5 +284,11 @@ export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
264
284
  export function applyReport(record: TaskRecord | TurnRecord, report: PersistedReport, completedAt = new Date().toISOString()): "blocked" | "completed" | "failed" {
265
285
  const status = report.status === "needs-input" ? "blocked" : report.status;
266
286
  applyTaskStatus(record, status, { at: completedAt, question: report.question, error: report.error });
287
+ if ("order" in record) {
288
+ if (report.failureKind) record.failureKind = report.failureKind;
289
+ else delete record.failureKind;
290
+ if (report.requiredInput) record.requiredInput = report.requiredInput;
291
+ else delete record.requiredInput;
292
+ }
267
293
  return status;
268
294
  }
@@ -1,19 +1,34 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { readRunRecord } from "./records.ts";
4
4
 
5
5
  export async function findRunDirectory(runsDirectory: string, runId: string): Promise<string | undefined> {
6
6
  if (!existsSync(runsDirectory)) return undefined;
7
+ const reference = basename(runId.trim());
7
8
  const errors: string[] = [];
9
+ const matches: Array<{ id: string; directory: string }> = [];
8
10
  for (const entry of readdirSync(runsDirectory)) {
9
11
  const directory = join(runsDirectory, entry);
10
12
  const runFile = join(directory, "run.json");
11
13
  try {
12
- if ((await readRunRecord(runFile)).id === runId) return directory;
14
+ const id = (await readRunRecord(runFile)).id;
15
+ if (id === reference || entry === reference) return directory;
16
+ if (id.startsWith(reference) || entry.endsWith(`-${reference}`)) matches.push({ id, directory });
13
17
  } catch (error) {
14
18
  errors.push(`${runFile}: ${error instanceof Error ? error.message : String(error)}`);
15
19
  }
16
20
  }
21
+ if (matches.length === 1) return matches[0].directory;
22
+ if (matches.length > 1) throw new Error(`Ambiguous Subagent run reference '${runId}': ${matches.map((match) => match.id).join(", ")}.`);
17
23
  if (errors.length) throw new Error(`Could not inspect Subagent runs while looking for '${runId}': ${errors.join("; ")}`);
18
24
  return undefined;
19
25
  }
26
+
27
+ export function resolveRunIdReference(reference: string, candidates: readonly string[]): string | undefined {
28
+ const trimmed = basename(reference.trim());
29
+ const exact = candidates.find((candidate) => candidate === trimmed);
30
+ if (exact) return exact;
31
+ const matches = [...new Set(candidates.filter((candidate) => candidate.startsWith(trimmed)))];
32
+ if (matches.length > 1) throw new Error(`Ambiguous Subagent run reference '${reference}': ${matches.join(", ")}.`);
33
+ return matches[0];
34
+ }
@@ -2,7 +2,7 @@ import { existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadQueuedRuns } from "./queued-runs.ts";
4
4
  import { isSettledRunStatus, readReport, readRunRecord, readTaskRecord, type RunStatus, type TaskStatus } from "./records.ts";
5
- import { findRunDirectory } from "./run-locator.ts";
5
+ import { findRunDirectory, resolveRunIdReference } from "./run-locator.ts";
6
6
 
7
7
  export interface RunStatusTask {
8
8
  order: number;
@@ -12,6 +12,8 @@ export interface RunStatusTask {
12
12
  summary?: string;
13
13
  question?: string;
14
14
  error?: string;
15
+ failureKind?: "missing_input";
16
+ requiredInput?: string;
15
17
  }
16
18
 
17
19
  export interface RunStatusSnapshot {
@@ -44,6 +46,8 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
44
46
  const report = reportIsCurrent && existsSync(reportFile) ? await readReport(reportFile) : undefined;
45
47
  const question = report?.question ?? task.question;
46
48
  const error = report?.error ?? task.error;
49
+ const failureKind = report?.failureKind ?? task.failureKind;
50
+ const requiredInput = report?.requiredInput ?? task.requiredInput;
47
51
  tasks.push({
48
52
  order: task.order,
49
53
  name: task.name ?? task.agent ?? `Task ${task.order}`,
@@ -52,6 +56,8 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
52
56
  ...(report?.summary ? { summary: report.summary } : {}),
53
57
  ...(question ? { question } : {}),
54
58
  ...(error ? { error } : {}),
59
+ ...(failureKind ? { failureKind } : {}),
60
+ ...(requiredInput ? { requiredInput } : {}),
55
61
  });
56
62
  }
57
63
  return {
@@ -70,9 +76,14 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
70
76
  }
71
77
 
72
78
  export async function getSubagentRunStatus(cwd: string, runId: string): Promise<RunStatusSnapshot | undefined> {
79
+ const queuedRuns = await loadQueuedRuns(cwd);
73
80
  const durable = await loadDurableStatus(cwd, runId);
74
- if (durable) return durable;
75
- const queued = (await loadQueuedRuns(cwd)).find((candidate) => candidate.runId === runId);
81
+ if (durable) {
82
+ resolveRunIdReference(runId, [durable.runId, ...queuedRuns.map((candidate) => candidate.runId)]);
83
+ return durable;
84
+ }
85
+ const queuedId = resolveRunIdReference(runId, queuedRuns.map((candidate) => candidate.runId));
86
+ const queued = queuedRuns.find((candidate) => candidate.runId === queuedId);
76
87
  if (!queued) return undefined;
77
88
  return {
78
89
  runId: queued.runId,
@@ -131,17 +131,20 @@ const actionHandlers = {
131
131
  },
132
132
  async cleanup({ params, ctx }: ToolExecutionContext) {
133
133
  try {
134
- const cleaned = await cleanSubagentRun(ctx.cwd, params.runId as string);
135
- const text = cleaned ? `Removed Subagent run ${params.runId}.` : `Unknown Subagent run '${params.runId}'.`;
136
- return { content: [{ type: "text", text }], details: { action: "cleanup", runId: params.runId, cleaned }, ...(cleaned ? {} : { isError: true }) };
134
+ const requestedRunId = params.runId as string;
135
+ const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
136
+ const cleaned = await cleanSubagentRun(ctx.cwd, runId);
137
+ const text = cleaned ? `Removed Subagent run ${runId}.` : `Unknown Subagent run '${requestedRunId}'.`;
138
+ return { content: [{ type: "text", text }], details: { action: "cleanup", runId, cleaned }, ...(cleaned ? {} : { isError: true }) };
137
139
  } catch (error) {
138
140
  const text = error instanceof Error ? error.message : String(error);
139
141
  return errorResponse(text, { action: "cleanup", runId: params.runId, cleaned: false });
140
142
  }
141
143
  },
142
144
  async cancel({ params, ctx, dependencies }: ToolExecutionContext) {
143
- const runId = (params.runId as string).trim();
145
+ const requestedRunId = (params.runId as string).trim();
144
146
  try {
147
+ const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
145
148
  let result: { cancelled: boolean; queued?: boolean; cleanupErrors?: string[] };
146
149
  if (dependencies.cancelRun) result = await dependencies.cancelRun(runId);
147
150
  else {
@@ -159,7 +162,7 @@ const actionHandlers = {
159
162
  const text = result.cancelled ? `Cancelled Subagent run ${runId}.${suffix}` : `Subagent run '${runId}' is not active or queued.`;
160
163
  return { content: [{ type: "text", text }], details: { action: "cancel", runId, ...result }, ...(result.cancelled ? {} : { isError: true }) };
161
164
  } catch (error) {
162
- return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId, cancelled: false });
165
+ return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId: requestedRunId, cancelled: false });
163
166
  }
164
167
  },
165
168
  async resume({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext) {
@@ -182,8 +185,9 @@ const actionHandlers = {
182
185
  onWarning: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, warning: text } }),
183
186
  onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, cleanupError: text } }),
184
187
  });
185
- const text = [result.summary, ...(result.question ? [`Question: ${result.question}`] : []), ...result.documents.map((document) => `${document.description}: ${document.path}`)].join("\n");
186
- return { content: [{ type: "text", text }], details: result, ...(result.status === "failed" || result.status === "partial" || result.status === "cancelled" ? { isError: true } : {}) };
188
+ const blockedControl = result.status === "blocked" ? { allowedActions: ["respond", "cancel"] as const } : {};
189
+ const text = [result.summary, ...(result.question ? [`Question: ${result.question}`] : []), ...(result.status === "blocked" ? [`Run ID: ${result.runId}`, `Answer with herdr_subagents_control({ action: "respond", runId: "${result.runId}", answer: "..." }) or cancel this run.`] : []), ...result.documents.map((document) => `${document.description}: ${document.path}`)].join("\n");
190
+ return { content: [{ type: "text", text }], details: { ...result, ...blockedControl }, ...(result.status === "failed" || result.status === "partial" || result.status === "cancelled" ? { isError: true } : {}) };
187
191
  } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); }
188
192
  },
189
193
  async list({ discovery }: ToolExecutionContext) {
@@ -215,9 +219,10 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
215
219
  onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: { ...partial, prompts: tasks.map((task) => task.prompt) } }),
216
220
  });
217
221
  const lines = [result.summary, ...result.tasks.map((task) => `- ${task.name}: ${task.status} — ${task.summary}`)];
222
+ if (result.status === "blocked") lines.push("", `Run ID: ${result.runId}`, `Answer with herdr_subagents_control({ action: "respond", runId: "${result.runId}", answer: "..." }) or cancel this run before starting another foreground run.`);
218
223
  if (result.documents.length) lines.push("", "Documents:", ...result.documents.map((document) => `- ${document.description}: ${document.path}`));
219
224
  const isError = result.status === "failed" || result.status === "partial" || result.status === "cancelled";
220
- return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt) }, ...(isError ? { isError: true } : {}) };
225
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt), ...(result.status === "blocked" ? { allowedActions: ["respond", "cancel"] } : {}) }, ...(isError ? { isError: true } : {}) };
221
226
  } catch (error) {
222
227
  const text = error instanceof Error ? error.message : String(error);
223
228
  const diagnostic = error as { recordDirectory?: string; runId?: string };
@@ -240,6 +240,81 @@ async function verifyForegroundFailureHandoff() {
240
240
  await successPromise;
241
241
 
242
242
  const backgroundGate = deferred<void>();
243
+ const queuedCancellationRoot = join(cwd, "queued-cancellation");
244
+ mkdirSync(queuedCancellationRoot, { recursive: true });
245
+ const activeGate = deferred<void>();
246
+ let queuedCancellationCalls = 0;
247
+ const queuedCancellation = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
248
+ queuedCancellationCalls += 1;
249
+ await activeGate.promise;
250
+ return { content: [{ type: "text", text: "done" }], details: { runId: dependencies.runId, label: "active", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
251
+ });
252
+ await startHarness(queuedCancellation, queuedCancellationRoot);
253
+ const activeBackground = await queuedCancellation.tool?.execute("active", { background: true, tasks: [{ name: "active", prompt: "wait" }] }, undefined, undefined, { cwd: queuedCancellationRoot, ui: queuedCancellation.ui });
254
+ await waitUntil(() => queuedCancellationCalls === 1, "active background run did not start");
255
+ const queuedCaller = new AbortController();
256
+ const queuedForeground = queuedCancellation.tool?.execute("queued", { tasks: [{ name: "queued", prompt: "later" }] }, queuedCaller.signal, undefined, { cwd: queuedCancellationRoot, ui: queuedCancellation.ui });
257
+ await new Promise<void>((resolve) => setImmediate(resolve));
258
+ queuedCaller.abort(new Error("foreground tool call cancelled"));
259
+ const queuedCancellationError = await Promise.race([
260
+ queuedForeground.then(() => "resolved", (error: Error) => error.message),
261
+ new Promise<string>((_, reject) => setTimeout(() => reject(new Error("queued foreground call ignored cancellation")), 100)),
262
+ ]);
263
+ if (queuedCancellationError !== "foreground tool call cancelled" || queuedCancellationCalls !== 1) throw new Error("extension did not remove a cancelled foreground run from the dispatcher queue");
264
+ const activeNotification = queuedCancellation.waitForMessage((message) => message.details?.runId === activeBackground.details.runId);
265
+ activeGate.resolve();
266
+ await activeNotification;
267
+
268
+ const blockedOwnerRoot = join(cwd, "blocked-owner");
269
+ mkdirSync(blockedOwnerRoot, { recursive: true });
270
+ let blockedOwnerCalls = 0;
271
+ let blockedOwnerRunId = "";
272
+ let blockedSuccessorStarted = false;
273
+ const becomeRetained = deferred<void>();
274
+ const blockedOwner = harness(async (params, _signal, _onUpdate, _ctx, dependencies) => {
275
+ if (params.action === "respond") {
276
+ return { content: [{ type: "text", text: "answered" }], details: { runId: blockedOwnerRunId, label: "blocked owner", status: "completed", summary: "answered", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
277
+ }
278
+ blockedOwnerCalls += 1;
279
+ if (blockedOwnerCalls > 1) {
280
+ blockedSuccessorStarted = true;
281
+ return { content: [{ type: "text", text: "successor done" }], details: { runId: dependencies.runId, label: "successor", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
282
+ }
283
+ blockedOwnerRunId = dependencies.runId;
284
+ if (blockedOwnerCalls === 1) await becomeRetained.promise;
285
+ return {
286
+ content: [{ type: "text", text: "waiting for input" }],
287
+ details: {
288
+ runId: dependencies.runId, label: "blocked owner", status: "blocked", summary: "waiting for input", question: "Continue?", documents: [],
289
+ tasks: [blockedTask], activity: [{ index: 1, name: blockedTask.name, status: "blocked" }], effectiveConcurrency: 1, requestedConcurrency: 1,
290
+ },
291
+ };
292
+ });
293
+ await startHarness(blockedOwner, blockedOwnerRoot);
294
+ const ownerPromise = blockedOwner.tool?.execute("blocked-owner", { tasks: [{ name: blockedTask.name, prompt: "block" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
295
+ await waitUntil(() => blockedOwnerCalls === 1, "blocked owner did not start");
296
+ const queuedBeforeBlocked = blockedOwner.tool?.execute("queued-before-blocked", { tasks: [{ name: "queued", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
297
+ await new Promise<void>((resolve) => setImmediate(resolve));
298
+ becomeRetained.resolve();
299
+ const ownerResult = await ownerPromise;
300
+ const retainedForeground = await Promise.race([
301
+ queuedBeforeBlocked,
302
+ new Promise((_, reject) => setTimeout(() => reject(new Error("foreground run queued before blocked transition remained stuck")), 100)),
303
+ ]);
304
+ if (!retainedForeground.isError || retainedForeground.details?.blockingRunId !== ownerResult.details.runId || blockedOwnerCalls !== 1) throw new Error("blocked transition did not reject an already-queued foreground run");
305
+ const blockedForeground = await Promise.race([
306
+ blockedOwner.tool?.execute("blocked-successor", { tasks: [{ name: "successor", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui }),
307
+ new Promise((_, reject) => setTimeout(() => reject(new Error("foreground run waited behind a blocked dispatcher owner")), 100)),
308
+ ]);
309
+ if (!blockedForeground.isError || blockedForeground.details?.blockingRunId !== ownerResult.details.runId || !blockedForeground.content?.[0]?.text.includes(ownerResult.details.runId)) {
310
+ throw new Error("foreground run blocked by dispatcher omitted the blocking run ID");
311
+ }
312
+ const queuedBehindBlocked = await blockedOwner.tool?.execute("blocked-background", { background: true, tasks: [{ name: "background", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
313
+ if (queuedBehindBlocked.details?.status !== "queued" || blockedOwnerCalls !== 1) throw new Error("background run did not remain queued behind the blocked dispatcher owner");
314
+ await blockedOwner.controlTool?.execute("respond-prefix", { action: "respond", runId: ownerResult.details.runId.slice(0, 8), answer: "continue" }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
315
+ await waitUntil(() => blockedSuccessorStarted, "responding through a run ID prefix did not release the blocked dispatcher owner");
316
+ await blockedOwner.handlers.get("session_shutdown")?.({}, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
317
+
243
318
  const explicitBackground = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
244
319
  await backgroundGate.promise;
245
320
  return { content: [{ type: "text", text: "background done" }], details: { runId: dependencies.runId, label: "background", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
@@ -1,5 +1,5 @@
1
1
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { getEventListeners } from "node:events";
5
5
  import { visibleWidth } from "@earendil-works/pi-tui";
@@ -81,7 +81,7 @@ function fakeHerdr(mode = "completed") {
81
81
  calls.push(["promptAgent", request]);
82
82
  const current = agents.get(request.target) ?? { directory: taskDirectory };
83
83
  const directory = current.directory;
84
- taskRuns += 1;
84
+ const runNumber = ++taskRuns;
85
85
  active += 1;
86
86
  maxActive = Math.max(maxActive, active);
87
87
  await new Promise((resolve) => setTimeout(resolve, request.prompt.includes("slow") ? 15 : 2));
@@ -115,7 +115,7 @@ function fakeHerdr(mode = "completed") {
115
115
  active -= 1;
116
116
  throw new Error("connection lost after authoritative report");
117
117
  }
118
- if (mode === "post-fail" || (mode === "blocked-then-post-fail" && taskRuns > 1)) { active -= 1; throw new Error("connection lost after prompt submission"); }
118
+ if (mode === "post-fail" || (mode === "blocked-then-post-fail" && runNumber > 1)) { active -= 1; throw new Error("connection lost after prompt submission"); }
119
119
  writeFileSync(join(directory, "session.jsonl"), `${JSON.stringify({ type: "session", version: 3, id: request.target, cwd: root })}\n`);
120
120
  if (mode === "completed" || mode === "close-failed") {
121
121
  const failed = request.prompt.includes("FAIL");
@@ -137,9 +137,9 @@ function fakeHerdr(mode = "completed") {
137
137
  } else if (mode === "malformed") {
138
138
  writeAttemptReport(directory, "{truncated");
139
139
  } else if (mode === "blocked" || mode === "blocked-twice" || mode === "blocked-then-post-fail") {
140
- const needsInput = ((mode === "blocked" || mode === "blocked-then-post-fail") && taskRuns === 1) || (mode === "blocked-twice" && (taskRuns === 1 || taskRuns === 3));
140
+ const needsInput = ((mode === "blocked" || mode === "blocked-then-post-fail") && runNumber === 1) || (mode === "blocked-twice" && (runNumber === 1 || runNumber === 3));
141
141
  const failed = request.prompt.includes("FAIL");
142
- const question = taskRuns === 3 ? "Which environment should I inspect?" : "Which branch should I inspect?";
142
+ const question = runNumber === 3 ? "Which environment should I inspect?" : "Which branch should I inspect?";
143
143
  writeFileSync(join(directory, "result.md"), `FULL ${request.prompt}`);
144
144
  writeAttemptReport(directory, { status: needsInput ? "needs-input" : failed ? "failed" : "completed", summary: needsInput ? "Need more input." : failed ? `${request.prompt} failed.` : `${request.prompt} completed.`, ...(needsInput ? { question } : {}), ...(failed ? { error: "expected failure" } : {}), documents: [] });
145
145
  }
@@ -533,6 +533,15 @@ try {
533
533
  check("read-only batch uses bounded concurrency", batchDetails.effectiveConcurrency === 2 && batchHerdr.maxActive === 2 && batchHerdr.calls.filter(([name]) => name === "splitPane").length === 3);
534
534
  check("batch is FIFO with a dedicated pane per task", batchPrompts.join(",") === "one slow,two,three FAIL,four" && new Set(batchStarts.map((request) => request.paneId)).size === 4);
535
535
  check("failed sibling does not cancel batch", batch.isError && batchDetails.status === "partial" && batchDetails.tasks.map((task) => task.name).join(",") === "one,two,three,four" && batchDetails.tasks[2].status === "failed" && batchDetails.tasks[3].status === "completed");
536
+ const parallelQuestionHerdr = fakeHerdr("blocked");
537
+ const parallelQuestion = await executeHerdrSubagents({ concurrency: 2, tasks: [
538
+ { name: "missing-input", prompt: "question", agent: "explorer" },
539
+ { name: "independent", prompt: "continue", agent: "reviewer" },
540
+ ] }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: parallelQuestionHerdr });
541
+ const parallelQuestionTask = JSON.parse(readFileSync(join(parallelQuestion.details.tasks[0].recordDirectory, "task.json"), "utf8"));
542
+ const parallelQuestionReport = JSON.parse(readFileSync(join(parallelQuestion.details.tasks[0].recordDirectory, "report.json"), "utf8"));
543
+ const parallelQuestionAttemptReport = JSON.parse(readFileSync(join(parallelQuestion.details.tasks[0].recordDirectory, "reports", `${parallelQuestionTask.currentAttemptId}.json`), "utf8"));
544
+ check("parallel needs-input becomes a structured failure while independent siblings finish", parallelQuestion.isError && parallelQuestion.details.status === "partial" && parallelQuestion.details.tasks[0].status === "failed" && parallelQuestion.details.tasks[0].failureKind === "missing_input" && parallelQuestion.details.tasks[0].requiredInput === "Which branch should I inspect?" && parallelQuestion.details.tasks[1].status === "completed" && parallelQuestionTask.status === "failed" && parallelQuestionReport.status === "failed" && parallelQuestionReport.failureKind === "missing_input" && parallelQuestionAttemptReport.status === "failed" && parallelQuestionAttemptReport.failureKind === "missing_input" && parallelQuestionHerdr.calls.some(([name]) => name === "closeTab"));
536
545
  const mixedCancelHerdr = fakeHerdr();
537
546
  const mixedCancelPrompt = mixedCancelHerdr.promptAgent.bind(mixedCancelHerdr);
538
547
  mixedCancelHerdr.promptAgent = async (request) => request.prompt.includes("WAIT") ? await new Promise((_resolve, reject) => request.signal?.addEventListener("abort", () => reject(request.signal.reason), { once: true })) : mixedCancelPrompt(request);
@@ -677,6 +686,20 @@ try {
677
686
  check("dispatcher cancels only the selected queued run", cancellingDispatcher.cancelQueuedRun("remove-me", new Error("cancelled by user")) && await removedQueued === "cancelled by user" && cancellingDispatcher.snapshot().queuedRunIds.join(",") === "keep-me");
678
687
  cancellingDispatcher.resume();
679
688
  check("dispatcher still executes uncancelled queued siblings", await keptQueued === "kept");
689
+ const abortingDispatcher = new RunDispatcher();
690
+ abortingDispatcher.pause();
691
+ const queuedAbort = new AbortController();
692
+ let abortedQueuedStarted = false;
693
+ const abortedQueued = abortingDispatcher.submit("abort-me", async () => { abortedQueuedStarted = true; return "unexpected"; }, { signal: queuedAbort.signal }).catch((error) => error.message);
694
+ queuedAbort.abort(new Error("foreground tool call cancelled"));
695
+ check("queued foreground dispatch responds to caller cancellation", await abortedQueued === "foreground tool call cancelled" && !abortedQueuedStarted && abortingDispatcher.snapshot().queuedRunIds.length === 0);
696
+ const retainingDispatcher = new RunDispatcher();
697
+ let settleRetainingOwner;
698
+ let retainedWaiterStarted = false;
699
+ const retainingOwner = retainingDispatcher.submit("retaining-owner", async () => { await new Promise((resolve) => { settleRetainingOwner = resolve; }); retainingDispatcher.retain("retaining-owner"); return "blocked"; });
700
+ const retainedWaiter = retainingDispatcher.submit("foreground-waiter", async () => { retainedWaiterStarted = true; return "unexpected"; }, { onRetained: (runId) => `blocked by ${runId}` });
701
+ settleRetainingOwner();
702
+ check("retaining an active run rejects already-queued foreground waiters", await retainingOwner === "blocked" && await retainedWaiter === "blocked by retaining-owner" && !retainedWaiterStarted && retainingDispatcher.snapshot().queuedRunIds.length === 0);
680
703
  const retainedDispatcher = new RunDispatcher();
681
704
  const retainedStarts = [];
682
705
  const retainedFirst = retainedDispatcher.submit("blocked-run", async () => { retainedStarts.push("blocked-run"); retainedDispatcher.retain("blocked-run"); return "blocked"; });
@@ -735,8 +758,8 @@ try {
735
758
 
736
759
  const blockedHerdr = fakeHerdr("blocked");
737
760
  const blocked = await executeHerdrSubagents({ concurrency: 1, tasks: [{ name: "question", prompt: "inspect branch", agent: "explorer" }, { name: "after-answer", prompt: "continue queued", agent: "reviewer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
738
- check("needs-input returns foreground control without a tool error and preserves tab", !blocked.isError && blocked.details.status === "blocked" && blocked.details.tasks[0].question === "Which branch should I inspect?" && !blockedHerdr.calls.some(([name]) => name === "closeTab"));
739
- const blockedStatus = await executeHerdrSubagents({ action: "status", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
761
+ check("needs-input returns foreground control with its full run ID and allowed actions", !blocked.isError && blocked.details.status === "blocked" && blocked.details.question === "Which branch should I inspect?" && blocked.details.tasks[0].question === blocked.details.question && blocked.details.allowedActions?.join(",") === "respond,cancel" && blocked.content[0].text.includes(blocked.details.runId) && !blockedHerdr.calls.some(([name]) => name === "closeTab"));
762
+ const blockedStatus = await executeHerdrSubagents({ action: "status", runId: basename(blocked.details.recordDirectory) }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
740
763
  check("status exposes blocked task questions for respond", blockedStatus.details.status === "blocked" && blockedStatus.details.settled === false && blockedStatus.details.tasks[0].question === "Which branch should I inspect?" && blockedStatus.content[0].text.includes("Which branch should I inspect?"));
741
764
  const activeCleanup = await executeHerdrSubagents({ action: "cleanup", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
742
765
  check("cleanup refuses to delete an active blocked run", activeCleanup.isError && activeCleanup.content[0].text.includes("still active") && existsSync(blocked.details.recordDirectory));
@@ -751,7 +774,7 @@ try {
751
774
  check("blocked response diagnoses a lock owned by a dead process without unsafe reclamation", diagnosedStaleResponse.isError && diagnosedStaleResponse.content[0].text.includes("stale blocked-response lock"));
752
775
  rmSync(join(staleLockBlocked.details.recordDirectory, ".respond.lock"));
753
776
  const resumeUpdates = [];
754
- const resumed = await executeHerdrSubagents({ action: "respond", runId: blocked.details.runId, answer: "Inspect main." }, undefined, (update) => resumeUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
777
+ const resumed = await executeHerdrSubagents({ action: "respond", runId: blocked.details.runId.slice(0, 8), answer: "Inspect main." }, undefined, (update) => resumeUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
755
778
  const blockedStarts = blockedHerdr.calls.filter(([name]) => name === "startAgent").map(([, request]) => request);
756
779
  const blockedPrompts = blockedHerdr.calls.filter(([name]) => name === "promptAgent").map(([, request]) => request);
757
780
  check("answer resumes original live Agent and queued sibling gets a new pane", !resumed.isError && resumed.details.status === "completed" && blockedPrompts.length === 3 && blockedPrompts[1].target === blockedPrompts[0].target && blockedStarts.length === 2 && new Set(blockedStarts.map((request) => request.paneId)).size === 2);
@@ -765,7 +788,7 @@ try {
765
788
  const twiceBlockedHerdr = fakeHerdr("blocked-twice");
766
789
  const twiceBlocked = await executeHerdrSubagents({ concurrency: 1, tasks: [{ name: "first-question", prompt: "first", agent: "explorer" }, { name: "second-question", prompt: "second", agent: "reviewer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: twiceBlockedHerdr });
767
790
  const secondQuestion = await executeHerdrSubagents({ action: "respond", runId: twiceBlocked.details.runId, answer: "main" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: twiceBlockedHerdr });
768
- check("queued sibling blocking returns its current question and Fleet pane", secondQuestion.details.status === "blocked" && secondQuestion.details.question === "Which environment should I inspect?" && secondQuestion.content[0].text.includes("environment") && secondQuestion.details.activity[1].status === "blocked" && Boolean(secondQuestion.details.activity[1].paneId));
791
+ check("queued sibling blocking returns its current question, actions, and Fleet pane", secondQuestion.details.status === "blocked" && secondQuestion.details.question === "Which environment should I inspect?" && secondQuestion.details.allowedActions?.join(",") === "respond,cancel" && secondQuestion.content[0].text.includes(secondQuestion.details.runId) && secondQuestion.details.activity[1].status === "blocked" && Boolean(secondQuestion.details.activity[1].paneId));
769
792
  const failedAfterBlockHerdr = fakeHerdr("blocked");
770
793
  const failedAfterBlock = await executeHerdrSubagents({ concurrency: 1, tasks: [{ name: "question-first", prompt: "question", agent: "explorer" }, { name: "fails-later", prompt: "FAIL sibling", agent: "reviewer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: failedAfterBlockHerdr });
771
794
  const failedAnswer = await executeHerdrSubagents({ action: "respond", runId: failedAfterBlock.details.runId, answer: "main" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: failedAfterBlockHerdr });
@@ -865,7 +888,7 @@ try {
865
888
  check("history lists compact project-local runs and tasks", Boolean(stableHistory?.tasks.length) && historyText(history).includes("stable") && !historyText(history).includes("FULL stable"));
866
889
  const historyHerdr = fakeHerdr();
867
890
  const historicalUpdates = [];
868
- const historicalResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "follow up" }, undefined, (update) => historicalUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: historyHerdr });
891
+ const historicalResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId.slice(0, 8), task: 1, prompt: "follow up" }, undefined, (update) => historicalUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: historyHerdr });
869
892
  const historyStartRequest = historyHerdr.calls.find(([name]) => name === "startAgent")[1];
870
893
  check("historical resume preserves the saved Pi session and persona prompt", !historicalResume.isError && historyHerdr.calls[0][0] === "createTab" && historyStartRequest.args.includes(stableHistory.tasks[0].sessionFile) && historyStartRequest.args.includes(stableHistory.tasks[0].model) && historyStartRequest.args.some((value) => String(value).includes("subagent_report")) && existsSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json")) && readFileSync(join(stableHistory.tasks[0].recordDirectory, "followup-system-prompt.md"), "utf8").includes("Investigate the assigned question without modifying files"));
871
894
  check("historical resume streams authoritative Fleet activity", historicalUpdates.some((update) => update.status === "running" && update.activity?.[0]?.agent === "explorer" && update.activity[0].sessionFile === stableHistory.tasks[0].sessionFile));
@@ -917,13 +940,11 @@ try {
917
940
  check("parent shutdown marks a historical follow-up run cancelled despite completed siblings", historicalCancelledRun.status === "cancelled" && historicalCancelledRun.error.includes("Parent Pi session closed"));
918
941
  const followupBlockedHerdr = fakeHerdr("blocked");
919
942
  const blockedFollowup = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "ask a follow-up" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: followupBlockedHerdr });
920
- const duplicateResumeHerdr = fakeHerdr();
921
- const duplicateResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "duplicate live follow-up" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: duplicateResumeHerdr });
922
- check("historical resume rejects an active run without starting a second Agent", duplicateResume.isError && duplicateResume.content[0].text.includes("still active") && duplicateResumeHerdr.calls.length === 0);
923
- const answeredFollowup = await executeHerdrSubagents({ action: "respond", runId: backgroundId, answer: "follow-up answer" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: followupBlockedHerdr });
924
943
  const historicalTurns = join(stableHistory.tasks[0].recordDirectory, "turns");
925
- const answeredTurn = JSON.parse(readFileSync(join(historicalTurns, "03.json"), "utf8"));
926
- check("blocked historical follow-up remains publicly resumable with durable turns", blockedFollowup.details.status === "blocked" && !answeredFollowup.isError && answeredFollowup.details.status === "completed" && existsSync(join(historicalTurns, "01.json")) && answeredTurn.status === "completed" && answeredTurn.events.some((event) => event.type === "answer"));
944
+ const failedInputTurn = JSON.parse(readFileSync(join(historicalTurns, "03.json"), "utf8"));
945
+ const failedInputTask = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "task.json"), "utf8"));
946
+ const failedInputAttempt = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "reports", `${failedInputTask.currentAttemptId}.json`), "utf8"));
947
+ check("historical input requests fail instead of creating unowned blocked work", blockedFollowup.isError && blockedFollowup.details.status === "failed" && failedInputTurn.status === "failed" && failedInputAttempt.failureKind === "missing_input" && followupBlockedHerdr.calls.at(-1)[0] === "closeTab");
927
948
  const prePromptFailureTaskFile = join(stableHistory.tasks[0].recordDirectory, "task.json");
928
949
  const prePromptStaleTask = JSON.parse(readFileSync(prePromptFailureTaskFile, "utf8"));
929
950
  Object.assign(prePromptStaleTask, { question: "stale question", error: "stale error", completedAt: "2000-01-01T00:00:00.000Z" });
@@ -952,7 +973,7 @@ try {
952
973
  const ignoreText = readFileSync(ignoreFile, "utf8");
953
974
  check("runtime archive is ignored without hiding personas or overwriting unrelated rules", ignoreText.includes("herdr-subagents/runs/") && !ignoreText.split(/\r?\n/).includes("herdr-subagents/") && ignoreText.includes("unrelated-state/") && ignoreText.match(/herdr-subagents\/runs\//g)?.length === 1);
954
975
  const remainingRunId = history.find((run) => run.id !== backgroundId)?.id;
955
- const cleaned = await executeHerdrSubagents({ action: "cleanup", runId: backgroundId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: fakeHerdr() });
976
+ const cleaned = await executeHerdrSubagents({ action: "cleanup", runId: basename(stableHistory.recordDirectory) }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: fakeHerdr() });
956
977
  const afterCleanup = await listSubagentHistory(root);
957
978
  check("explicit cleanup removes only the selected run", !cleaned.isError && !afterCleanup.some((run) => run.id === backgroundId) && (!remainingRunId || afterCleanup.some((run) => run.id === remainingRunId)));
958
979
 
@@ -1079,6 +1100,33 @@ try {
1079
1100
  writeFileSync(join(ownerLossTask, "reports", `${ownerLossAttempt}.json`), JSON.stringify({ attemptId: ownerLossAttempt, status: "completed", summary: "Completed before owner loss.", documents: [] }));
1080
1101
  await reconcileSubagentRuns(ownerLossRoot, fakeHerdr(), { sessionId: "new-owner", processId: process.pid });
1081
1102
  check("matching attempt report survives prior owner loss", JSON.parse(readFileSync(join(ownerLossTask, "task.json"), "utf8")).status === "completed" && JSON.parse(readFileSync(join(ownerLossRun, "run.json"), "utf8")).status === "completed" && JSON.parse(readFileSync(join(ownerLossTask, "report.json"), "utf8")).attemptId === ownerLossAttempt);
1103
+ const blockedOwnerLossRoot = join(root, "blocked-owner-loss-recovery");
1104
+ const blockedOwnerLossRun = join(blockedOwnerLossRoot, ".pi", "herdr-subagents", "runs", "run");
1105
+ const blockedOwnerLossTask = join(blockedOwnerLossRun, "tasks", "01-task");
1106
+ const blockedOwnerLossAttempt = "66666666-6666-4666-8666-666666666666";
1107
+ mkdirSync(join(blockedOwnerLossTask, "reports"), { recursive: true });
1108
+ writeFileSync(join(blockedOwnerLossRun, "run.json"), JSON.stringify({ id: "blocked-owner-loss", label: "blocked-owner-loss", status: "running", effectiveConcurrency: 1, ownerSessionId: "dead-owner", ownerProcessId: 99999999, tabId: "w1:t-blocked-owner-loss", startedAt: new Date().toISOString() }));
1109
+ writeFileSync(join(blockedOwnerLossTask, "task.json"), JSON.stringify({ id: "blocked-owner-loss-task", runId: "blocked-owner-loss", order: 1, status: "running", paneId: "w1:p-blocked-owner-loss", currentAttemptId: blockedOwnerLossAttempt, sessionFile: join(blockedOwnerLossTask, "session.jsonl") }));
1110
+ writeFileSync(join(blockedOwnerLossTask, "reports", `${blockedOwnerLossAttempt}.json`), JSON.stringify({ attemptId: blockedOwnerLossAttempt, status: "needs-input", summary: "Need input before owner loss.", question: "Which branch?", documents: [] }));
1111
+ const blockedOwnerLossHerdr = fakeHerdr();
1112
+ await reconcileSubagentRuns(blockedOwnerLossRoot, blockedOwnerLossHerdr, { sessionId: "new-owner", processId: process.pid });
1113
+ const cancelledBlockedOwnerTask = JSON.parse(readFileSync(join(blockedOwnerLossTask, "task.json"), "utf8"));
1114
+ const cancelledBlockedOwnerReport = JSON.parse(readFileSync(join(blockedOwnerLossTask, "report.json"), "utf8"));
1115
+ const cancelledBlockedOwnerAttempt = JSON.parse(readFileSync(join(blockedOwnerLossTask, "reports", `${blockedOwnerLossAttempt}.json`), "utf8"));
1116
+ check("owner-loss reconciliation never leaves blocked work without dispatcher ownership", cancelledBlockedOwnerTask.status === "cancelled" && cancelledBlockedOwnerReport.status === "failed" && !cancelledBlockedOwnerReport.question && cancelledBlockedOwnerAttempt.status === "failed" && !cancelledBlockedOwnerAttempt.question && JSON.parse(readFileSync(join(blockedOwnerLossRun, "run.json"), "utf8")).status === "cancelled" && blockedOwnerLossHerdr.calls.some(([name]) => name === "closeTab"));
1117
+ const parallelInputRecoveryRoot = join(root, "parallel-input-recovery");
1118
+ const parallelInputRecoveryRun = join(parallelInputRecoveryRoot, ".pi", "herdr-subagents", "runs", "run");
1119
+ const parallelInputRecoveryTask = join(parallelInputRecoveryRun, "tasks", "01-task");
1120
+ const parallelInputRecoveryAttempt = "55555555-5555-4555-8555-555555555555";
1121
+ mkdirSync(join(parallelInputRecoveryTask, "reports"), { recursive: true });
1122
+ writeFileSync(join(parallelInputRecoveryRun, "run.json"), JSON.stringify({ id: "parallel-input-recovery", label: "parallel-input-recovery", status: "running", effectiveConcurrency: 2, ownerSessionId: "dead-owner", ownerProcessId: 99999999, tabId: "w1:t-parallel-input", startedAt: new Date().toISOString() }));
1123
+ writeFileSync(join(parallelInputRecoveryTask, "task.json"), JSON.stringify({ id: "parallel-input-task", runId: "parallel-input-recovery", order: 1, status: "running", paneId: "w1:p-parallel-input", currentAttemptId: parallelInputRecoveryAttempt, sessionFile: join(parallelInputRecoveryTask, "session.jsonl") }));
1124
+ writeFileSync(join(parallelInputRecoveryTask, "reports", `${parallelInputRecoveryAttempt}.json`), JSON.stringify({ attemptId: parallelInputRecoveryAttempt, status: "needs-input", summary: "Need a branch.", question: "Which branch?", documents: [] }));
1125
+ await reconcileSubagentRuns(parallelInputRecoveryRoot, fakeHerdr(), { sessionId: "new-owner", processId: process.pid });
1126
+ const recoveredParallelInputTask = JSON.parse(readFileSync(join(parallelInputRecoveryTask, "task.json"), "utf8"));
1127
+ const recoveredParallelInputReport = JSON.parse(readFileSync(join(parallelInputRecoveryTask, "report.json"), "utf8"));
1128
+ const recoveredParallelInputAttempt = JSON.parse(readFileSync(join(parallelInputRecoveryTask, "reports", `${parallelInputRecoveryAttempt}.json`), "utf8"));
1129
+ check("reconciliation keeps concurrent input requests out of blocked state", recoveredParallelInputTask.status === "failed" && recoveredParallelInputTask.failureKind === "missing_input" && recoveredParallelInputReport.status === "failed" && recoveredParallelInputReport.requiredInput === "Which branch?" && recoveredParallelInputAttempt.status === "failed" && recoveredParallelInputAttempt.failureKind === "missing_input" && JSON.parse(readFileSync(join(parallelInputRecoveryRun, "run.json"), "utf8")).status === "failed");
1082
1130
  const malformedRoot = join(root, "malformed-recovery");
1083
1131
  const malformedRun = join(malformedRoot, ".pi", "herdr-subagents", "runs", "run");
1084
1132
  const malformedTask = join(malformedRun, "tasks", "01-task");