@maxiaochao/pi-toolkit 0.10.15 → 0.10.17

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,12 @@
1
+ ## 2026-10-10 - v0.10.17
2
+
3
+ - bundle herdr-subagents v0.4.5: fix transient retry lifecycle, attempt isolation, and recovery semantics
4
+ - bundle Herdr Subagents lifecycle and attempt-isolation fixes so provider retries cannot create false failed tasks or overwrite newer attempts
5
+
6
+ ## 2026-10-10 - v0.10.16
7
+
8
+ - tapd skill: handle large attachment timeouts without misdiagnosing download failures
9
+
1
10
  ## 2026-10-09 - v0.10.15
2
11
 
3
12
  - redesign the TAPD skill around executable workflows, explicit workspace and JSON contracts, and safe attachment handling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.15",
3
+ "version": "0.10.17",
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,11 @@
1
+ ## 2026-10-10 - v0.4.5
2
+
3
+ - fix transient retry lifecycle, attempt isolation, and recovery semantics
4
+ - prevent transient Herdr states from finalizing live child attempts
5
+ - Report authoritative child Pi lifecycle state even under `--no-extensions`, wait through provider retry/fallback until the matching structured report arrives, and bind every report to a unique attempt ID.
6
+ - Reconcile matching attempt reports after interruption, serialize blocked responses, keep blocked results non-error, and preserve business outcomes when tab cleanup fails.
7
+ - Aggregate mixed failed/cancelled task sets as partial instead of hiding one terminal outcome.
8
+
1
9
  ## 2026-10-09 - v0.4.4
2
10
 
3
11
  - return failed foreground batches early while surviving subagents continue in background
@@ -82,6 +82,8 @@ A child missing required information reports `needs-input` with an exact questio
82
82
  herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
83
83
  ```
84
84
 
85
+ 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
+
85
87
  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.
86
88
 
87
89
  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.
@@ -96,13 +98,13 @@ Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({
96
98
 
97
99
  Read-only tasks start FIFO up to the configured concurrency limit. A pane is created only when its task starts, belongs exclusively to that task, and is never reused. Completed panes remain inspectable while another task in the batch is unsettled. Results remain in input order, and one failed task does not cancel its siblings. Any batch containing a write-capable persona is forced to concurrency one.
98
100
 
99
- The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
101
+ The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning without changing the completed/failed business result. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
100
102
 
101
- Foreground calls wait until all children report completion or a request for input when no task fails. On the first task failure, they return early as described above while the surviving children continue. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
103
+ Foreground calls wait until all children report completion or a request for input when no task fails. On the first task failure, they return early as described above while the surviving children continue. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane. The child extension reports Pi's authoritative `agent_start`/`agent_settled` lifecycle to Herdr, so provider retry and fallback screens cannot end a task early through screen detection alone.
102
104
 
103
105
  ## Reports and records
104
106
 
105
- The child must finish through the package's structured `subagent_report` tool. The parent receives only:
107
+ The child must finish through the package's structured `subagent_report` tool. Every prompt gets a unique attempt ID; the child commits to an attempt-specific report, and the parent accepts only the matching report before updating the canonical task result. A late report from an older retry, response, or historical follow-up cannot settle a newer attempt. The parent receives only:
106
108
 
107
109
  - completion or failure status
108
110
  - a concise summary
@@ -115,7 +117,7 @@ The complete result is not copied into the parent model context. Each run is ret
115
117
  .pi/herdr-subagents/runs/<run>/tasks/<task>/
116
118
  ```
117
119
 
118
- The task directory contains the full result, structured report, task metadata, system prompt, and persistent Pi session. Run-level metadata is stored in the parent run directory.
120
+ The task directory contains the full result, canonical structured report, attempt-scoped reports and lifecycle markers, task metadata, system prompt, and persistent Pi session. Run-level metadata is stored in the parent run directory.
119
121
 
120
122
  ## Configuration and personas
121
123
 
@@ -27,7 +27,9 @@
27
27
  - Verify selected active/queued run cancellation, blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, idempotent missing-tab cleanup, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
28
28
  - Verify `status(runId)` isolates one run, projects queue-only state, prefers durable state over a stale queue record, exposes blocked questions, marks terminal states settled, rejects unknown IDs, and leaves full `history` semantics unchanged.
29
29
  - Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
30
+ - Simulate a false screen-settled return while the authoritative Pi lifecycle remains working, then write the report later and verify the task completes without a transient failed state.
31
+ - Verify reports are bound to unique attempt IDs, blocked results are not tool errors, duplicate blocked responses are serialized, mixed failed/cancelled aggregates remain partial, and cleanup failures never overwrite business outcomes.
30
32
 
31
33
  ## Latest baseline
32
34
 
33
- All 179 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, 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 two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
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.
@@ -1,10 +1,12 @@
1
1
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import net from "node:net";
3
+ import { dirname, resolve } from "node:path";
3
4
  import { StringEnum } from "@earendil-works/pi-ai";
4
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
6
  import { Type } from "typebox";
6
- import { resultPathForAttempt } from "../src/result-path.ts";
7
+ import { resultPathForAttemptId } from "../src/result-path.ts";
7
8
  import { PARENT_PANE_ENV } from "../src/live-agent.ts";
9
+ import { attemptReportPath, attemptStatePath, requireAttemptId, type AttemptState } from "../src/attempts.ts";
8
10
 
9
11
  const RETURN_SHORTCUT = "alt+p";
10
12
 
@@ -33,12 +35,52 @@ const ReportParams = Type.Object({
33
35
  });
34
36
 
35
37
  function writeAtomic(path: string, content: string): void {
38
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
36
39
  const temporary = `${path}.tmp-${process.pid}`;
37
40
  writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
38
41
  renameSync(temporary, path);
39
42
  }
40
43
 
44
+ function currentAttemptId(taskDirectory: string): string {
45
+ const task = JSON.parse(readFileSync(resolve(taskDirectory, "task.json"), "utf8")) as { currentAttemptId?: unknown };
46
+ return requireAttemptId(task.currentAttemptId);
47
+ }
48
+
49
+ function writeAttemptState(taskDirectory: string, attemptId: string, state: AttemptState): void {
50
+ writeAtomic(attemptStatePath(taskDirectory, attemptId), `${JSON.stringify({ attemptId, state, at: new Date().toISOString() }, null, 2)}\n`);
51
+ }
52
+
53
+ let herdrReportSeq = Date.now() * 1000;
54
+
55
+ function reportHerdrState(state: "working" | "blocked" | "idle", message?: string): void {
56
+ const socketPath = process.env.HERDR_SOCKET_PATH;
57
+ const paneId = process.env.HERDR_PANE_ID;
58
+ if (process.env.HERDR_ENV !== "1" || !socketPath || !paneId) return;
59
+ const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
60
+ const socket = net.createConnection(endpoint);
61
+ socket.on("error", () => undefined);
62
+ socket.on("connect", () => socket.end(`${JSON.stringify({ id: `herdr:subagent:${Date.now()}:${Math.random().toString(36).slice(2)}`, method: "pane.report_agent", params: { pane_id: paneId, source: "herdr:pi", agent: "pi", state, message, seq: ++herdrReportSeq } })}\n`));
63
+ }
64
+
41
65
  export default function (pi: ExtensionAPI) {
66
+ let activeAttemptId: string | undefined;
67
+ let blockedCount = 0;
68
+ pi.on("before_agent_start", async () => {
69
+ const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
70
+ if (!taskDirectory) return;
71
+ activeAttemptId = currentAttemptId(taskDirectory);
72
+ writeAttemptState(taskDirectory, activeAttemptId, "working");
73
+ });
74
+ pi.on("agent_start", () => reportHerdrState("working"));
75
+ pi.on("agent_settled", (_event, ctx) => {
76
+ const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
77
+ if (taskDirectory && activeAttemptId) writeAttemptState(taskDirectory, activeAttemptId, "settled");
78
+ if (ctx?.isIdle?.() === true) reportHerdrState("idle");
79
+ });
80
+ pi.events.on("herdr:blocked", (data: { active?: boolean; label?: string }) => {
81
+ blockedCount = Math.max(0, blockedCount + (data?.active ? 1 : -1));
82
+ reportHerdrState(blockedCount > 0 ? "blocked" : "working", blockedCount > 0 ? data?.label : undefined);
83
+ });
42
84
  pi.registerCommand("parent", {
43
85
  description: "Return focus to the parent Agent pane",
44
86
  async handler(_args, ctx) { await returnToParent(pi, ctx); },
@@ -58,12 +100,9 @@ export default function (pi: ExtensionAPI) {
58
100
  async execute(_toolCallId, params) {
59
101
  const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
60
102
  if (!taskDirectory) throw new Error("PI_HERDR_SUBAGENTS_TASK_DIR is not set");
103
+ const attemptId = activeAttemptId ?? currentAttemptId(taskDirectory);
61
104
  mkdirSync(taskDirectory, { recursive: true, mode: 0o700 });
62
- let attempt = 1;
63
- const task = JSON.parse(readFileSync(resolve(taskDirectory, "task.json"), "utf8")) as { attempt?: unknown };
64
- if (Number.isInteger(task.attempt) && Number(task.attempt) > 1) attempt = Number(task.attempt);
65
- const resultPath = resultPathForAttempt(taskDirectory, attempt);
66
- if (attempt > 1) mkdirSync(resolve(taskDirectory, "turns"), { recursive: true, mode: 0o700 });
105
+ const resultPath = resultPathForAttemptId(taskDirectory, attemptId);
67
106
  writeAtomic(resultPath, params.result);
68
107
  const documents = (params.documents ?? []).map((document) => ({
69
108
  path: resolve(process.cwd(), document.path),
@@ -73,6 +112,7 @@ export default function (pi: ExtensionAPI) {
73
112
  documents.unshift({ path: resultPath, description: "Complete task result" });
74
113
  }
75
114
  const report = {
115
+ attemptId,
76
116
  status: params.status,
77
117
  summary: params.summary,
78
118
  documents,
@@ -80,7 +120,7 @@ export default function (pi: ExtensionAPI) {
80
120
  ...(params.question ? { question: params.question } : {}),
81
121
  reportedAt: new Date().toISOString(),
82
122
  };
83
- writeAtomic(resolve(taskDirectory, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
123
+ writeAtomic(attemptReportPath(taskDirectory, attemptId), `${JSON.stringify(report, null, 2)}\n`);
84
124
  return {
85
125
  content: [{ type: "text" as const, text: `${params.status}: ${params.summary}` }],
86
126
  details: report,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -0,0 +1,21 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+
4
+ const ATTEMPT_ID = /^[a-f0-9-]{36}$/i;
5
+
6
+ export type AttemptState = "working" | "settled";
7
+
8
+ export function createAttemptId(): string { return randomUUID(); }
9
+
10
+ export function requireAttemptId(value: unknown): string {
11
+ if (typeof value !== "string" || !ATTEMPT_ID.test(value)) throw new Error("Subagent attempt has no valid attempt ID.");
12
+ return value;
13
+ }
14
+
15
+ export function attemptReportPath(taskDirectory: string, attemptId: string): string {
16
+ return join(taskDirectory, "reports", `${requireAttemptId(attemptId)}.json`);
17
+ }
18
+
19
+ export function attemptStatePath(taskDirectory: string, attemptId: string): string {
20
+ return join(taskDirectory, "attempts", `${requireAttemptId(attemptId)}.json`);
21
+ }
@@ -13,7 +13,7 @@ import { RunPaneAllocator } from "./run-pane-allocator.ts";
13
13
  import { applyReport, applyTaskStatus, 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
- import { applyRunStatus } from "./run-status.ts";
16
+ import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
17
17
  import { isAbortError } from "./errors.ts";
18
18
 
19
19
  export interface BatchTask {
@@ -150,6 +150,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
150
150
  emit("running", `${settled}/${tasks.length} tasks settled.`);
151
151
  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
152
  agentName = prompted.agentName;
153
+ context.taskRecord.currentAttemptId = prompted.attemptId;
153
154
  report = prompted.report;
154
155
  status = applyReport(context.taskRecord, report);
155
156
  } catch (error) {
@@ -190,9 +191,10 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
190
191
  const settledResults = results.filter(Boolean);
191
192
  const blocked = settledResults.find((result) => result.status === "blocked");
192
193
  const failed = settledResults.filter((result) => result.status === "failed").length;
193
- const cancelled = options.signal?.aborted || activity.some((entry) => entry.status === "cancelled");
194
- const status: BatchResult["status"] = cancelled ? "cancelled" : blocked || activity.some((entry) => entry.status === "queued") ? "blocked" : failed === 0 ? "completed" : failed === tasks.length ? "failed" : "partial";
195
- const summary = status === "cancelled" ? "Subagent batch cancelled." : blocked ? `${blocked.name} needs input: ${blocked.question}` : failed ? `${tasks.length - failed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
194
+ const completed = settledResults.filter((result) => result.status === "completed").length;
195
+ const cancelled = settledResults.filter((result) => result.status === "cancelled").length;
196
+ const status = aggregateTaskStatus(activity.map((entry) => entry.status)) as BatchResult["status"];
197
+ 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.`;
196
198
  applyRunStatus(runRecord, status, { question: blocked?.question });
197
199
  await writeJsonAtomic(runFile, runRecord);
198
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 };
@@ -203,19 +205,11 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
203
205
  } finally {
204
206
  const keepTab = !options.signal?.aborted && activity.some((entry) => entry.status === "blocked" || entry.status === "queued" || entry.status === "running");
205
207
  if (tabId && !keepTab) {
206
- try {
207
- await cleanupRunTab({
208
- herdr, tabId, runFile, runRecord, signal: options.signal, failureStatus: "failed",
209
- errorPrefix: "Task results settled, but Herdr tab cleanup failed",
210
- onError: (message) => options.onCleanupError?.(message),
211
- });
212
- } catch (error) {
213
- const message = error instanceof Error ? error.message : String(error);
214
- const cleanupError = new Error(`Task results settled, but Herdr tab cleanup failed: ${message}`) as Error & { recordDirectory: string; runId: string };
215
- cleanupError.recordDirectory = runDirectory;
216
- cleanupError.runId = runId;
217
- throw cleanupError;
218
- }
208
+ await cleanupRunTab({
209
+ herdr, tabId, runFile, runRecord, signal: options.signal,
210
+ errorPrefix: "Task results settled, but Herdr tab cleanup failed",
211
+ onError: (message) => options.onCleanupError?.(message),
212
+ });
219
213
  }
220
214
  }
221
215
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
- import { mkdir, rename } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { join } from "node:path";
4
5
  import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
5
6
  import { continueQueuedRun } from "./continue-run.ts";
@@ -7,7 +8,7 @@ import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
7
8
  import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
8
9
  import { cleanupRunTab } from "./tab-cleanup.ts";
9
10
  import type { OwnerIdentity } from "./ownership.ts";
10
- import { ownerRecord } from "./ownership.ts";
11
+ import { isProcessAlive, ownerRecord } from "./ownership.ts";
11
12
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
12
13
  import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, projectTaskActivity, readReport, readRunRecord, readTaskRecord, readTurnRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus, type TurnRecord } from "./records.ts";
13
14
  import { isAbortError } from "./errors.ts";
@@ -32,6 +33,36 @@ export interface ResumeBlockedResult {
32
33
  tabId?: string;
33
34
  }
34
35
 
36
+ interface ResponseLock { token?: string; processId?: unknown; sessionId?: unknown; createdAt?: unknown; }
37
+
38
+ async function readResponseLock(lockFile: string): Promise<ResponseLock | undefined> {
39
+ try {
40
+ const lock = JSON.parse(await readFile(lockFile, "utf8")) as unknown;
41
+ return lock && typeof lock === "object" ? lock as ResponseLock : undefined;
42
+ } catch { return undefined; }
43
+ }
44
+
45
+ async function releaseResponseLock(lockFile: string, token: string): Promise<void> {
46
+ if ((await readResponseLock(lockFile))?.token !== token) return;
47
+ try { await unlink(lockFile); }
48
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
49
+ }
50
+
51
+ async function acquireResponseLock(lockFile: string, runId: string, owner?: OwnerIdentity): Promise<string> {
52
+ const token = randomUUID();
53
+ try {
54
+ await writeFile(lockFile, `${JSON.stringify({ token, processId: process.pid, sessionId: owner?.sessionId, createdAt: new Date().toISOString() })}\n`, { flag: "wx", mode: 0o600 });
55
+ return token;
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
58
+ const lock = await readResponseLock(lockFile);
59
+ if (lock && Number.isInteger(lock.processId) && !isProcessAlive(lock.processId)) {
60
+ throw new Error(`Subagent run '${runId}' has a stale blocked-response lock from process ${lock.processId}; remove ${lockFile} and retry.`);
61
+ }
62
+ throw new Error(`Subagent run '${runId}' already has a blocked response in progress (lock: ${lockFile}).`);
63
+ }
64
+ }
65
+
35
66
  async function emitProgress(options: RespondToBlockedTaskOptions, runDirectory: string, run: RunRecord, taskDirectories: string[], summary: string): Promise<void> {
36
67
  if (!options.onUpdate) return;
37
68
  const tasks = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
@@ -71,7 +102,7 @@ async function projectSettledRun(runDirectory: string, runFile: string, run: Run
71
102
  run = await readRunRecord(runFile);
72
103
  applyRunStatus(run, runStatus, { question: blockedReport?.question });
73
104
  await writeJsonAtomic(runFile, run);
74
- if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError, failureStatus: "failed" });
105
+ if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError });
75
106
  const summary = runStatus === "completed" ? "All tasks completed after the answer." : runStatus === "blocked" ? `${finalTasks[blockedIndex]?.name ?? "Task"} needs input: ${blockedReport?.question ?? "Input required."}` : finalReports.map((entry, index) => `${finalTasks[index].name}: ${entry.summary}`).join("\n");
76
107
  const activity = finalTasks.map((entry, index) => projectTaskActivity(entry, index));
77
108
  const tasks = finalTasks.map((entry, index) => ({ index, name: activity[index].name, status: entry.status, summary: String(finalReports[index].summary ?? "Task has no report."), documents: finalReports[index].documents ?? [], ...(entry.error ? { error: String(entry.error) } : {}), ...(entry.question ? { question: String(entry.question) } : {}), paneId: String(entry.paneId ?? ""), recordDirectory: taskDirectories[index], sessionFile: String(entry.sessionFile ?? "") }));
@@ -79,11 +110,18 @@ async function projectSettledRun(runDirectory: string, runFile: string, run: Run
79
110
  }
80
111
 
81
112
  export async function respondToBlockedTask(cwd: string, runId: string, answer: string, options: RespondToBlockedTaskOptions = {}): Promise<ResumeBlockedResult> {
82
- const herdr = options.herdr ?? new CliHerdrAutomation();
83
- const { taskNumber, signal, onCleanupError } = options;
84
113
  const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
85
114
  const runDirectory = await findRunDirectory(runsDirectory, runId);
86
115
  if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
116
+ const lockFile = join(runDirectory, ".respond.lock");
117
+ const lockToken = await acquireResponseLock(lockFile, runId, options.owner);
118
+ try { return await respondToBlockedTaskLocked(cwd, runId, answer, runDirectory, options); }
119
+ finally { await releaseResponseLock(lockFile, lockToken); }
120
+ }
121
+
122
+ async function respondToBlockedTaskLocked(cwd: string, runId: string, answer: string, runDirectory: string, options: RespondToBlockedTaskOptions): Promise<ResumeBlockedResult> {
123
+ const herdr = options.herdr ?? new CliHerdrAutomation();
124
+ const { taskNumber, signal, onCleanupError } = options;
87
125
  const runFile = join(runDirectory, "run.json");
88
126
  const run = await readRunRecord(runFile);
89
127
  const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
@@ -131,7 +169,9 @@ export async function respondToBlockedTask(cwd: string, runId: string, answer: s
131
169
  let status: "blocked" | "completed" | "failed" | "cancelled";
132
170
  let attemptError: unknown;
133
171
  try {
134
- report = (await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: answer, systemPromptFile: resumePrompt, agentName, start: !live, signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) })).report;
172
+ const prompted = await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: answer, systemPromptFile: resumePrompt, agentName, start: !live, signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) });
173
+ task.currentAttemptId = prompted.attemptId;
174
+ report = prompted.report;
135
175
  status = applyReport(task, report);
136
176
  } catch (error) {
137
177
  attemptError = error;
@@ -158,7 +198,7 @@ export async function respondToBlockedTask(cwd: string, runId: string, answer: s
158
198
  await writeJsonAtomic(siblingFile, sibling);
159
199
  }
160
200
  await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
161
- if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError, failureStatus: "failed" });
201
+ if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError });
162
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) } : {}) };
163
203
  }
164
204
  turn.events.push({ type: "report", status, summary: report.summary, question: report.question, error: report.error, at: new Date().toISOString() });
@@ -48,7 +48,9 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
48
48
  Object.assign(task, { tabId: run.tabId, paneId, paneLabel, agentName, attempt: Number(task.attempt ?? 0) + 1, startedAt: new Date().toISOString() });
49
49
  await writeJsonAtomic(taskFile, task);
50
50
  await options.onUpdate?.();
51
- const report = (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.`) })).report;
51
+ 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
+ task.currentAttemptId = prompted.attemptId;
53
+ const report = prompted.report;
52
54
  applyReport(task, report);
53
55
  } catch (error) {
54
56
  await settleAttemptFailure(task, reportFile, {
@@ -79,9 +81,9 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
79
81
  }
80
82
  }
81
83
  const statuses = await Promise.all(taskDirectories.map(async (directory) => (await readTaskRecord(join(directory, "task.json"))).status));
82
- applyRunStatus(run, options.signal?.aborted ? "cancelled" : aggregateTaskStatus(statuses));
84
+ applyRunStatus(run, aggregateTaskStatus(statuses));
83
85
  await writeJsonAtomic(runFile, run);
84
86
  if (options.cleanup !== false && run.status !== "blocked" && run.tabId) {
85
- await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: options.signal, onError: options.onCleanupError, failureStatus: "failed" });
87
+ await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: options.signal, onError: options.onCleanupError });
86
88
  }
87
89
  }
@@ -161,7 +161,9 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
161
161
  ]);
162
162
  const runningActivity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
163
163
  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
- report = (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.`) })).report;
164
+ 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
+ task.currentAttemptId = prompted.attemptId;
166
+ report = prompted.report;
165
167
  status = applyReport(task, report);
166
168
  } catch (error) {
167
169
  const settled = await settleAttemptFailure(task, join(historical.recordDirectory, "report.json"), {
@@ -183,7 +185,7 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
183
185
  Object.assign(runRecord, { ...ownerRecord(options.owner), tabId: tab.tabId });
184
186
  await writeJsonAtomic(join(run.recordDirectory, "run.json"), runRecord);
185
187
  if (status !== "blocked") {
186
- await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
188
+ await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError });
187
189
  }
188
190
  const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
189
191
  return { runId, label: run.label, status, summary: report.summary, documents: report.documents ?? [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity };
@@ -210,7 +212,7 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
210
212
  ]);
211
213
  const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
212
214
  options.onUpdate?.({ runId, label: run.label, status, summary: report.summary, documents: report.documents, tabId: tab?.tabId ?? "", paneId: tab?.paneId ?? "", sessionFile: historical.sessionFile, activity });
213
- if (tab) await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
215
+ if (tab) await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError });
214
216
  throw error;
215
217
  }
216
218
  }
@@ -1,10 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
- import { readFile, rm } from "node:fs/promises";
2
+ import { mkdir, readFile, rename } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { HerdrAutomation } from "./herdr.ts";
6
6
  import type { HerdrSubagentsReport } from "./runner.ts";
7
7
  import { parseReport } from "./records.ts";
8
+ import { attemptReportPath, attemptStatePath, createAttemptId, type AttemptState } from "./attempts.ts";
9
+ import { writeJsonAtomic } from "./state.ts";
8
10
 
9
11
  export interface LiveTaskRecord {
10
12
  id: string;
@@ -15,6 +17,7 @@ export interface LiveTaskRecord {
15
17
  thinking?: string;
16
18
  tools?: string[];
17
19
  skills?: string[];
20
+ currentAttemptId?: string;
18
21
  }
19
22
 
20
23
  export const PARENT_PANE_ENV = "PI_HERDR_SUBAGENTS_PARENT_PANE_ID";
@@ -61,10 +64,17 @@ export async function promptLiveAgent(options: {
61
64
  start?: boolean;
62
65
  stalledWarningMs?: number;
63
66
  onStalled?: () => void;
64
- }): Promise<{ report: HerdrSubagentsReport; agentName: string }> {
67
+ }): Promise<{ report: HerdrSubagentsReport; agentName: string; attemptId: string }> {
65
68
  const taskDirectory = dirname(options.task.sessionFile);
66
69
  const reportFile = join(taskDirectory, "report.json");
67
- await rm(reportFile, { force: true });
70
+ const taskFile = join(taskDirectory, "task.json");
71
+ const attemptId = createAttemptId();
72
+ const attemptReportFile = attemptReportPath(taskDirectory, attemptId);
73
+ const attemptStateFile = attemptStatePath(taskDirectory, attemptId);
74
+ await Promise.all([mkdir(dirname(attemptReportFile), { recursive: true, mode: 0o700 }), mkdir(dirname(attemptStateFile), { recursive: true, mode: 0o700 })]);
75
+ const durableTask = JSON.parse(await readFile(taskFile, "utf8")) as Record<string, unknown>;
76
+ durableTask.currentAttemptId = attemptId;
77
+ await writeJsonAtomic(taskFile, durableTask);
68
78
  const agentName = options.agentName ?? liveAgentName(options.task.id, options.attempt);
69
79
  if (options.start !== false) {
70
80
  await options.herdr.startAgent({
@@ -80,18 +90,50 @@ export async function promptLiveAgent(options: {
80
90
  try { await options.herdr.promptAgent({ target: agentName, prompt: options.prompt, signal: options.signal }); }
81
91
  catch (error) { promptError = error; }
82
92
  finally { if (stalledTimer) clearTimeout(stalledTimer); }
83
- if (!existsSync(reportFile)) {
93
+ while (!existsSync(attemptReportFile)) {
94
+ let state: AttemptState | undefined;
95
+ if (existsSync(attemptStateFile)) {
96
+ try {
97
+ const value = JSON.parse(await readFile(attemptStateFile, "utf8")) as { attemptId?: unknown; state?: unknown };
98
+ if (value.attemptId === attemptId && (value.state === "working" || value.state === "settled")) state = value.state;
99
+ } catch { /* a concurrent atomic replacement will be retried */ }
100
+ }
101
+ if (state === "working") {
102
+ try {
103
+ if (!(await options.herdr.isTaskRunning(options.paneId, options.signal))) {
104
+ if (promptError) throw promptError;
105
+ throw new Error("Child Pi stopped while its authoritative attempt state was still working and did not submit a subagent_report.");
106
+ }
107
+ } catch (error) {
108
+ if (error === promptError || (error instanceof Error && error.message.startsWith("Child Pi stopped while"))) throw error;
109
+ // A transient liveness query is not evidence that the child stopped.
110
+ }
111
+ await new Promise<void>((resolve, reject) => {
112
+ const onAbort = () => { clearTimeout(timer); reject(options.signal?.reason ?? new Error("Subagent attempt aborted.")); };
113
+ const timer = setTimeout(() => { options.signal?.removeEventListener("abort", onAbort); resolve(); }, 100);
114
+ if (options.signal?.aborted) onAbort();
115
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
116
+ });
117
+ continue;
118
+ }
84
119
  if (promptError) throw promptError;
85
- throw new Error("Child Pi settled but did not submit a subagent_report.");
120
+ throw new Error(state === "settled" ? "Child Pi settled but did not submit a subagent_report." : "Child Pi stopped without an authoritative lifecycle state and did not submit a subagent_report.");
86
121
  }
87
122
  let value: unknown;
88
- try { value = JSON.parse(await readFile(reportFile, "utf8")); }
123
+ try { value = JSON.parse(await readFile(attemptReportFile, "utf8")); }
89
124
  catch {
125
+ await rename(attemptReportFile, `${reportFile}.invalid-${Date.now()}`).catch(() => undefined);
90
126
  if (promptError) throw new Error(`${promptError instanceof Error ? promptError.message : String(promptError)}; Child Pi also submitted an invalid subagent_report.`);
91
127
  throw new Error("Child Pi submitted an invalid subagent_report.");
92
128
  }
93
- try { return { report: parseReport(value, "child subagent_report") as HerdrSubagentsReport, agentName }; }
129
+ try {
130
+ const report = parseReport(value, "child subagent_report") as HerdrSubagentsReport;
131
+ if (report.attemptId !== attemptId) throw new Error(`Report belongs to attempt '${report.attemptId ?? "unknown"}', expected '${attemptId}'.`);
132
+ await writeJsonAtomic(reportFile, report);
133
+ return { report, agentName, attemptId };
134
+ }
94
135
  catch (error) {
136
+ await rename(attemptReportFile, `${reportFile}.invalid-${Date.now()}`).catch(() => undefined);
95
137
  const detail = `Child Pi submitted an invalid subagent_report: ${error instanceof Error ? error.message : String(error)}`;
96
138
  throw new Error(promptError ? `${promptError instanceof Error ? promptError.message : String(promptError)}; ${detail}` : detail);
97
139
  }
@@ -6,6 +6,7 @@ import { writeJsonAtomic } from "./state.ts";
6
6
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
7
7
  import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, type RunRecord, type TaskStatus } from "./records.ts";
8
8
  import { cleanupRunTab } from "./tab-cleanup.ts";
9
+ import { attemptReportPath } from "./attempts.ts";
9
10
 
10
11
 
11
12
  export interface CancellationResult { cancelledRuns: number; cleanupErrors: string[]; }
@@ -109,24 +110,34 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
109
110
  continue;
110
111
  }
111
112
  if (isActiveStatus(task.status)) {
112
- const reportFile = join(tasksDirectory, taskEntry, "report.json");
113
- if (existsSync(reportFile) && (task.status === "running" || task.status === "starting")) {
113
+ let recoveredCurrentReport = false;
114
+ const canonicalReportFile = join(tasksDirectory, taskEntry, "report.json");
115
+ let currentAttemptReport: string | undefined;
116
+ if (task.currentAttemptId) {
117
+ try { currentAttemptReport = attemptReportPath(join(tasksDirectory, taskEntry), task.currentAttemptId); }
118
+ catch (error) { result.cleanupErrors.push(`Invalid current attempt for Subagent task ${taskFile}: ${error instanceof Error ? error.message : String(error)}`); }
119
+ }
120
+ // Once an attempt is identified, the canonical report is only history from
121
+ // an earlier attempt until this attempt publishes its scoped report.
122
+ const reportFile = task.currentAttemptId ? currentAttemptReport : canonicalReportFile;
123
+ if (reportFile && existsSync(reportFile) && (task.status === "running" || task.status === "starting")) {
114
124
  try {
115
125
  const report = await readReport(reportFile);
126
+ if (task.currentAttemptId && report.attemptId !== task.currentAttemptId) throw new Error(`Report attempt '${report.attemptId ?? "unknown"}' does not match current attempt '${task.currentAttemptId}'.`);
116
127
  applyReport(task, report, report.reportedAt ?? new Date().toISOString());
128
+ recoveredCurrentReport = reportFile !== canonicalReportFile;
129
+ if (reportFile !== canonicalReportFile) await writeJsonAtomic(canonicalReportFile, report);
117
130
  result.settledTasks += 1;
118
131
  } catch (error) {
119
132
  const detail = error instanceof Error ? error.message : String(error);
120
133
  result.cleanupErrors.push(`Invalid Subagent report ${reportFile}: ${detail}`);
121
- await persistFailureReport(reportFile, { status: "failed", summary: "Recovered task had an invalid structured report.", documents: [], error: detail, reportedAt: new Date().toISOString() });
122
- if (!differentOwner) {
123
- applyTaskStatus(task, "failed", { error: `Invalid structured report: ${detail}` });
124
- result.settledTasks += 1;
125
- result.interruptedTasks += 1;
126
- }
134
+ await persistFailureReport(canonicalReportFile, { status: "failed", summary: "Recovered task had an invalid structured report.", documents: [], error: detail, reportedAt: new Date().toISOString() }, reportFile !== canonicalReportFile);
135
+ applyTaskStatus(task, "failed", { error: `Invalid structured report: ${detail}` });
136
+ result.settledTasks += 1;
137
+ result.interruptedTasks += 1;
127
138
  }
128
139
  }
129
- if (differentOwner && isActiveStatus(task.status)) {
140
+ if (differentOwner && isActiveStatus(task.status) && !(recoveredCurrentReport && task.status === "blocked")) {
130
141
  applyTaskStatus(task, "cancelled", { error: "Owning parent Pi session is no longer active." });
131
142
  result.cancelledTasks += 1;
132
143
  result.interruptedTasks += 1;
@@ -158,8 +169,8 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
158
169
  result.interruptedTasks += unreadableTasks;
159
170
  continue;
160
171
  }
161
- if (differentOwner || !statuses.some((status) => status === "running" || status === "starting")) {
162
- applyRunStatus(run, differentOwner ? "cancelled" : aggregateTaskStatus(statuses), { error: differentOwner ? "Owning parent Pi session is no longer active." : undefined });
172
+ if (!statuses.some((status) => status === "running" || status === "starting")) {
173
+ applyRunStatus(run, aggregateTaskStatus(statuses));
163
174
  await writeJsonAtomic(runFile, run);
164
175
  if (differentOwner || run.status === "cancelled") await closeRunTab(run, runFile, herdr, result.cleanupErrors);
165
176
  }
@@ -19,6 +19,7 @@ export interface PersistedReport {
19
19
  status: "completed" | "needs-input" | "failed";
20
20
  summary: string;
21
21
  documents: PersistedDocument[];
22
+ attemptId?: string;
22
23
  error?: string;
23
24
  question?: string;
24
25
  reportedAt?: string;
@@ -74,6 +75,7 @@ export interface TaskRecord {
74
75
  agentName?: string;
75
76
  attempt?: number;
76
77
  currentTurnFile?: string;
78
+ currentAttemptId?: string;
77
79
  question?: string;
78
80
  error?: string;
79
81
  }
@@ -176,6 +178,7 @@ export function parseReport(value: unknown, source = "structured report"): Persi
176
178
  if (report.status === "needs-input" && (typeof report.question !== "string" || !report.question.trim())) throw new Error(`Missing report question in ${source}.`);
177
179
  if (report.error !== undefined && typeof report.error !== "string") throw new Error(`Invalid report error in ${source}.`);
178
180
  if (report.question !== undefined && typeof report.question !== "string") throw new Error(`Invalid report question in ${source}.`);
181
+ if (report.attemptId !== undefined && (typeof report.attemptId !== "string" || !report.attemptId.trim())) throw new Error(`Invalid report attemptId in ${source}.`);
179
182
  return report as unknown as PersistedReport;
180
183
  }
181
184
 
@@ -5,3 +5,7 @@ export function resultPathForAttempt(taskDirectory: string, attempt: number): st
5
5
  ? resolve(taskDirectory, "turns", `${String(attempt).padStart(2, "0")}-result.md`)
6
6
  : resolve(taskDirectory, "result.md");
7
7
  }
8
+
9
+ export function resultPathForAttemptId(taskDirectory: string, attemptId: string): string {
10
+ return resolve(taskDirectory, "attempt-results", `${attemptId}.md`);
11
+ }
@@ -7,9 +7,9 @@ export function aggregateTaskStatus(statuses: readonly TaskStatus[]): AggregateR
7
7
  if (statuses.length === 0) return "failed";
8
8
  if (statuses.some((status) => status === "running" || status === "starting")) return "running";
9
9
  if (statuses.some((status) => status === "blocked" || status === "queued")) return "blocked";
10
- const completed = statuses.some((status) => status === "completed");
11
- if (statuses.some((status) => status === "cancelled")) return completed ? "partial" : "cancelled";
12
- if (statuses.some((status) => status === "failed")) return completed ? "partial" : "failed";
10
+ if (statuses.every((status) => status === "cancelled")) return "cancelled";
11
+ if (statuses.every((status) => status === "failed")) return "failed";
12
+ if (statuses.some((status) => status === "cancelled" || status === "failed")) return "partial";
13
13
  if (statuses.every((status) => status === "completed")) return "completed";
14
14
  throw new Error(`Cannot aggregate unknown task status: ${statuses.join(", ")}`);
15
15
  }
@@ -184,7 +184,9 @@ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsO
184
184
  await writeJson(taskFile, taskRecord);
185
185
  update("starting", "Starting child Pi in Herdr...");
186
186
  update("running", "Child Pi is working...");
187
- const report = (await promptLiveAgent({ herdr, task: { ...taskRecord, id: taskId, sessionFile }, paneId, prompt, systemPromptFile, agentName: name, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${label} appears stalled; it is still running.`) })).report;
187
+ const prompted = await promptLiveAgent({ herdr, task: { ...taskRecord, id: taskId, sessionFile }, paneId, prompt, systemPromptFile, agentName: name, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${label} appears stalled; it is still running.`) });
188
+ taskRecord.currentAttemptId = prompted.attemptId;
189
+ const report = prompted.report;
188
190
  attempt = { status: applyReport(taskRecord, report), report };
189
191
  } catch (error) {
190
192
  const settled = await settleAttemptFailure(taskRecord, reportFile, {
@@ -224,19 +226,7 @@ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsO
224
226
  }
225
227
  } finally {
226
228
  if (tabId && result?.status !== "blocked") {
227
- try {
228
- await cleanupRunTab({ herdr, tabId, runFile, runRecord, signal: options.signal, onError: options.onCleanupError, failureStatus: "failed" });
229
- } catch (error) {
230
- const cleanupError = error instanceof Error ? error.message : String(error);
231
- if (result?.ok) {
232
- const errorMessage = `Task completed but Herdr tab cleanup failed: ${cleanupError}`;
233
- const completedAt = new Date().toISOString();
234
- applyRunStatus(runRecord, "failed", { at: completedAt, error: errorMessage });
235
- applyTaskStatus(taskRecord, "failed", { at: completedAt, error: errorMessage });
236
- await Promise.all([writeJson(runFile, runRecord), writeJson(taskFile, taskRecord)]);
237
- result = failure(cwd, startedAt, errorMessage, common());
238
- }
239
- }
229
+ await cleanupRunTab({ herdr, tabId, runFile, runRecord, signal: options.signal, onError: options.onCleanupError });
240
230
  }
241
231
  }
242
232
 
@@ -15,7 +15,6 @@ export async function cleanupRunTab(options: {
15
15
  signal?: AbortSignal;
16
16
  onError?: (message: string) => void;
17
17
  onClosed?: (runRecord: RunRecord) => void | Promise<void>;
18
- failureStatus?: "failed";
19
18
  errorPrefix?: string;
20
19
  }): Promise<{ deferred: boolean }> {
21
20
  const mergeLatest = async (update: (run: RunRecord) => void | Promise<void>, requirePending = false, clearPending = true) => {
@@ -34,14 +33,17 @@ export async function cleanupRunTab(options: {
34
33
  const reportFailure = async (error: unknown, requirePending = false) => {
35
34
  const detail = error instanceof Error ? error.message : String(error);
36
35
  try {
37
- await mergeLatest((latest) => Object.assign(latest, { cleanupPendingTabIds: [...new Set([...(latest.cleanupPendingTabIds ?? []), options.tabId])], cleanupError: detail, ...(options.failureStatus ? { status: options.failureStatus } : {}) }), requirePending, false);
36
+ await mergeLatest((latest) => Object.assign(latest, { cleanupPendingTabIds: [...new Set([...(latest.cleanupPendingTabIds ?? []), options.tabId])], cleanupError: detail }), requirePending, false);
38
37
  } catch (persistError) {
39
38
  options.onError?.(`${options.errorPrefix ?? "Herdr tab cleanup failed"}: ${detail}; could not persist cleanup state: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
40
39
  return;
41
40
  }
42
41
  options.onError?.(`${options.errorPrefix ?? "Herdr tab cleanup failed"}: ${detail}`);
43
42
  };
44
- const finishClosed = async (requirePending = false) => mergeLatest(async (latest) => options.onClosed?.(latest), requirePending);
43
+ const finishClosed = async (requirePending = false) => mergeLatest(async (latest) => {
44
+ await options.onClosed?.(latest);
45
+ if (!latest.cleanupPendingTabIds?.length) delete latest.cleanupError;
46
+ }, requirePending);
45
47
  try {
46
48
  if (options.signal?.aborted) {
47
49
  await options.herdr.closeTab(options.tabId);
@@ -78,7 +80,7 @@ export async function cleanupRunTab(options: {
78
80
  return { deferred: false };
79
81
  }
80
82
  await reportFailure(error);
81
- throw error;
83
+ return { deferred: false };
82
84
  }
83
85
  return { deferred: false };
84
86
  }
@@ -216,7 +216,8 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
216
216
  });
217
217
  const lines = [result.summary, ...result.tasks.map((task) => `- ${task.name}: ${task.status} — ${task.summary}`)];
218
218
  if (result.documents.length) lines.push("", "Documents:", ...result.documents.map((document) => `- ${document.description}: ${document.path}`));
219
- return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt) }, ...(result.ok ? {} : { isError: true }) };
219
+ 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 } : {}) };
220
221
  } catch (error) {
221
222
  const text = error instanceof Error ? error.message : String(error);
222
223
  const diagnostic = error as { recordDirectory?: string; runId?: string };
@@ -248,10 +249,11 @@ async function executeSingle({ params, signal, onUpdate, ctx, dependencies, disc
248
249
  herdr: dependencies.herdr,
249
250
  stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
250
251
  onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "running", stalled: true } }),
251
- onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "failed", cleanupError: text } }),
252
+ onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "warning", cleanupError: text } }),
252
253
  onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: details(partial) }),
253
254
  });
254
- return { content: [{ type: "text", text: summaryText(result) }], details: details(result), ...(result.ok ? {} : { isError: true }) };
255
+ const isError = result.status === "failed" || result.status === "cancelled";
256
+ return { content: [{ type: "text", text: summaryText(result) }], details: details(result), ...(isError ? { isError: true } : {}) };
255
257
  }
256
258
 
257
259
  export async function executeHerdrSubagents(
@@ -56,6 +56,14 @@ function fakeHerdr(mode = "completed") {
56
56
  let maxActive = 0;
57
57
  let taskRuns = 0;
58
58
  const agents = new Map();
59
+ const writeAttemptReport = (directory, report) => {
60
+ const task = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
61
+ const attemptId = task.currentAttemptId;
62
+ if (!attemptId) throw new Error("test task has no currentAttemptId");
63
+ const reports = join(directory, "reports");
64
+ mkdirSync(reports, { recursive: true });
65
+ writeFileSync(join(reports, `${attemptId}.json`), typeof report === "string" ? report : JSON.stringify({ attemptId, ...report }));
66
+ };
59
67
  return {
60
68
  calls,
61
69
  get maxActive() { return maxActive; },
@@ -77,8 +85,33 @@ function fakeHerdr(mode = "completed") {
77
85
  active += 1;
78
86
  maxActive = Math.max(maxActive, active);
79
87
  await new Promise((resolve) => setTimeout(resolve, request.prompt.includes("slow") ? 15 : 2));
88
+ if (mode === "delayed-report") {
89
+ const task = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
90
+ const attempts = join(directory, "attempts");
91
+ mkdirSync(attempts, { recursive: true });
92
+ writeFileSync(join(attempts, `${task.currentAttemptId}.json`), JSON.stringify({ attemptId: task.currentAttemptId, state: "working" }));
93
+ setTimeout(() => writeAttemptReport(directory, { status: "completed", summary: "Retry eventually completed.", documents: [] }), 25);
94
+ active -= 1;
95
+ return { status: "idle" };
96
+ }
97
+ if (mode === "working-state-crash") {
98
+ const task = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
99
+ const attempts = join(directory, "attempts");
100
+ mkdirSync(attempts, { recursive: true });
101
+ writeFileSync(join(attempts, `${task.currentAttemptId}.json`), JSON.stringify({ attemptId: task.currentAttemptId, state: "working" }));
102
+ active -= 1;
103
+ throw new Error("child process exited");
104
+ }
105
+ if (mode === "working-state-dead-return") {
106
+ const task = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
107
+ const attempts = join(directory, "attempts");
108
+ mkdirSync(attempts, { recursive: true });
109
+ writeFileSync(join(attempts, `${task.currentAttemptId}.json`), JSON.stringify({ attemptId: task.currentAttemptId, state: "working" }));
110
+ active -= 1;
111
+ return { status: "idle" };
112
+ }
80
113
  if (mode === "report-then-post-fail") {
81
- writeFileSync(join(directory, "report.json"), JSON.stringify({ status: "completed", summary: "Authoritative report completed before transport failed.", documents: [] }));
114
+ writeAttemptReport(directory, { status: "completed", summary: "Authoritative report completed before transport failed.", documents: [] });
82
115
  active -= 1;
83
116
  throw new Error("connection lost after authoritative report");
84
117
  }
@@ -87,28 +120,28 @@ function fakeHerdr(mode = "completed") {
87
120
  if (mode === "completed" || mode === "close-failed") {
88
121
  const failed = request.prompt.includes("FAIL");
89
122
  writeFileSync(join(directory, "result.md"), request.prompt.startsWith("Review") ? "FULL RESULT THAT STAYS OUT OF PARENT CONTEXT" : `FULL ${request.prompt}`);
90
- writeFileSync(join(directory, "report.json"), JSON.stringify({
123
+ writeAttemptReport(directory, {
91
124
  status: failed ? "failed" : "completed",
92
125
  summary: request.prompt.startsWith("Review") ? "Authentication review completed." : failed ? `${request.prompt} failed.` : `${request.prompt} completed.`,
93
126
  ...(failed ? { error: "expected failure" } : {}),
94
127
  documents: [{ path: join(directory, "result.md"), description: "Full result" }],
95
- }));
128
+ });
96
129
  } else if (mode === "failed") {
97
130
  writeFileSync(join(directory, "result.md"), "Provider stopped before completing the review.");
98
- writeFileSync(join(directory, "report.json"), JSON.stringify({
131
+ writeAttemptReport(directory, {
99
132
  status: "failed",
100
133
  summary: "Authentication review failed.",
101
134
  error: "provider unavailable",
102
135
  documents: [],
103
- }));
136
+ });
104
137
  } else if (mode === "malformed") {
105
- writeFileSync(join(directory, "report.json"), "{truncated");
138
+ writeAttemptReport(directory, "{truncated");
106
139
  } else if (mode === "blocked" || mode === "blocked-twice" || mode === "blocked-then-post-fail") {
107
140
  const needsInput = ((mode === "blocked" || mode === "blocked-then-post-fail") && taskRuns === 1) || (mode === "blocked-twice" && (taskRuns === 1 || taskRuns === 3));
108
141
  const failed = request.prompt.includes("FAIL");
109
142
  const question = taskRuns === 3 ? "Which environment should I inspect?" : "Which branch should I inspect?";
110
143
  writeFileSync(join(directory, "result.md"), `FULL ${request.prompt}`);
111
- writeFileSync(join(directory, "report.json"), JSON.stringify({ 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: [] }));
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: [] });
112
145
  }
113
146
  active -= 1;
114
147
  return { status: mode === "failed" ? "done" : "idle" };
@@ -124,7 +157,7 @@ function fakeHerdr(mode = "completed") {
124
157
  async isTabFocused(tabId) { calls.push(["isTabFocused", { tabId }]); return mode === "focused"; },
125
158
  async waitForTabUnfocused(tabId) { calls.push(["waitForTabUnfocused", { tabId }]); },
126
159
  async paneExists(paneId) { calls.push(["paneExists", { paneId }]); return mode !== "missing-pane"; },
127
- async isTaskRunning(paneId) { calls.push(["isTaskRunning", { paneId }]); return mode !== "missing-pane" && mode !== "shell-only"; },
160
+ async isTaskRunning(paneId) { calls.push(["isTaskRunning", { paneId }]); return mode !== "missing-pane" && mode !== "shell-only" && mode !== "working-state-crash" && mode !== "working-state-dead-return"; },
128
161
  async closeTab(tabId) {
129
162
  calls.push(["closeTab", { tabId }]);
130
163
  if (mode === "close-failed") throw new Error("tab still busy");
@@ -308,6 +341,7 @@ try {
308
341
  let unknownStatusRejected = false;
309
342
  try { aggregateTaskStatus(["unknown"]); } catch { unknownStatusRejected = true; }
310
343
  check("task status aggregation rejects unknown states", unknownStatusRejected);
344
+ check("mixed failed and cancelled tasks remain partial", aggregateTaskStatus(["failed", "cancelled"]) === "partial" && aggregateTaskStatus(["failed", "failed"]) === "failed" && aggregateTaskStatus(["cancelled", "cancelled"]) === "cancelled");
311
345
  check("all execution paths share abort classification", isAbortError(Object.assign(new Error("stopped"), { name: "AbortError" })) && isAbortError(new Error("operation aborted")) && !isAbortError(new Error("provider unavailable")));
312
346
  check("respond releases ownership only for explicit terminal statuses", ["completed", "partial", "failed", "cancelled"].every(isSettledRunStatus) && !isSettledRunStatus("blocked") && !isSettledRunStatus(undefined));
313
347
 
@@ -322,11 +356,13 @@ try {
322
356
  const childEvents = new Map();
323
357
  const childExecCalls = [];
324
358
  const childStatuses = [];
359
+ let childReportTool;
325
360
  registerSubagentReport({
326
- registerTool() {},
361
+ registerTool(definition) { childReportTool = definition; },
327
362
  registerCommand(name, options) { childCommands.set(name, options); },
328
363
  registerShortcut(key, options) { childShortcuts.set(key, options); },
329
364
  on(name, handler) { childEvents.set(name, handler); },
365
+ events: { on() {} },
330
366
  async exec(command, args) { childExecCalls.push([command, args]); return { stdout: "", stderr: "", code: 0, killed: false }; },
331
367
  });
332
368
  process.env.PI_HERDR_SUBAGENTS_PARENT_PANE_ID = "w1:p-parent";
@@ -335,6 +371,23 @@ try {
335
371
  await childCommands.get("parent")?.handler("", childContext);
336
372
  await childShortcuts.get("alt+p")?.handler(childContext);
337
373
  check("child command and shortcut return to the exact parent Agent pane", childExecCalls.length === 2 && childExecCalls.every(([command, args]) => command === "herdr" && args.join(",") === "agent,focus,w1:p-parent") && childStatuses.some(([, value]) => value.includes("alt+p")));
374
+ const reportToolDirectory = join(root, "child-report-tool");
375
+ const reportAttemptId = "11111111-1111-4111-8111-111111111111";
376
+ mkdirSync(reportToolDirectory, { recursive: true });
377
+ writeFileSync(join(reportToolDirectory, "task.json"), JSON.stringify({ id: "report-task", runId: "report-run", order: 1, status: "running", currentAttemptId: reportAttemptId }));
378
+ process.env.PI_HERDR_SUBAGENTS_TASK_DIR = reportToolDirectory;
379
+ await childEvents.get("before_agent_start")?.({}, childContext);
380
+ await childReportTool.execute("report", { status: "completed", summary: "Attempt completed.", result: "Full attempt result.", documents: [] });
381
+ const scopedReport = JSON.parse(readFileSync(join(reportToolDirectory, "reports", `${reportAttemptId}.json`), "utf8"));
382
+ check("child report commits only to its captured attempt identity", scopedReport.attemptId === reportAttemptId && scopedReport.status === "completed" && !existsSync(join(reportToolDirectory, "report.json")) && JSON.parse(readFileSync(join(reportToolDirectory, "attempts", `${reportAttemptId}.json`), "utf8")).state === "working");
383
+ const firstAttemptResult = scopedReport.documents[0].path;
384
+ const nextReportAttemptId = "33333333-3333-4333-8333-333333333333";
385
+ writeFileSync(join(reportToolDirectory, "task.json"), JSON.stringify({ id: "report-task", runId: "report-run", order: 1, status: "running", currentAttemptId: nextReportAttemptId }));
386
+ await childEvents.get("before_agent_start")?.({}, childContext);
387
+ await childReportTool.execute("report", { status: "completed", summary: "New attempt completed.", result: "New full result.", documents: [] });
388
+ const nextScopedReport = JSON.parse(readFileSync(join(reportToolDirectory, "reports", `${nextReportAttemptId}.json`), "utf8"));
389
+ check("full results are isolated by attempt identity", nextScopedReport.documents[0].path !== firstAttemptResult && readFileSync(firstAttemptResult, "utf8") === "Full attempt result." && readFileSync(nextScopedReport.documents[0].path, "utf8") === "New full result.");
390
+ delete process.env.PI_HERDR_SUBAGENTS_TASK_DIR;
338
391
  const herdr = fakeHerdr();
339
392
  const success = await runHerdrSubagents("Review authentication", {
340
393
  cwd: root,
@@ -381,6 +434,12 @@ try {
381
434
  const silentHerdr = fakeHerdr("silent");
382
435
  const silent = await runHerdrSubagents("Forget report", { cwd: root, herdr: silentHerdr });
383
436
  check("missing report is visible failure", !silent.ok && silent.errorMessage?.includes("did not submit") && silentHerdr.calls.at(-1)[0] === "closeTab");
437
+ const delayedReport = await runHerdrSubagents("Retry after a transient provider error", { cwd: root, herdr: fakeHerdr("delayed-report") });
438
+ check("authoritative lifecycle waits for a report after a false screen-settled state", delayedReport.ok && delayedReport.status === "completed" && delayedReport.summary === "Retry eventually completed.");
439
+ const crashedAttempt = await runHerdrSubagents("Crash after reporting working", { cwd: root, herdr: fakeHerdr("working-state-crash") });
440
+ check("a dead child cannot leave an authoritative working attempt waiting forever", !crashedAttempt.ok && crashedAttempt.errorMessage?.includes("child process exited"));
441
+ const deadWorkingReturn = await runHerdrSubagents("Return idle then exit", { cwd: root, herdr: fakeHerdr("working-state-dead-return") });
442
+ check("a normal wait return cannot leave a dead working attempt waiting forever", !deadWorkingReturn.ok && deadWorkingReturn.errorMessage?.includes("Child Pi stopped while"));
384
443
  const malformedExecutionHerdr = fakeHerdr("malformed");
385
444
  const malformedExecution = await runHerdrSubagents("Write malformed report", { cwd: root, herdr: malformedExecutionHerdr });
386
445
  const malformedExecutionTaskDirectory = malformedExecution.recordDirectory;
@@ -396,7 +455,7 @@ try {
396
455
  const cleanupRunRecord = JSON.parse(readFileSync(join(cleanupRunDirectory, "run.json"), "utf8"));
397
456
  const cleanupTaskDirectory = join(cleanupRunDirectory, "tasks", readdirSync(join(cleanupRunDirectory, "tasks"))[0]);
398
457
  const cleanupTaskRecord = JSON.parse(readFileSync(join(cleanupTaskDirectory, "task.json"), "utf8"));
399
- check("tab cleanup failure settles records as failed", !cleanupFailure.ok && cleanupFailure.errorMessage?.includes("cleanup failed") && cleanupRunRecord.status === "failed" && cleanupTaskRecord.status === "failed");
458
+ check("tab cleanup failure preserves completed business records", cleanupFailure.ok && cleanupRunRecord.status === "completed" && cleanupTaskRecord.status === "completed" && cleanupRunRecord.cleanupError.includes("tab still busy"));
400
459
 
401
460
  const definition = parseAgentMarkdown(`---\nname: reviewer\ndescription: Review code\nmodel: fake/model\ntools: read, grep\n---\nReview carefully.`, "reviewer.md");
402
461
  check("persona frontmatter parsing", definition.name === "reviewer" && definition.model === "fake/model" && definition.tools?.join(",") === "read,grep" && definition.systemPrompt === "Review carefully.");
@@ -474,16 +533,25 @@ try {
474
533
  check("read-only batch uses bounded concurrency", batchDetails.effectiveConcurrency === 2 && batchHerdr.maxActive === 2 && batchHerdr.calls.filter(([name]) => name === "splitPane").length === 3);
475
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);
476
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 mixedCancelHerdr = fakeHerdr();
537
+ const mixedCancelPrompt = mixedCancelHerdr.promptAgent.bind(mixedCancelHerdr);
538
+ 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);
539
+ const mixedCancelController = new AbortController();
540
+ const mixedCancelPromise = executeHerdrSubagents({ concurrency: 2, tasks: [{ name: "fail-first", prompt: "FAIL", agent: "explorer" }, { name: "cancel-second", prompt: "WAIT", agent: "reviewer" }] }, mixedCancelController.signal, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: mixedCancelHerdr });
541
+ await new Promise((resolve) => setTimeout(resolve, 10));
542
+ mixedCancelController.abort(new Error("Parent Pi session closed."));
543
+ const mixedCancel = await mixedCancelPromise;
544
+ check("failed and cancelled production tasks aggregate to partial", mixedCancel.details.status === "partial" && mixedCancel.details.tasks.some((task) => task.status === "failed") && mixedCancel.details.tasks.some((task) => task.status === "cancelled"));
477
545
  const batchCleanupHerdr = fakeHerdr("close-failed");
478
546
  const batchCleanupUpdates = [];
479
547
  const batchCleanup = await executeHerdrSubagents({ tasks: [{ name: "cleanup", prompt: "cleanup", agent: "explorer" }] }, undefined, (update) => batchCleanupUpdates.push(update), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: batchCleanupHerdr });
480
548
  const batchCleanupRecord = JSON.parse(readFileSync(join(batchCleanup.details.recordDirectory, "run.json"), "utf8"));
481
- check("batch cleanup failure is persisted and surfaced once", batchCleanup.isError && batchCleanup.content[0].text.includes("cleanup failed") && batchCleanupRecord.status === "failed" && batchCleanupRecord.cleanupError.includes("tab still busy") && batchCleanupUpdates.filter((update) => update.content[0]?.text.includes("cleanup failed")).length === 1);
549
+ check("batch cleanup failure is persisted without changing the business result", !batchCleanup.isError && batchCleanupRecord.status === "completed" && batchCleanupRecord.cleanupError.includes("tab still busy") && batchCleanupUpdates.filter((update) => update.content[0]?.text.includes("cleanup failed")).length === 1);
482
550
  const cleanupProbeHerdr = fakeHerdr();
483
551
  cleanupProbeHerdr.isTabFocused = async () => { throw new Error("tab lookup failed"); };
484
552
  const cleanupProbe = await executeHerdrSubagents({ tasks: [{ name: "cleanup-probe", prompt: "cleanup probe", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: cleanupProbeHerdr });
485
553
  const cleanupProbeRecord = JSON.parse(readFileSync(join(cleanupProbe.details.recordDirectory, "run.json"), "utf8"));
486
- check("cleanup focus-probe failure is persisted and surfaced", cleanupProbe.isError && cleanupProbe.content[0].text.includes("tab lookup failed") && cleanupProbeRecord.status === "failed" && cleanupProbeRecord.cleanupError === "tab lookup failed");
554
+ check("cleanup focus-probe failure is persisted without changing the business result", !cleanupProbe.isError && cleanupProbeRecord.status === "completed" && cleanupProbeRecord.cleanupError === "tab lookup failed");
487
555
  const missingTabHerdr = fakeHerdr();
488
556
  missingTabHerdr.isTabFocused = async () => { throw new Error('{"error":{"code":"tab_not_found","message":"tab w1:t2 not found"}}'); };
489
557
  const missingTabCleanup = await executeHerdrSubagents({ tasks: [{ name: "already-closed", prompt: "already closed", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: missingTabHerdr });
@@ -549,7 +617,7 @@ try {
549
617
  const deferredSingle = await executeHerdrSubagents({ prompt: "single deferred cleanup", agent: "explorer" }, undefined, (update) => deferredSingleUpdates.push(update), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: deferredSingleHerdr });
550
618
  await new Promise((resolve) => setTimeout(resolve, 10));
551
619
  const deferredSingleRun = JSON.parse(readFileSync(join(deferredSingle.details.recordDirectory, "..", "..", "run.json"), "utf8"));
552
- check("deferred single-task cleanup failure is persisted and surfaced", deferredSingleRun.status === "failed" && deferredSingleRun.cleanupError === "single deferred close failed" && deferredSingleUpdates.some((update) => update.content[0].text.includes("cleanup failed")));
620
+ check("deferred single-task cleanup failure is persisted and surfaced without changing completion", deferredSingleRun.status === "completed" && deferredSingleRun.cleanupError === "single deferred close failed" && deferredSingleUpdates.some((update) => update.content[0].text.includes("cleanup failed") && update.details.status === "warning"));
553
621
  const cancelledHerdr = fakeHerdr();
554
622
  cancelledHerdr.isTabFocused = async (tabId) => { cancelledHerdr.calls.push(["isTabFocused", { tabId }]); return true; };
555
623
  cancelledHerdr.waitForTabUnfocused = async () => { throw new Error("shutdown cleanup must not wait for focus"); };
@@ -667,11 +735,21 @@ try {
667
735
 
668
736
  const blockedHerdr = fakeHerdr("blocked");
669
737
  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 });
670
- check("needs-input returns foreground control and preserves tab", blocked.details.status === "blocked" && blocked.details.tasks[0].question === "Which branch should I inspect?" && !blockedHerdr.calls.some(([name]) => name === "closeTab"));
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"));
671
739
  const blockedStatus = await executeHerdrSubagents({ action: "status", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
672
740
  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?"));
673
741
  const activeCleanup = await executeHerdrSubagents({ action: "cleanup", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
674
742
  check("cleanup refuses to delete an active blocked run", activeCleanup.isError && activeCleanup.content[0].text.includes("still active") && existsSync(blocked.details.recordDirectory));
743
+ mkdirSync(join(blocked.details.recordDirectory, ".respond.lock"));
744
+ const concurrentResponse = await executeHerdrSubagents({ action: "respond", runId: blocked.details.runId, answer: "duplicate" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
745
+ rmSync(join(blocked.details.recordDirectory, ".respond.lock"), { recursive: true });
746
+ check("concurrent blocked responses are rejected before a second prompt", concurrentResponse.isError && concurrentResponse.content[0].text.includes("already has a blocked response in progress"));
747
+ const staleLockHerdr = fakeHerdr("blocked");
748
+ const staleLockBlocked = await executeHerdrSubagents({ tasks: [{ name: "stale-lock-question", prompt: "question", agent: "explorer" }] }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: staleLockHerdr });
749
+ writeFileSync(join(staleLockBlocked.details.recordDirectory, ".respond.lock"), JSON.stringify({ processId: 99999999, createdAt: "2000-01-01T00:00:00.000Z" }));
750
+ const diagnosedStaleResponse = await executeHerdrSubagents({ action: "respond", runId: staleLockBlocked.details.runId, answer: "Inspect main." }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: staleLockHerdr });
751
+ 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
+ rmSync(join(staleLockBlocked.details.recordDirectory, ".respond.lock"));
675
753
  const resumeUpdates = [];
676
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 });
677
755
  const blockedStarts = blockedHerdr.calls.filter(([name]) => name === "startAgent").map(([, request]) => request);
@@ -714,7 +792,7 @@ try {
714
792
  const cancelledContinuationTask = JSON.parse(readFileSync(join(abortedTaskDirectories[1], "task.json"), "utf8"));
715
793
  const cancelledContinuationRun = JSON.parse(readFileSync(join(abortedContinuationRun.details.recordDirectory, "run.json"), "utf8"));
716
794
  const cancelledContinuationReport = JSON.parse(readFileSync(join(abortedTaskDirectories[1], "report.json"), "utf8"));
717
- check("aborted queued continuation is persisted as cancelled", cancelledContinuationTask.status === "cancelled" && cancelledContinuationRun.status === "cancelled" && cancelledContinuationReport.summary.includes("cancelled"));
795
+ check("aborted queued continuation preserves a partial completed/cancelled run", cancelledContinuationTask.status === "cancelled" && cancelledContinuationRun.status === "partial" && cancelledContinuationReport.summary.includes("cancelled"));
718
796
  const deadPaneHerdr = fakeHerdr("blocked");
719
797
  const deadPaneRun = await executeHerdrSubagents({ tasks: [{ name: "dead-pane", prompt: "question", agent: "explorer" }] }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: deadPaneHerdr });
720
798
  deadPaneHerdr.paneExists = async () => false;
@@ -980,6 +1058,27 @@ try {
980
1058
  writeFileSync(join(recoveryTask, "task.json"), JSON.stringify({ id: "task", runId: "recover", order: 1, name: "lost", status: "running", paneId: "w1:missing", sessionFile: join(recoveryTask, "session.jsonl") }));
981
1059
  const reconciliation = await reconcileSubagentRuns(recoveryRoot, fakeHerdr("missing-pane"), { sessionId: "new-owner" });
982
1060
  check("reconciliation marks stale child cancelled", reconciliation.cancelledTasks === 1 && JSON.parse(readFileSync(join(recoveryTask, "task.json"), "utf8")).status === "cancelled");
1061
+ const isolatedAttemptRoot = join(root, "isolated-attempt-recovery");
1062
+ const isolatedAttemptRun = join(isolatedAttemptRoot, ".pi", "herdr-subagents", "runs", "run");
1063
+ const isolatedAttemptTask = join(isolatedAttemptRun, "tasks", "01-task");
1064
+ mkdirSync(isolatedAttemptTask, { recursive: true });
1065
+ const currentAttemptId = "22222222-2222-4222-8222-222222222222";
1066
+ writeFileSync(join(isolatedAttemptRun, "run.json"), JSON.stringify({ id: "isolated-attempt", label: "isolated-attempt", status: "running", startedAt: new Date().toISOString() }));
1067
+ writeFileSync(join(isolatedAttemptTask, "task.json"), JSON.stringify({ id: "isolated-attempt-task", runId: "isolated-attempt", order: 1, status: "running", paneId: "w1:p-live", currentAttemptId, sessionFile: join(isolatedAttemptTask, "session.jsonl") }));
1068
+ writeFileSync(join(isolatedAttemptTask, "report.json"), JSON.stringify({ attemptId: "11111111-1111-4111-8111-111111111111", status: "needs-input", summary: "Prior attempt blocked.", question: "Old question?", documents: [] }));
1069
+ const isolatedReconciliation = await reconcileSubagentRuns(isolatedAttemptRoot, fakeHerdr());
1070
+ const isolatedTaskRecord = JSON.parse(readFileSync(join(isolatedAttemptTask, "task.json"), "utf8"));
1071
+ check("reconciliation ignores a prior canonical report while the current attempt is live", isolatedReconciliation.liveTasks === 1 && isolatedTaskRecord.status === "running" && isolatedTaskRecord.currentAttemptId === currentAttemptId && JSON.parse(readFileSync(join(isolatedAttemptTask, "report.json"), "utf8")).summary === "Prior attempt blocked.");
1072
+ const ownerLossRoot = join(root, "owner-loss-report-recovery");
1073
+ const ownerLossRun = join(ownerLossRoot, ".pi", "herdr-subagents", "runs", "run");
1074
+ const ownerLossTask = join(ownerLossRun, "tasks", "01-task");
1075
+ const ownerLossAttempt = "44444444-4444-4444-8444-444444444444";
1076
+ mkdirSync(join(ownerLossTask, "reports"), { recursive: true });
1077
+ writeFileSync(join(ownerLossRun, "run.json"), JSON.stringify({ id: "owner-loss", label: "owner-loss", status: "running", ownerSessionId: "dead-owner", ownerProcessId: 99999999, tabId: "w1:t-owner-loss", startedAt: new Date().toISOString() }));
1078
+ writeFileSync(join(ownerLossTask, "task.json"), JSON.stringify({ id: "owner-loss-task", runId: "owner-loss", order: 1, status: "running", paneId: "w1:p-owner-loss", currentAttemptId: ownerLossAttempt, sessionFile: join(ownerLossTask, "session.jsonl") }));
1079
+ writeFileSync(join(ownerLossTask, "reports", `${ownerLossAttempt}.json`), JSON.stringify({ attemptId: ownerLossAttempt, status: "completed", summary: "Completed before owner loss.", documents: [] }));
1080
+ await reconcileSubagentRuns(ownerLossRoot, fakeHerdr(), { sessionId: "new-owner", processId: process.pid });
1081
+ 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);
983
1082
  const malformedRoot = join(root, "malformed-recovery");
984
1083
  const malformedRun = join(malformedRoot, ".pi", "herdr-subagents", "runs", "run");
985
1084
  const malformedTask = join(malformedRun, "tasks", "01-task");
@@ -989,7 +1088,7 @@ try {
989
1088
  writeFileSync(join(malformedTask, "report.json"), "{truncated");
990
1089
  const malformedHerdr = fakeHerdr();
991
1090
  const malformedReconciliation = await reconcileSubagentRuns(malformedRoot, malformedHerdr, { sessionId: "new-owner", processId: process.pid });
992
- check("malformed report cannot abort orphan reconciliation", malformedReconciliation.cancelledTasks === 1 && malformedReconciliation.cleanupErrors.some((message) => message.includes("Invalid Subagent report")) && JSON.parse(readFileSync(join(malformedTask, "task.json"), "utf8")).status === "cancelled" && malformedHerdr.calls.some(([name]) => name === "closeTab"));
1091
+ check("malformed report cannot abort orphan reconciliation", malformedReconciliation.settledTasks === 1 && malformedReconciliation.cleanupErrors.some((message) => message.includes("Invalid Subagent report")) && JSON.parse(readFileSync(join(malformedTask, "task.json"), "utf8")).status === "failed" && malformedHerdr.calls.some(([name]) => name === "closeTab"));
993
1092
  const invalidReportRoot = join(root, "invalid-report-recovery");
994
1093
  const invalidReportRun = join(invalidReportRoot, ".pi", "herdr-subagents", "runs", "run");
995
1094
  const invalidReportTask = join(invalidReportRun, "tasks", "01-task");
@@ -22,6 +22,8 @@ URL="$(
22
22
  设置来自附件列表的目标文件名,并完整执行下载命令:
23
23
 
24
24
  ```bash
25
+ set -euo pipefail
26
+
25
27
  OUTPUT='<附件文件名>'
26
28
  PART="${OUTPUT}.part"
27
29
  if [ -e "$OUTPUT" ] || [ -e "$PART" ]; then
@@ -43,4 +45,4 @@ test -s "$PART"
43
45
  5. 按格式做完整性检查:ZIP 使用 `unzip -t "$PART"`,图片使用环境可用的格式识别/解码能力,其他格式使用对应检查。
44
46
  6. 全部检查通过后执行 `mv -- "$PART" "$OUTPUT"`。目标文件或 `.part` 已存在时必须在下载前失败,避免把旧文件误认为本次结果;任何检查失败都不得创建最终文件。
45
47
 
46
- 本流程不续传失败的 `.part`;始终用新 URL 从头下载。临时 URL 过期仅表示需要重新获取链接,不能据此判断 TAPD 原附件损坏。
48
+ 本流程不续传失败的 `.part`。附件大小未知或大于 50 MB 时,工具超时至少设为 600 秒;timeout 仅表示本地执行被终止。删除残留 `.part`,重新获取临时 URL 后从头下载;临时 URL 过期不能说明原附件损坏。