@maxiaochao/pi-toolkit 0.10.21 → 0.10.22

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/packages/herdr-subagents/CHANGELOG.md +19 -0
  4. package/packages/herdr-subagents/README.md +16 -8
  5. package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
  6. package/packages/herdr-subagents/TESTING.md +13 -3
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +4 -17
  8. package/packages/herdr-subagents/extensions/subagent-report.ts +2 -2
  9. package/packages/herdr-subagents/package.json +1 -1
  10. package/packages/herdr-subagents/src/batch-runner.ts +56 -15
  11. package/packages/herdr-subagents/src/blocking.ts +115 -72
  12. package/packages/herdr-subagents/src/continue-run.ts +22 -7
  13. package/packages/herdr-subagents/src/fleet.ts +2 -1
  14. package/packages/herdr-subagents/src/herdr.ts +10 -1
  15. package/packages/herdr-subagents/src/historical-resume.ts +184 -0
  16. package/packages/herdr-subagents/src/history.ts +5 -131
  17. package/packages/herdr-subagents/src/live-agent.ts +99 -35
  18. package/packages/herdr-subagents/src/parameters.ts +2 -2
  19. package/packages/herdr-subagents/src/protocol.ts +3 -1
  20. package/packages/herdr-subagents/src/reconcile.ts +156 -135
  21. package/packages/herdr-subagents/src/records.ts +50 -32
  22. package/packages/herdr-subagents/src/resume-state.ts +46 -0
  23. package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
  24. package/packages/herdr-subagents/src/run-state.ts +120 -0
  25. package/packages/herdr-subagents/src/status.ts +0 -6
  26. package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
  27. package/packages/herdr-subagents/src/tool.ts +1 -54
  28. package/packages/herdr-subagents/src/validation.ts +1 -1
  29. package/packages/herdr-subagents/tests/extension-contract.ts +4 -4
  30. package/packages/herdr-subagents/tests/run-tests.mjs +556 -60
  31. package/packages/herdr-subagents/src/runner.ts +0 -234
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
- import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
- import { randomUUID } from "node:crypto";
2
+ import { mkdir, rename } from "node:fs/promises";
4
3
  import { join } from "node:path";
5
4
  import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
6
5
  import { continueQueuedRun } from "./continue-run.ts";
@@ -8,20 +7,23 @@ import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
8
7
  import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
9
8
  import { cleanupRunTab } from "./tab-cleanup.ts";
10
9
  import type { OwnerIdentity } from "./ownership.ts";
11
- import { isProcessAlive, ownerRecord } from "./ownership.ts";
12
- import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
10
+ import { ownerRecord } from "./ownership.ts";
11
+ import { applyRunStatus } from "./run-status.ts";
13
12
  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";
14
13
  import { isAbortError } from "./errors.ts";
15
14
  import { continuationSystemPrompt } from "./protocol.ts";
16
15
  import { RunPaneAllocator } from "./run-pane-allocator.ts";
17
16
  import { findRunDirectory } from "./run-locator.ts";
17
+ import { acquireDirectoryLock, withRunStateLock } from "./run-state.ts";
18
+ import { createAttemptId } from "./attempts.ts";
19
+ import { aggregateTasksWithResume, claimResumeCleanup } from "./resume-state.ts";
18
20
 
19
- export interface RespondToBlockedTaskOptions { herdr?: HerdrAutomation; taskNumber?: number; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; onWarning?: (message: string) => void; onStalled?: (message: string) => void; onUpdate?: (result: ResumeBlockedResult) => void; }
21
+ export interface RespondToBlockedTaskOptions { herdr?: HerdrAutomation; taskNumber: number; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; onWarning?: (message: string) => void; onStalled?: (message: string) => void; onUpdate?: (result: ResumeBlockedResult) => void; }
20
22
  export interface ResumeBlockedResult {
21
23
  ok: boolean;
22
24
  runId: string;
23
25
  label: string;
24
- status: "blocked" | "completed" | "partial" | "failed" | "cancelled";
26
+ status: "running" | "blocked" | "completed" | "partial" | "failed" | "cancelled";
25
27
  summary: string;
26
28
  question?: string;
27
29
  documents: PersistedDocument[];
@@ -33,34 +35,18 @@ export interface ResumeBlockedResult {
33
35
  tabId?: string;
34
36
  }
35
37
 
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; }
38
+ /**
39
+ * A blocked task still owns a live child waiting for an answer. A settled failed task can only
40
+ * continue from its saved Pi session.
41
+ */
42
+ function isContinuable(task: TaskRecord, taskNumber: number): boolean {
43
+ if (task.order !== taskNumber) return false;
44
+ if (task.status === "blocked") return true;
45
+ return task.status === "failed" && existsSync(task.sessionFile ?? "");
49
46
  }
50
47
 
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
- }
48
+ function noContinuableTaskError(runId: string, taskNumber: number): Error {
49
+ return new Error(`Run '${runId}' task ${taskNumber} is neither blocked nor a failed task with a saved Pi session.`);
64
50
  }
65
51
 
66
52
  async function emitProgress(options: RespondToBlockedTaskOptions, runDirectory: string, run: RunRecord, taskDirectories: string[], summary: string): Promise<void> {
@@ -82,7 +68,26 @@ async function emitProgress(options: RespondToBlockedTaskOptions, runDirectory:
82
68
  }
83
69
 
84
70
  async function projectSettledRun(runDirectory: string, runFile: string, run: RunRecord, taskDirectories: string[], herdr: HerdrAutomation, onCleanupError?: (message: string) => void): Promise<ResumeBlockedResult> {
85
- const finalTasks = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
71
+ const settledState = await withRunStateLock(runFile, undefined, async (latest) => {
72
+ const tasks = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
73
+ if (latest.resume) {
74
+ for (const task of tasks) {
75
+ const member = latest.resume.active[task.id];
76
+ if (!member || member.attemptId !== task.currentAttemptId) continue;
77
+ if (task.status === "blocked") member.phase = "blocked";
78
+ else if (!isActiveStatus(task.status)) delete latest.resume.active[task.id];
79
+ }
80
+ }
81
+ const aggregated = aggregateTasksWithResume(tasks, latest.resume?.active);
82
+ const blockedTask = tasks.find((task) => task.status === "blocked");
83
+ // Queued work aggregates as blocked; with no blocked task the run is simply still running.
84
+ const status = aggregated === "blocked" && !blockedTask ? "running" : aggregated;
85
+ applyRunStatus(latest, status, { question: blockedTask?.question });
86
+ const cleanup = status === "running" || status === "blocked" ? undefined : claimResumeCleanup(latest)
87
+ ?? (!latest.resume && latest.tabId ? { tabId: latest.tabId, generation: undefined } : undefined);
88
+ return { run: latest, tasks, status, cleanup };
89
+ });
90
+ let finalTasks = settledState.tasks;
86
91
  const finalReports = await Promise.all(taskDirectories.map(async (directory, index) => {
87
92
  const reportFile = join(directory, "report.json");
88
93
  if (!existsSync(reportFile) && (finalTasks[index].status === "queued" || finalTasks[index].status === "cancelled")) return { status: "failed" as const, summary: finalTasks[index].error ?? (finalTasks[index].status === "queued" ? "Task is still queued." : "Task was cancelled."), documents: [] };
@@ -95,15 +100,17 @@ async function projectSettledRun(runDirectory: string, runFile: string, run: Run
95
100
  return failedReport;
96
101
  }
97
102
  }));
98
- const aggregateStatus = aggregateTaskStatus(finalTasks.map((entry) => entry.status));
99
- const runStatus = aggregateStatus === "running" ? "blocked" : aggregateStatus;
103
+ const runStatus = settledState.status;
100
104
  const blockedIndex = finalTasks.findIndex((entry) => entry.status === "blocked");
101
105
  const blockedReport = blockedIndex >= 0 ? finalReports[blockedIndex] : undefined;
102
- run = await readRunRecord(runFile);
103
- applyRunStatus(run, runStatus, { question: blockedReport?.question });
104
- await writeJsonAtomic(runFile, run);
105
- if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError });
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");
106
+ run = settledState.run;
107
+ if (settledState.cleanup) await cleanupRunTab({
108
+ herdr, tabId: settledState.cleanup.tabId, runFile, runRecord: run, onError: onCleanupError,
109
+ onClosed: (latest) => {
110
+ if (settledState.cleanup?.generation !== undefined && latest.resume?.generation === settledState.cleanup.generation && latest.resume.phase === "closing") delete latest.resume;
111
+ },
112
+ });
113
+ const summary = runStatus === "completed" ? "All tasks completed after the answer." : runStatus === "running" ? "Other resumed tasks are still running." : runStatus === "blocked" ? `${finalTasks[blockedIndex]?.name ?? "Task"} needs input: ${blockedReport?.question ?? "Input required."}` : finalReports.map((entry, index) => `${finalTasks[index].name}: ${entry.summary}`).join("\n");
107
114
  const activity = finalTasks.map((entry, index) => projectTaskActivity(entry, index));
108
115
  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 ?? "") }));
109
116
  return { ok: runStatus === "completed", runId: run.id, label: String(run.label ?? "batch"), status: runStatus, summary, ...(blockedReport?.question ? { question: blockedReport.question } : {}), documents: finalReports.flatMap((entry) => entry.documents ?? []), recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? run.effectiveConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), tasks, activity, ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
@@ -113,10 +120,20 @@ export async function respondToBlockedTask(cwd: string, runId: string, answer: s
113
120
  const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
114
121
  const runDirectory = await findRunDirectory(runsDirectory, runId);
115
122
  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);
123
+ const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
124
+ let blockedTaskDirectory: string | undefined;
125
+ for (const directory of taskDirectories) {
126
+ const task = await readTaskRecord(join(directory, "task.json"));
127
+ if (isContinuable(task, options.taskNumber)) { blockedTaskDirectory = directory; break; }
128
+ }
129
+ if (!blockedTaskDirectory) throw noContinuableTaskError(runId, options.taskNumber);
130
+ const taskLock = await acquireDirectoryLock(join(blockedTaskDirectory, ".resume.lock"), {
131
+ wait: false,
132
+ signal: options.signal,
133
+ contentionMessage: `Run '${runId}' already has a response or resume in progress for that task.`,
134
+ });
118
135
  try { return await respondToBlockedTaskLocked(cwd, runId, answer, runDirectory, options); }
119
- finally { await releaseResponseLock(lockFile, lockToken); }
136
+ finally { await taskLock.release(); }
120
137
  }
121
138
 
122
139
  async function respondToBlockedTaskLocked(cwd: string, runId: string, answer: string, runDirectory: string, options: RespondToBlockedTaskOptions): Promise<ResumeBlockedResult> {
@@ -129,28 +146,53 @@ async function respondToBlockedTaskLocked(cwd: string, runId: string, answer: st
129
146
  let task: TaskRecord | undefined;
130
147
  for (const directory of taskDirectories) {
131
148
  const candidate = await readTaskRecord(join(directory, "task.json"));
132
- if (candidate.status === "blocked" && (!taskNumber || candidate.order === taskNumber)) { taskDirectory = directory; task = candidate; break; }
149
+ if (isContinuable(candidate, taskNumber)) { taskDirectory = directory; task = candidate; break; }
133
150
  }
134
- if (!taskDirectory || !task) throw new Error(`Run '${runId}' has no blocked task.`);
151
+ if (!taskDirectory || !task) throw noContinuableTaskError(runId, taskNumber);
135
152
  Object.assign(run, ownerRecord(options.owner));
153
+ const respondingToBlocked = task.status === "blocked";
154
+ const historicalResume = Boolean(run.resume?.active[task.id]);
155
+ const responseAttemptId = createAttemptId();
136
156
  const previousReport = await readReport(join(taskDirectory, "report.json"));
137
157
  const turnsDirectory = join(taskDirectory, "turns");
138
158
  await mkdir(turnsDirectory, { recursive: true, mode: 0o700 });
139
159
  const resumePrompt = join(taskDirectory, "resume-system-prompt.md");
140
160
  await writeTextAtomic(resumePrompt, `${continuationSystemPrompt(task.systemPrompt, "Continue from the supplied answer.", "resumed")}\n`);
161
+ const taskFile = join(taskDirectory, "task.json");
162
+ const claimedFrom: TaskRecord = structuredClone(task);
163
+ // Claim the task before any pane work. A settling batch or a sibling continuation decides tab
164
+ // cleanup from the task records it reads under this lock, so an unclaimed continuation can have
165
+ // the tab it is about to use closed underneath it.
166
+ applyTaskStatus(task, "running");
167
+ await withRunStateLock(runFile, signal, async (latest) => {
168
+ await writeJsonAtomic(taskFile, task);
169
+ Object.assign(latest, ownerRecord(options.owner));
170
+ applyRunStatus(latest, "running");
171
+ });
141
172
  let paneId = String(task.paneId ?? "");
142
173
  let agentName = String(task.agentName ?? liveAgentName(task.id));
143
- let live = Boolean(paneId) && await herdr.paneExists(paneId, signal) && await herdr.isTaskRunning(paneId, signal);
144
- if (!live) {
145
- const paneAllocator = new RunPaneAllocator({ herdr, run, runFile, signal, validateExistingPane: true, onCleanupError });
146
- paneId = await paneAllocator.allocate(taskDirectory);
147
- agentName = liveAgentName(task.id, Number(task.attempt ?? 1) + 1);
148
- task.attempt = Number(task.attempt ?? 1) + 1;
149
- await herdr.renamePane(paneId, `${String(task.order).padStart(2, "0")} · ${task.name ?? task.agent}`, signal);
174
+ let live = false;
175
+ try {
176
+ live = Boolean(paneId) && await herdr.paneExists(paneId, signal) && await herdr.isTaskRunning(paneId, signal);
177
+ if (!live) {
178
+ const paneAllocator = new RunPaneAllocator({ herdr, run, runFile, signal, validateExistingPane: true, onCleanupError });
179
+ paneId = await paneAllocator.allocate(taskDirectory);
180
+ agentName = liveAgentName(task.id, Number(task.attempt ?? 1) + 1);
181
+ task.attempt = Number(task.attempt ?? 1) + 1;
182
+ await herdr.renamePane(paneId, `${String(task.order).padStart(2, "0")} · ${task.name ?? task.agent}`, signal);
183
+ }
184
+ } catch (error) {
185
+ // Nothing was prompted, so release the claim exactly instead of inventing a new outcome.
186
+ await withRunStateLock(runFile, signal, async (latest) => {
187
+ await writeJsonAtomic(taskFile, claimedFrom);
188
+ const siblings = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
189
+ applyRunStatus(latest, aggregateTasksWithResume(siblings, latest.resume?.active), { question: claimedFrom.question });
190
+ });
191
+ throw error;
150
192
  }
151
193
  const attempt = Number(task.attempt ?? 1);
152
194
  const turnFile = live && task.currentTurnFile ? String(task.currentTurnFile) : join(turnsDirectory, `${String(attempt).padStart(2, "0")}.json`);
153
- let turn: TurnRecord = { attempt, prompt: task.prompt, status: "blocked", summary: previousReport.summary, question: previousReport.question, documents: previousReport.documents ?? [], sessionFile: task.sessionFile, startedAt: task.startedAt };
195
+ let turn: TurnRecord = { attempt, prompt: task.prompt, status: task.status, summary: previousReport.summary, question: previousReport.question, documents: previousReport.documents ?? [], sessionFile: task.sessionFile, startedAt: task.startedAt };
154
196
  if (existsSync(turnFile)) {
155
197
  try { turn = await readTurnRecord(turnFile); }
156
198
  catch (error) {
@@ -163,13 +205,24 @@ async function respondToBlockedTaskLocked(cwd: string, runId: string, answer: st
163
205
  task.currentTurnFile = turnFile;
164
206
  applyTaskStatus(task, "running");
165
207
  Object.assign(task, { tabId: run.tabId, paneId, agentName });
166
- await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
208
+ await Promise.all([
209
+ writeJsonAtomic(join(taskDirectory, "task.json"), task),
210
+ writeJsonAtomic(turnFile, turn),
211
+ withRunStateLock(runFile, signal, (latest) => {
212
+ Object.assign(latest, ownerRecord(options.owner));
213
+ const member = latest.resume?.active[task.id];
214
+ if (member) { member.attemptId = responseAttemptId; member.phase = "running"; }
215
+ }),
216
+ ]);
167
217
  await emitProgress(options, runDirectory, run, taskDirectories, `${task.name ?? task.agent} is continuing after input.`);
168
218
  let report: PersistedReport;
169
219
  let status: "blocked" | "completed" | "failed" | "cancelled";
170
220
  let attemptError: unknown;
171
221
  try {
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.`) });
222
+ const prompted = await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: answer, systemPromptFile: resumePrompt, agentName, attemptId: responseAttemptId, start: !live, signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`), onRuntimeUpdate: async (updated) => {
223
+ Object.assign(task, updated);
224
+ await emitProgress(options, runDirectory, run, taskDirectories, `${task.name ?? task.agent} runtime: ${updated.runtimeState}.`);
225
+ } });
173
226
  task.currentAttemptId = prompted.attemptId;
174
227
  report = prompted.report;
175
228
  status = applyReport(task, report);
@@ -186,29 +239,19 @@ async function respondToBlockedTaskLocked(cwd: string, runId: string, answer: st
186
239
  }
187
240
  if (attemptError && isAbortError(attemptError, signal)) {
188
241
  const message = attemptError instanceof Error ? attemptError.message : String(attemptError);
189
- applyTaskStatus(task, "cancelled", { error: message });
190
- applyRunStatus(run, "cancelled", { error: message });
191
- applyTaskStatus(turn, "cancelled", { error: message });
192
- for (const siblingDirectory of taskDirectories) {
193
- if (siblingDirectory === taskDirectory) continue;
194
- const siblingFile = join(siblingDirectory, "task.json");
195
- const sibling = await readTaskRecord(siblingFile);
196
- if (!isActiveStatus(sibling.status)) continue;
197
- applyTaskStatus(sibling, "cancelled", { error: "Parent Pi session closed." });
198
- await writeJsonAtomic(siblingFile, sibling);
199
- }
200
- await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
201
- if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError });
202
- return { ok: false, runId: run.id, label: String(run.label ?? "batch"), status: "cancelled", summary: "Subagent response cancelled.", documents: [], recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
242
+ applyTaskStatus(task, "cancelled", { error: message });
243
+ applyTaskStatus(turn, "cancelled", { error: message });
244
+ await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(turnFile, turn)]);
245
+ return projectSettledRun(runDirectory, runFile, run, taskDirectories, herdr, onCleanupError);
203
246
  }
204
247
  turn.events.push({ type: "report", status, summary: report.summary, question: report.question, error: report.error, at: new Date().toISOString() });
205
248
  Object.assign(turn, { summary: report.summary, documents: report.documents ?? [] });
206
249
  applyReport(turn, report);
207
250
  await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(turnFile, turn)]);
208
251
  await emitProgress(options, runDirectory, run, taskDirectories, `${task.name ?? task.agent} ${status}.`);
209
- if (status !== "blocked") {
210
- applyRunStatus(run, "queued");
211
- await writeJsonAtomic(runFile, run);
252
+ // Only a blocked task halts its batch worker. Continuing a settled failure must not restart
253
+ // queued siblings that the still-running batch already owns.
254
+ if (status !== "blocked" && !historicalResume && respondingToBlocked) {
212
255
  await continueQueuedRun(runDirectory, herdr, { cleanup: false, signal, onStalled: options.onStalled, onCleanupError, onUpdate: () => emitProgress(options, runDirectory, run, taskDirectories, "Queued tasks are continuing.") });
213
256
  }
214
257
  return projectSettledRun(runDirectory, runFile, run, taskDirectories, herdr, onCleanupError);
@@ -6,9 +6,9 @@ import { writeJsonAtomic } from "./state.ts";
6
6
  import { cleanupRunTab } from "./tab-cleanup.ts";
7
7
  import { RunPaneAllocator } from "./run-pane-allocator.ts";
8
8
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
9
- import { applyReport, applyTaskStatus, normalizeNeedsInputReport, readRunRecord, readTaskRecord, settleAttemptFailure } from "./records.ts";
9
+ import { applyReport, applyTaskStatus, readRunRecord, readTaskRecord, settleAttemptFailure } from "./records.ts";
10
+ import { acquireDirectoryLock, adoptRecord, queueLockPath, withRunStateLock, type DirectoryLock } from "./run-state.ts";
10
11
  import { isAbortError } from "./errors.ts";
11
- import { attemptReportPath } from "./attempts.ts";
12
12
 
13
13
  export interface ContinueQueuedRunOptions {
14
14
  cleanup?: boolean;
@@ -20,6 +20,20 @@ export interface ContinueQueuedRunOptions {
20
20
 
21
21
  export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutomation, options: ContinueQueuedRunOptions = {}): Promise<void> {
22
22
  const runFile = join(runDirectory, "run.json");
23
+ // A live batch keeps starting its own queued tasks even after one of its lanes blocks, so take the
24
+ // queue over only when nothing else owns it. Otherwise a task could be started twice.
25
+ const ownedElsewhere = `Run queue at ${runDirectory} is owned by its batch.`;
26
+ let queueLock: DirectoryLock;
27
+ try { queueLock = await acquireDirectoryLock(queueLockPath(runDirectory), { wait: false, signal: options.signal, contentionMessage: ownedElsewhere }); }
28
+ catch (error) {
29
+ if (error instanceof Error && error.message === ownedElsewhere) return;
30
+ throw error;
31
+ }
32
+ try { await continueQueuedTasks(runDirectory, runFile, herdr, options); }
33
+ finally { await queueLock.release(); }
34
+ }
35
+
36
+ async function continueQueuedTasks(runDirectory: string, runFile: string, herdr: HerdrAutomation, options: ContinueQueuedRunOptions): Promise<void> {
23
37
  const run = await readRunRecord(runFile);
24
38
  const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
25
39
  const queuedDirectories: string[] = [];
@@ -51,9 +65,7 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
51
65
  await options.onUpdate?.();
52
66
  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.`) });
53
67
  task.currentAttemptId = prompted.attemptId;
54
- const report = normalizeNeedsInputReport(prompted.report, Number(run.effectiveConcurrency) || 1);
55
- if (report !== prompted.report) await Promise.all([writeJsonAtomic(reportFile, report), writeJsonAtomic(attemptReportPath(directory, prompted.attemptId), report)]);
56
- applyReport(task, report);
68
+ applyReport(task, prompted.report);
57
69
  } catch (error) {
58
70
  await settleAttemptFailure(task, reportFile, {
59
71
  cancelled: isAbortError(error, options.signal),
@@ -83,8 +95,11 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
83
95
  }
84
96
  }
85
97
  const statuses = await Promise.all(taskDirectories.map(async (directory) => (await readTaskRecord(join(directory, "task.json"))).status));
86
- applyRunStatus(run, aggregateTaskStatus(statuses));
87
- await writeJsonAtomic(runFile, run);
98
+ // Settling the run must not be cancellable, so the terminal status is never written under the signal.
99
+ await withRunStateLock(runFile, undefined, (latest) => {
100
+ applyRunStatus(latest, aggregateTaskStatus(statuses));
101
+ adoptRecord(run, latest);
102
+ });
88
103
  if (options.cleanup !== false && run.status !== "blocked" && run.tabId) {
89
104
  await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: options.signal, onError: options.onCleanupError });
90
105
  }
@@ -153,7 +153,8 @@ export function fleetLines(
153
153
  const color = task.status === "completed" ? "success" : task.status === "running" ? "warning" : task.status === "failed" ? "error" : task.status === "blocked" ? "warning" : task.status === "cancelled" ? "dim" : "accent";
154
154
  const marker = chosen ? theme.fg("accent", "›") : " ";
155
155
  const persona = theme.fg("muted", task.agent || "worker");
156
- const left = `${marker} ${theme.fg(color, circle)} ${persona} ${task.name} ${theme.fg("dim", task.status)}`;
156
+ const visibleStatus = task.runtimeState && task.status === "running" ? task.runtimeState : task.status;
157
+ const left = `${marker} ${theme.fg(color, circle)} ${persona} ${task.name} ${theme.fg("dim", visibleStatus)}`;
157
158
  const duration = formatFleetDuration(task, options.now);
158
159
  const tokens = task.status === "queued" || task.status === "starting" ? undefined : options.metrics?.get(task.index)?.tokens;
159
160
  const right = [duration, tokens === undefined ? undefined : formatFleetTokens(tokens)].filter(Boolean).join(" ");
@@ -32,10 +32,13 @@ export interface SplitPaneRequest {
32
32
  signal?: AbortSignal;
33
33
  }
34
34
 
35
+ export type HerdrAgentState = "working" | "blocked" | "idle" | "done" | "unknown";
36
+
35
37
  export interface HerdrAutomation {
36
38
  createTab(request: CreateTabRequest): Promise<{ tabId: string; paneId: string }>;
37
39
  startAgent(request: StartAgentRequest): Promise<void>;
38
40
  promptAgent(request: PromptAgentRequest): Promise<{ status?: string }>;
41
+ getAgentState(target: string, signal?: AbortSignal): Promise<HerdrAgentState>;
39
42
  splitPane(request: SplitPaneRequest): Promise<{ paneId: string }>;
40
43
  renamePane(paneId: string, label: string, signal?: AbortSignal): Promise<void>;
41
44
  renameTab(tabId: string, label: string, signal?: AbortSignal): Promise<void>;
@@ -52,7 +55,7 @@ interface HerdrResponse {
52
55
  tab?: { tab_id?: string; focused?: boolean };
53
56
  root_pane?: { pane_id?: string };
54
57
  pane?: { pane_id?: string };
55
- agent?: { status?: string };
58
+ agent?: { status?: string; agent_status?: string };
56
59
  process_info?: { foreground_processes?: Array<{ name?: string; argv?: string[] }> };
57
60
  };
58
61
  }
@@ -156,6 +159,12 @@ export class CliHerdrAutomation implements HerdrAutomation {
156
159
  return { status: response.result?.agent?.status };
157
160
  }
158
161
 
162
+ async getAgentState(target: string, signal?: AbortSignal): Promise<HerdrAgentState> {
163
+ const response = parseResponse(await runCommand(this.binary, ["agent", "get", target], signal), "herdr agent get");
164
+ const state = response.result?.agent?.agent_status ?? response.result?.agent?.status;
165
+ return state === "working" || state === "blocked" || state === "idle" || state === "done" ? state : "unknown";
166
+ }
167
+
159
168
  async splitPane(request: SplitPaneRequest): Promise<{ paneId: string }> {
160
169
  const args = ["pane", "split", request.paneId, "--direction", request.direction, "--cwd", request.cwd, request.focus ? "--focus" : "--no-focus"];
161
170
  for (const [key, value] of Object.entries(request.env ?? {}).sort(([left], [right]) => left.localeCompare(right))) args.push("--env", `${key}=${value}`);
@@ -0,0 +1,184 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { attemptReportPath, createAttemptId } from "./attempts.ts";
6
+ import { isAbortError } from "./errors.ts";
7
+ import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
8
+ import { childEnvironment, liveAgentName, promptLiveAgent } from "./live-agent.ts";
9
+ import { ownerRecord } from "./ownership.ts";
10
+ import { continuationSystemPrompt } from "./protocol.ts";
11
+ import { applyRunStatus } from "./run-status.ts";
12
+ import { acquireDirectoryLock, withRunStateLock } from "./run-state.ts";
13
+ import { applyReport, applyTaskStatus, isActiveStatus, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, projectTaskActivity, type PersistedReport, type ResumeMember, type TaskRecord } from "./records.ts";
14
+ import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
15
+ import { cleanupRunTab } from "./tab-cleanup.ts";
16
+ import { findRunDirectory } from "./run-locator.ts";
17
+ import type { ResumeHistoricalTaskOptions, ResumeHistoricalTaskResult } from "./history.ts";
18
+ import { aggregateTasksWithResume, claimResumeCleanup, readTaskEntries } from "./resume-state.ts";
19
+
20
+ async function aggregateRun(runDirectory: string, active: Record<string, ResumeMember> = {}) {
21
+ return aggregateTasksWithResume((await readTaskEntries(runDirectory)).map(({ task }) => task), active);
22
+ }
23
+
24
+ export async function resumeHistoricalTaskConcurrent(cwd: string, requestedRunId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<ResumeHistoricalTaskResult> {
25
+ const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), requestedRunId);
26
+ if (!runDirectory) throw new Error(`Unknown Subagent run '${requestedRunId}'.`);
27
+ const runFile = join(runDirectory, "run.json");
28
+ const initialRun = await readRunRecord(runFile);
29
+ const entries = await readTaskEntries(runDirectory);
30
+ const selected = entries.find(({ task }) => task.order === taskNumber);
31
+ if (!selected) throw new Error(`Unknown historical task ${initialRun.id}#${taskNumber}.`);
32
+ const { directory: taskDirectory } = selected;
33
+ const taskFile = join(taskDirectory, "task.json");
34
+ const taskLock = await acquireDirectoryLock(join(taskDirectory, ".resume.lock"), {
35
+ wait: false,
36
+ signal: options.signal,
37
+ contentionMessage: `Historical task ${initialRun.id}#${taskNumber} already has a resume or response in progress.`,
38
+ });
39
+ try {
40
+ return await resumeLocked(cwd, runDirectory, runFile, taskDirectory, taskFile, prompt, options);
41
+ } finally {
42
+ await taskLock.release();
43
+ }
44
+ }
45
+
46
+ async function resumeLocked(cwd: string, runDirectory: string, runFile: string, taskDirectory: string, taskFile: string, prompt: string, options: ResumeHistoricalTaskOptions): Promise<ResumeHistoricalTaskResult> {
47
+ const herdr = options.herdr ?? new CliHerdrAutomation();
48
+ const task = await readTaskRecord(taskFile);
49
+ const initialRun = await readRunRecord(runFile);
50
+ if (!existsSync(task.sessionFile ?? "")) throw new Error(`Historical task ${initialRun.id}#${task.order} has no saved Pi session.`);
51
+ const attempt = Number(task.attempt ?? 1) + 1;
52
+ const attemptId = createAttemptId();
53
+ const ownerToken = randomUUID();
54
+ const agentName = liveAgentName(task.id, attempt);
55
+ const turnsDirectory = join(taskDirectory, "turns");
56
+ await mkdir(turnsDirectory, { recursive: true, mode: 0o700 });
57
+ const initialTurnFile = join(turnsDirectory, "01.json");
58
+ if (!existsSync(initialTurnFile)) {
59
+ let report: PersistedReport;
60
+ try { report = await readReport(join(taskDirectory, "report.json")); }
61
+ catch (error) { report = { status: "failed", summary: `Previous structured report was unavailable: ${error instanceof Error ? error.message : String(error)}`, documents: [] }; }
62
+ await writeJsonAtomic(initialTurnFile, { attempt: 1, prompt: task.prompt, status: task.status, summary: report.summary, question: report.question, error: report.error, documents: report.documents, sessionFile: task.sessionFile, startedAt: task.startedAt, completedAt: task.completedAt });
63
+ }
64
+ const turnFile = join(turnsDirectory, `${String(attempt).padStart(2, "0")}.json`);
65
+ const followupPrompt = join(taskDirectory, "followup-system-prompt.md");
66
+ await writeTextAtomic(followupPrompt, `${continuationSystemPrompt(task.systemPrompt, "Continue the requested historical work.", "followup")}\n`);
67
+ let allocation: { tabId: string; paneId: string; generation: number } | undefined;
68
+ let failed: { status: "cancelled" | "failed"; report: PersistedReport; error: unknown } | undefined;
69
+ const paneLabel = `${String(task.order).padStart(2, "0")} · ${task.name ?? task.agent ?? "worker"}`;
70
+ try {
71
+ allocation = await withRunStateLock(runFile, options.signal, async (run) => {
72
+ if (isActiveStatus(run.status) && (!run.resume || Object.keys(run.resume.active).length === 0)) {
73
+ throw new Error(`Subagent run '${run.id}' is still active. Use action 'respond' for blocked work or wait for it to settle before resuming history.`);
74
+ }
75
+ if (run.resume?.active[task.id]) throw new Error(`Historical task ${run.id}#${task.order} already has an active resume.`);
76
+ const env = childEnvironment(taskDirectory);
77
+ let generation: number;
78
+ let tabId: string;
79
+ let paneId: string;
80
+ let paneIds: string[];
81
+ if (run.resume?.phase === "active") {
82
+ generation = run.resume.generation;
83
+ tabId = run.resume.tabId;
84
+ const anchor = run.resume.paneIds.at(-1);
85
+ if (!anchor) throw new Error(`Resume generation ${generation} has no pane anchor.`);
86
+ paneId = (await herdr.splitPane({ paneId: anchor, cwd, direction: run.resume.paneIds.length % 2 ? "right" : "down", focus: false, env, signal: options.signal })).paneId;
87
+ paneIds = [...run.resume.paneIds, paneId];
88
+ } else {
89
+ generation = Number(run.resume?.generation ?? 0) + 1;
90
+ const created = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd, label: `SA · ${run.label}`, env, focus: false, signal: options.signal });
91
+ tabId = created.tabId;
92
+ paneId = created.paneId;
93
+ paneIds = [paneId];
94
+ }
95
+ const member: ResumeMember = { paneId, generation, agentName, attemptId, ownerToken, phase: "allocating", ...ownerRecord(options.owner) };
96
+ run.resume = { generation, phase: "active", tabId, paneIds, active: { ...(run.resume?.phase === "active" ? run.resume.active : {}), [task.id]: member } };
97
+ applyRunStatus(run, "running");
98
+ Object.assign(run, { tabId, paneIds: [...new Set([...(run.paneIds ?? []), paneId])] });
99
+ return { tabId, paneId, generation };
100
+ });
101
+ await herdr.renamePane(allocation.paneId, paneLabel, options.signal);
102
+ applyTaskStatus(task, "running");
103
+ Object.assign(task, { attempt, currentAttemptId: attemptId, currentTurnFile: turnFile, tabId: allocation.tabId, paneId: allocation.paneId, paneLabel, agentName });
104
+ await Promise.all([
105
+ writeJsonAtomic(taskFile, task),
106
+ writeJsonAtomic(turnFile, { attempt, prompt, status: "running", sessionFile: task.sessionFile, tabId: allocation.tabId, paneId: allocation.paneId, agentName, startedAt: new Date().toISOString() }),
107
+ ]);
108
+ await withRunStateLock(runFile, options.signal, (run) => {
109
+ const member = run.resume?.active[task.id];
110
+ if (member?.attemptId === attemptId && member.generation === allocation?.generation && member.phase === "allocating") member.phase = "running";
111
+ });
112
+ const runningEntries = await readTaskEntries(runDirectory);
113
+ options.onUpdate?.({ runId: initialRun.id, label: initialRun.label, status: "running", summary: `${task.name ?? task.agent} is continuing.`, documents: [], tabId: allocation.tabId, paneId: allocation.paneId, sessionFile: task.sessionFile as string, activity: runningEntries.map(({ task }, index) => projectTaskActivity(task, index)) });
114
+ let report: PersistedReport;
115
+ let status: "blocked" | "completed" | "failed";
116
+ try {
117
+ const prompted = await promptLiveAgent({ herdr, task: { ...task, sessionFile: task.sessionFile as string }, paneId: allocation.paneId, prompt, systemPromptFile: followupPrompt, agentName, attemptId, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`), onRuntimeUpdate: async (updated) => {
118
+ Object.assign(task, updated);
119
+ const liveEntries = await readTaskEntries(runDirectory);
120
+ options.onUpdate?.({ runId: initialRun.id, label: initialRun.label, status: "running", summary: `${task.name ?? task.agent} runtime: ${updated.runtimeState}.`, documents: [], tabId: allocation?.tabId ?? "", paneId: allocation?.paneId ?? "", sessionFile: task.sessionFile as string, activity: liveEntries.map(({ task }, index) => projectTaskActivity(task, index)) });
121
+ } });
122
+ report = prompted.report;
123
+ status = applyReport(task, report);
124
+ } catch (error) {
125
+ const settled = await settleAttemptFailure(task, join(taskDirectory, "report.json"), { cancelled: isAbortError(error, options.signal), error: error instanceof Error ? error.message : String(error), name: `${task.name ?? task.agent} follow-up`, replaceReport: true });
126
+ failed = { ...settled, error };
127
+ throw error;
128
+ }
129
+ await Promise.all([
130
+ writeJsonAtomic(taskFile, task),
131
+ writeJsonAtomic(turnFile, { attempt, prompt, status, summary: report.summary, documents: report.documents, sessionFile: task.sessionFile, tabId: allocation.tabId, paneId: allocation.paneId, agentName, completedAt: new Date().toISOString(), ...(report.question ? { question: report.question } : {}), ...(report.error ? { error: report.error } : {}) }),
132
+ ]);
133
+ const cleanup = await settleMember(runDirectory, runFile, task.id, attemptId, allocation.generation, status, report.question, options);
134
+ if (cleanup) await closeClaimedTab(herdr, runFile, cleanup, options);
135
+ const finalEntries = await readTaskEntries(runDirectory);
136
+ return { runId: initialRun.id, label: initialRun.label, status, summary: report.summary, documents: report.documents, tabId: allocation.tabId, paneId: allocation.paneId, sessionFile: task.sessionFile as string, activity: finalEntries.map(({ task }, index) => projectTaskActivity(task, index)) };
137
+ } catch (error) {
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ if (!failed) {
140
+ const settled = await settleAttemptFailure(task, join(taskDirectory, "report.json"), { cancelled: isAbortError(error, options.signal), error: message, name: `${task.name ?? task.agent} follow-up`, replaceReport: true });
141
+ failed = { ...settled, error };
142
+ }
143
+ task.attempt = attempt;
144
+ await Promise.all([
145
+ writeJsonAtomic(taskFile, task),
146
+ writeJsonAtomic(turnFile, { attempt, prompt, status: failed.status, summary: failed.report.summary, documents: failed.report.documents, error: message, sessionFile: task.sessionFile, ...(allocation ? { tabId: allocation.tabId, paneId: allocation.paneId } : {}), completedAt: new Date().toISOString() }),
147
+ ]);
148
+ let cleanup: { tabId: string; generation: number } | undefined;
149
+ if (allocation) cleanup = await settleMember(runDirectory, runFile, task.id, attemptId, allocation.generation, failed.status, undefined, options);
150
+ else await withRunStateLock(runFile, options.signal, async (run) => applyRunStatus(run, await aggregateRun(runDirectory), { error: message }));
151
+ if (cleanup) await closeClaimedTab(herdr, runFile, cleanup, options);
152
+ const finalEntries = await readTaskEntries(runDirectory);
153
+ options.onUpdate?.({ runId: initialRun.id, label: initialRun.label, status: failed.status, summary: failed.report.summary, documents: failed.report.documents, tabId: allocation?.tabId ?? "", paneId: allocation?.paneId ?? "", sessionFile: task.sessionFile as string, activity: finalEntries.map(({ task }, index) => projectTaskActivity(task, index)) });
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ async function settleMember(runDirectory: string, runFile: string, taskId: string, attemptId: string, generation: number, status: "blocked" | "completed" | "failed" | "cancelled", question: string | undefined, options: ResumeHistoricalTaskOptions): Promise<{ tabId: string; generation: number } | undefined> {
159
+ return withRunStateLock(runFile, options.signal, async (run) => {
160
+ const member = run.resume?.active[taskId];
161
+ if (!member || member.attemptId !== attemptId || member.generation !== generation) return undefined;
162
+ if (status === "blocked") member.phase = "blocked";
163
+ else if (run.resume) delete run.resume.active[taskId];
164
+ const aggregate = await aggregateRun(runDirectory, run.resume?.active);
165
+ applyRunStatus(run, aggregate, { question });
166
+ if (status === "blocked") return undefined;
167
+ return claimResumeCleanup(run);
168
+ });
169
+ }
170
+
171
+ async function closeClaimedTab(herdr: HerdrAutomation, runFile: string, claim: { tabId: string; generation: number }, options: ResumeHistoricalTaskOptions): Promise<void> {
172
+ const snapshot = await readRunRecord(runFile);
173
+ await cleanupRunTab({
174
+ herdr,
175
+ tabId: claim.tabId,
176
+ runFile,
177
+ runRecord: snapshot,
178
+ signal: options.signal,
179
+ onError: options.onCleanupError,
180
+ onClosed: (latest) => {
181
+ if (latest.resume?.generation === claim.generation && latest.resume.phase === "closing") delete latest.resume;
182
+ },
183
+ });
184
+ }