@maxiaochao/pi-toolkit 0.10.14 → 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,16 @@
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
+
10
+ ## 2026-10-09 - v0.10.15
11
+
12
+ - redesign the TAPD skill around executable workflows, explicit workspace and JSON contracts, and safe attachment handling
13
+
1
14
  ## 2026-10-09 - v0.10.14
2
15
 
3
16
  - bundle worktree v0.3.2: continue Agent tasks automatically after switching into a worktree
package/README.md CHANGED
@@ -87,7 +87,7 @@ bash scripts/install.sh
87
87
  | `pdlog` | Decompress and inspect Pudu `.pdlog` files |
88
88
  | `herdr` | Control Herdr workspaces, tabs, panes, and agents |
89
89
  | `agent-team` | Run tool-agnostic, cross-vendor adversarial review with independent concurrent agents |
90
- | `tapd` | Use the TAPD CLI for requirements, defects, tasks, and wikis |
90
+ | `tapd` | Investigate and operate on TAPD items with workspace, evidence, and safe-download guardrails |
91
91
 
92
92
  Package-specific skills, such as `pi-worktree`, are documented by their child package.
93
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.14",
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
  }