@maxiaochao/pi-toolkit 0.10.21 → 0.10.23
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 +10 -0
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +22 -0
- package/packages/herdr-subagents/README.md +16 -8
- package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
- package/packages/herdr-subagents/TESTING.md +13 -3
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +4 -17
- package/packages/herdr-subagents/extensions/subagent-report.ts +2 -2
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +56 -15
- package/packages/herdr-subagents/src/blocking.ts +115 -72
- package/packages/herdr-subagents/src/continue-run.ts +22 -7
- package/packages/herdr-subagents/src/fleet.ts +2 -1
- package/packages/herdr-subagents/src/herdr.ts +10 -1
- package/packages/herdr-subagents/src/historical-resume.ts +184 -0
- package/packages/herdr-subagents/src/history.ts +5 -131
- package/packages/herdr-subagents/src/live-agent.ts +99 -35
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/protocol.ts +3 -1
- package/packages/herdr-subagents/src/reconcile.ts +156 -135
- package/packages/herdr-subagents/src/records.ts +50 -32
- package/packages/herdr-subagents/src/resume-state.ts +46 -0
- package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
- package/packages/herdr-subagents/src/run-state.ts +120 -0
- package/packages/herdr-subagents/src/status.ts +0 -6
- package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
- package/packages/herdr-subagents/src/tool.ts +1 -54
- package/packages/herdr-subagents/src/validation.ts +1 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +4 -4
- package/packages/herdr-subagents/tests/run-tests.mjs +579 -60
- package/packages/herdr-subagents/src/runner.ts +0 -234
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { mkdir,
|
|
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 {
|
|
12
|
-
import {
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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 =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
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 (
|
|
149
|
+
if (isContinuable(candidate, taskNumber)) { taskDirectory = directory; task = candidate; break; }
|
|
133
150
|
}
|
|
134
|
-
if (!taskDirectory || !task) throw
|
|
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 =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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:
|
|
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([
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
await
|
|
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
|
|
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
|
+
}
|