@maxiaochao/pi-toolkit 0.10.20 → 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 (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. package/packages/herdr-subagents/CHANGELOG.md +26 -0
  4. package/packages/herdr-subagents/README.md +21 -7
  5. package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
  6. package/packages/herdr-subagents/TESTING.md +15 -2
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +15 -21
  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 +58 -11
  11. package/packages/herdr-subagents/src/blocking.ts +115 -72
  12. package/packages/herdr-subagents/src/continue-run.ts +21 -4
  13. package/packages/herdr-subagents/src/dispatcher.ts +44 -4
  14. package/packages/herdr-subagents/src/fleet.ts +2 -1
  15. package/packages/herdr-subagents/src/herdr.ts +10 -1
  16. package/packages/herdr-subagents/src/historical-resume.ts +184 -0
  17. package/packages/herdr-subagents/src/history.ts +11 -128
  18. package/packages/herdr-subagents/src/live-agent.ts +99 -35
  19. package/packages/herdr-subagents/src/parameters.ts +2 -2
  20. package/packages/herdr-subagents/src/protocol.ts +3 -1
  21. package/packages/herdr-subagents/src/reconcile.ts +156 -123
  22. package/packages/herdr-subagents/src/records.ts +50 -6
  23. package/packages/herdr-subagents/src/resume-state.ts +46 -0
  24. package/packages/herdr-subagents/src/run-locator.ts +17 -2
  25. package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
  26. package/packages/herdr-subagents/src/run-state.ts +120 -0
  27. package/packages/herdr-subagents/src/status.ts +8 -3
  28. package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
  29. package/packages/herdr-subagents/src/tool.ts +14 -62
  30. package/packages/herdr-subagents/src/validation.ts +1 -1
  31. package/packages/herdr-subagents/tests/extension-contract.ts +78 -3
  32. package/packages/herdr-subagents/tests/run-tests.mjs +608 -64
  33. 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, 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);
@@ -7,6 +7,7 @@ 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
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
12
 
12
13
  export interface ContinueQueuedRunOptions {
@@ -19,6 +20,20 @@ export interface ContinueQueuedRunOptions {
19
20
 
20
21
  export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutomation, options: ContinueQueuedRunOptions = {}): Promise<void> {
21
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> {
22
37
  const run = await readRunRecord(runFile);
23
38
  const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
24
39
  const queuedDirectories: string[] = [];
@@ -50,8 +65,7 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
50
65
  await options.onUpdate?.();
51
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.`) });
52
67
  task.currentAttemptId = prompted.attemptId;
53
- const report = prompted.report;
54
- applyReport(task, report);
68
+ applyReport(task, prompted.report);
55
69
  } catch (error) {
56
70
  await settleAttemptFailure(task, reportFile, {
57
71
  cancelled: isAbortError(error, options.signal),
@@ -81,8 +95,11 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
81
95
  }
82
96
  }
83
97
  const statuses = await Promise.all(taskDirectories.map(async (directory) => (await readTaskRecord(join(directory, "task.json"))).status));
84
- applyRunStatus(run, aggregateTaskStatus(statuses));
85
- 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
+ });
86
103
  if (options.cleanup !== false && run.status !== "blocked" && run.tabId) {
87
104
  await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: options.signal, onError: options.onCleanupError });
88
105
  }
@@ -3,10 +3,19 @@ interface QueuedRun<T> {
3
3
  execute: () => Promise<T>;
4
4
  resolve: (value: T) => void;
5
5
  reject: (error: unknown) => void;
6
+ signal?: AbortSignal;
7
+ onAbort?: () => void;
8
+ onRetained?: (retainedRunId: string) => T;
9
+ }
10
+
11
+ interface SubmitOptions<T> {
12
+ signal?: AbortSignal;
13
+ onRetained?: (retainedRunId: string) => T;
6
14
  }
7
15
 
8
16
  export interface DispatcherSnapshot {
9
17
  activeRunId?: string;
18
+ retainedRunId?: string;
10
19
  queuedRunIds: string[];
11
20
  }
12
21
 
@@ -19,15 +28,33 @@ export class RunDispatcher {
19
28
 
20
29
  constructor(onChange?: (snapshot: DispatcherSnapshot) => void) { this.onChange = onChange; }
21
30
 
22
- submit<T>(id: string, execute: () => Promise<T>): Promise<T> {
23
- const promise = new Promise<T>((resolve, reject) => this.queue.push({ id, execute, resolve: resolve as (value: unknown) => void, reject }));
31
+ submit<T>(id: string, execute: () => Promise<T>, options: SubmitOptions<T> = {}): Promise<T> {
32
+ const { signal, onRetained } = options;
33
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error(`Queued run '${id}' was cancelled.`));
34
+ if (this.retainedRunId && onRetained) return Promise.resolve(onRetained(this.retainedRunId));
35
+ const promise = new Promise<T>((resolve, reject) => {
36
+ const queued: QueuedRun<T> = { id, execute, resolve, reject, signal, onRetained };
37
+ queued.onAbort = () => {
38
+ const index = this.queue.indexOf(queued as QueuedRun<unknown>);
39
+ if (index < 0) return;
40
+ this.queue.splice(index, 1);
41
+ reject(signal?.reason ?? new Error(`Queued run '${id}' was cancelled.`));
42
+ this.changed();
43
+ };
44
+ signal?.addEventListener("abort", queued.onAbort, { once: true });
45
+ this.queue.push(queued as QueuedRun<unknown>);
46
+ });
24
47
  this.changed();
25
48
  void this.drain();
26
49
  return promise;
27
50
  }
28
51
 
29
52
  snapshot(): DispatcherSnapshot {
30
- return { ...(this.activeRunId ? { activeRunId: this.activeRunId } : {}), queuedRunIds: this.queue.map((run) => run.id) };
53
+ return {
54
+ ...(this.activeRunId ? { activeRunId: this.activeRunId } : {}),
55
+ ...(this.retainedRunId ? { retainedRunId: this.retainedRunId } : {}),
56
+ queuedRunIds: this.queue.map((run) => run.id),
57
+ };
31
58
  }
32
59
 
33
60
  pause(): void { this.paused = true; this.changed(); }
@@ -35,6 +62,14 @@ export class RunDispatcher {
35
62
  retain(id: string): void {
36
63
  if (this.activeRunId !== id) throw new Error(`Cannot retain inactive run '${id}'.`);
37
64
  this.retainedRunId = id;
65
+ for (let index = this.queue.length - 1; index >= 0; index -= 1) {
66
+ const queued = this.queue[index];
67
+ if (!queued.onRetained) continue;
68
+ this.queue.splice(index, 1);
69
+ if (queued.onAbort) queued.signal?.removeEventListener("abort", queued.onAbort);
70
+ try { queued.resolve(queued.onRetained(id)); }
71
+ catch (error) { queued.reject(error); }
72
+ }
38
73
  this.changed();
39
74
  }
40
75
  detach(id: string): boolean {
@@ -54,7 +89,10 @@ export class RunDispatcher {
54
89
  }
55
90
  cancelQueued(reason: Error): string[] {
56
91
  const cancelled = this.queue.splice(0);
57
- for (const run of cancelled) run.reject(reason);
92
+ for (const run of cancelled) {
93
+ if (run.onAbort) run.signal?.removeEventListener("abort", run.onAbort);
94
+ run.reject(reason);
95
+ }
58
96
  this.changed();
59
97
  return cancelled.map((run) => run.id);
60
98
  }
@@ -62,6 +100,7 @@ export class RunDispatcher {
62
100
  const index = this.queue.findIndex((run) => run.id === id);
63
101
  if (index < 0) return false;
64
102
  const [cancelled] = this.queue.splice(index, 1);
103
+ if (cancelled.onAbort) cancelled.signal?.removeEventListener("abort", cancelled.onAbort);
65
104
  cancelled.reject(reason);
66
105
  this.changed();
67
106
  return true;
@@ -73,6 +112,7 @@ export class RunDispatcher {
73
112
  if (this.activeRunId || this.paused) return;
74
113
  const run = this.queue.shift();
75
114
  if (!run) { this.changed(); return; }
115
+ if (run.onAbort) run.signal?.removeEventListener("abort", run.onAbort);
76
116
  this.activeRunId = run.id;
77
117
  this.changed();
78
118
  try { run.resolve(await run.execute()); }
@@ -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}`);