@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.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +26 -0
- package/packages/herdr-subagents/README.md +21 -7
- package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
- package/packages/herdr-subagents/TESTING.md +15 -2
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +15 -21
- 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 +58 -11
- package/packages/herdr-subagents/src/blocking.ts +115 -72
- package/packages/herdr-subagents/src/continue-run.ts +21 -4
- package/packages/herdr-subagents/src/dispatcher.ts +44 -4
- 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 +11 -128
- 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 -123
- package/packages/herdr-subagents/src/records.ts +50 -6
- package/packages/herdr-subagents/src/resume-state.ts +46 -0
- package/packages/herdr-subagents/src/run-locator.ts +17 -2
- 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 +8 -3
- package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
- package/packages/herdr-subagents/src/tool.ts +14 -62
- package/packages/herdr-subagents/src/validation.ts +1 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +78 -3
- package/packages/herdr-subagents/tests/run-tests.mjs +608 -64
- package/packages/herdr-subagents/src/runner.ts +0 -234
|
@@ -2,25 +2,31 @@ import { existsSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { HerdrAutomation } from "./herdr.ts";
|
|
4
4
|
import { isProcessAlive, type OwnerIdentity } from "./ownership.ts";
|
|
5
|
-
import { writeJsonAtomic } from "./state.ts";
|
|
6
5
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
7
|
-
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord,
|
|
6
|
+
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type RunRecord } from "./records.ts";
|
|
8
7
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
9
8
|
import { attemptReportPath } from "./attempts.ts";
|
|
10
|
-
|
|
9
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
10
|
+
import { withRunStateLock } from "./run-state.ts";
|
|
11
|
+
import { aggregateTasksWithResume, claimResumeCleanup, memberOwnedBy, memberOwnerIsStale, readTaskEntries, type ResumeCleanupClaim } from "./resume-state.ts";
|
|
11
12
|
|
|
12
13
|
export interface CancellationResult { cancelledRuns: number; cleanupErrors: string[]; }
|
|
13
14
|
export interface ReconciliationResult { liveTasks: number; settledTasks: number; interruptedTasks: number; cancelledTasks: number; cleanupErrors: string[]; }
|
|
14
15
|
|
|
15
|
-
async function closeRunTab(run: RunRecord, runFile: string, herdr: HerdrAutomation, cleanupErrors: string[]): Promise<void> {
|
|
16
|
-
|
|
16
|
+
async function closeRunTab(run: RunRecord, runFile: string, herdr: HerdrAutomation, cleanupErrors: string[], claim?: ResumeCleanupClaim): Promise<void> {
|
|
17
|
+
const tabId = claim?.tabId ?? run.tabId;
|
|
18
|
+
if (!tabId) return;
|
|
17
19
|
const forceClose = new AbortController();
|
|
18
20
|
forceClose.abort(new Error("Owning parent Pi session is no longer active."));
|
|
19
21
|
const errorsBeforeCleanup = cleanupErrors.length;
|
|
20
22
|
try {
|
|
21
|
-
await cleanupRunTab({
|
|
23
|
+
await cleanupRunTab({
|
|
24
|
+
herdr, tabId, runFile, runRecord: run, signal: forceClose.signal,
|
|
25
|
+
onError: (message) => cleanupErrors.push(message), errorPrefix: `Could not close Subagent tab ${tabId}`,
|
|
26
|
+
onClosed: claim ? (latest) => { if (latest.resume?.generation === claim.generation && latest.resume.phase === "closing") delete latest.resume; } : undefined,
|
|
27
|
+
});
|
|
22
28
|
} catch (error) {
|
|
23
|
-
if (cleanupErrors.length === errorsBeforeCleanup) cleanupErrors.push(`Could not close Subagent tab ${
|
|
29
|
+
if (cleanupErrors.length === errorsBeforeCleanup) cleanupErrors.push(`Could not close Subagent tab ${tabId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
24
30
|
}
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -34,32 +40,47 @@ export async function cancelActiveSubagentRuns(cwd: string, herdr: HerdrAutomati
|
|
|
34
40
|
for (const runEntry of readdirSync(runsDirectory)) {
|
|
35
41
|
const runDirectory = join(runsDirectory, runEntry);
|
|
36
42
|
const runFile = join(runDirectory, "run.json");
|
|
37
|
-
let
|
|
38
|
-
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
43
|
+
let snapshot: RunRecord;
|
|
44
|
+
try { snapshot = await readRunRecord(runFile); } catch (error) { result.cleanupErrors.push(`Could not inspect Subagent run ${runFile}: ${error instanceof Error ? error.message : String(error)}`); continue; }
|
|
45
|
+
if (options.runId && snapshot.id !== options.runId) continue;
|
|
46
|
+
let affected = false;
|
|
47
|
+
let claim: ResumeCleanupClaim | undefined;
|
|
48
|
+
let latest = snapshot;
|
|
49
|
+
try {
|
|
50
|
+
latest = await withRunStateLock(runFile, undefined, async (run) => {
|
|
51
|
+
const entries = await readTaskEntries(runDirectory);
|
|
52
|
+
if (run.resume?.phase === "active") {
|
|
53
|
+
for (const [taskId, member] of Object.entries(run.resume.active)) {
|
|
54
|
+
if (!memberOwnedBy(member, options.owner)) continue;
|
|
55
|
+
const entry = entries.find(({ task }) => task.id === taskId);
|
|
56
|
+
if (entry && (member.phase === "allocating" || entry.task.currentAttemptId === member.attemptId)) {
|
|
57
|
+
entry.task.currentAttemptId = member.attemptId;
|
|
58
|
+
if (isActiveStatus(entry.task.status) || member.phase === "allocating") {
|
|
59
|
+
applyTaskStatus(entry.task, "cancelled", { error: reason });
|
|
60
|
+
await writeJsonAtomic(join(entry.directory, "task.json"), entry.task);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
delete run.resume.active[taskId];
|
|
64
|
+
affected = true;
|
|
65
|
+
}
|
|
66
|
+
applyRunStatus(run, aggregateTasksWithResume(entries.map(({ task }) => task), run.resume.active), { error: reason });
|
|
67
|
+
claim = claimResumeCleanup(run);
|
|
68
|
+
return run;
|
|
69
|
+
}
|
|
70
|
+
const ownerMatches = !options.owner?.sessionId || run.ownerSessionId === options.owner.sessionId;
|
|
71
|
+
if (!ownerMatches || !isActiveStatus(run.status)) return run;
|
|
72
|
+
for (const { directory, task } of entries) {
|
|
73
|
+
if (!isActiveStatus(task.status)) continue;
|
|
74
|
+
applyTaskStatus(task, "cancelled", { error: reason });
|
|
75
|
+
await writeJsonAtomic(join(directory, "task.json"), task);
|
|
76
|
+
}
|
|
77
|
+
applyRunStatus(run, "cancelled", { error: reason });
|
|
78
|
+
affected = true;
|
|
79
|
+
return run;
|
|
80
|
+
});
|
|
81
|
+
} catch (error) { result.cleanupErrors.push(`Could not cancel Subagent run ${snapshot.id}: ${error instanceof Error ? error.message : String(error)}`); continue; }
|
|
82
|
+
if (!affected) continue;
|
|
83
|
+
if (claim || (!latest.resume && latest.status === "cancelled")) await closeRunTab(latest, runFile, herdr, result.cleanupErrors, claim);
|
|
63
84
|
result.cancelledRuns += 1;
|
|
64
85
|
}
|
|
65
86
|
return result;
|
|
@@ -72,108 +93,120 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
|
|
|
72
93
|
for (const runEntry of readdirSync(runsDirectory)) {
|
|
73
94
|
const runDirectory = join(runsDirectory, runEntry);
|
|
74
95
|
const runFile = join(runDirectory, "run.json");
|
|
75
|
-
let
|
|
76
|
-
try {
|
|
77
|
-
|
|
78
|
-
result.cleanupErrors.push(`Could not
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
const differentOwner = owner?.sessionId !== undefined && run.ownerSessionId !== owner.sessionId;
|
|
82
|
-
if (differentOwner && run.ownerProcessId !== (owner?.processId ?? process.pid) && isProcessAlive(run.ownerProcessId)) continue;
|
|
83
|
-
for (const pendingTabId of [...(run.cleanupPendingTabIds ?? [])]) {
|
|
84
|
-
const errorsBeforeCleanup = result.cleanupErrors.length;
|
|
85
|
-
try {
|
|
86
|
-
await cleanupRunTab({ herdr, tabId: pendingTabId, runFile, runRecord: run, onError: (message) => result.cleanupErrors.push(message), errorPrefix: `Could not finish deferred cleanup for Herdr tab ${pendingTabId}` });
|
|
87
|
-
} catch (error) {
|
|
88
|
-
if (result.cleanupErrors.length === errorsBeforeCleanup) result.cleanupErrors.push(`Could not finish deferred cleanup for Herdr tab ${pendingTabId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
89
|
-
}
|
|
96
|
+
let snapshot: RunRecord;
|
|
97
|
+
try { snapshot = await readRunRecord(runFile); } catch (error) { result.cleanupErrors.push(`Could not inspect Subagent run ${runFile}: ${error instanceof Error ? error.message : String(error)}`); continue; }
|
|
98
|
+
for (const pendingTabId of [...(snapshot.cleanupPendingTabIds ?? [])]) {
|
|
99
|
+
try { await cleanupRunTab({ herdr, tabId: pendingTabId, runFile, runRecord: snapshot, onError: (message) => result.cleanupErrors.push(message), errorPrefix: `Could not finish deferred cleanup for Herdr tab ${pendingTabId}` }); }
|
|
100
|
+
catch (error) { result.cleanupErrors.push(`Could not finish deferred cleanup for Herdr tab ${pendingTabId}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
90
101
|
}
|
|
91
|
-
if (!isActiveStatus(run.status)) continue;
|
|
92
102
|
const tasksDirectory = join(runDirectory, "tasks");
|
|
93
103
|
if (!existsSync(tasksDirectory)) {
|
|
94
|
-
const message = `Active Subagent run ${
|
|
104
|
+
const message = `Active Subagent run ${snapshot.id} has no tasks directory.`;
|
|
95
105
|
result.cleanupErrors.push(message);
|
|
96
|
-
applyRunStatus(run, "failed", { error: message });
|
|
97
|
-
|
|
98
|
-
await closeRunTab(
|
|
106
|
+
const failedRun = await withRunStateLock(runFile, undefined, (run) => applyRunStatus(run, "failed", { error: message }));
|
|
107
|
+
result.interruptedTasks += 1;
|
|
108
|
+
await closeRunTab(failedRun, runFile, herdr, result.cleanupErrors);
|
|
99
109
|
continue;
|
|
100
110
|
}
|
|
101
|
-
|
|
102
|
-
let
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
111
|
+
let claim: ResumeCleanupClaim | undefined;
|
|
112
|
+
let closeRegular = false;
|
|
113
|
+
let latest = snapshot;
|
|
114
|
+
try {
|
|
115
|
+
latest = await withRunStateLock(runFile, undefined, async (run) => {
|
|
116
|
+
const entries = await readTaskEntries(runDirectory);
|
|
117
|
+
// Recover each historical member independently. A live sibling owner is never
|
|
118
|
+
// cancelled merely because another owner invokes reconciliation.
|
|
119
|
+
if (run.resume?.phase === "active") for (const [taskId, member] of Object.entries(run.resume.active)) {
|
|
120
|
+
const entry = entries.find(({ task }) => task.id === taskId);
|
|
121
|
+
if (!entry) { if (memberOwnerIsStale(member, owner)) delete run.resume.active[taskId]; continue; }
|
|
122
|
+
if (member.phase === "allocating" && entry.task.currentAttemptId === member.attemptId && isActiveStatus(entry.task.status)) member.phase = "running";
|
|
123
|
+
const attemptMatches = entry.task.currentAttemptId === member.attemptId;
|
|
124
|
+
if (!memberOwnerIsStale(member, owner)) continue;
|
|
125
|
+
const scopedResumeReport = attemptReportPath(entry.directory, member.attemptId);
|
|
126
|
+
if (existsSync(scopedResumeReport)) {
|
|
127
|
+
try {
|
|
128
|
+
const submitted = await readReport(scopedResumeReport);
|
|
129
|
+
if (submitted.attemptId !== member.attemptId) throw new Error(`Report attempt '${submitted.attemptId ?? "unknown"}' does not match resume member '${member.attemptId}'.`);
|
|
130
|
+
const report = submitted;
|
|
131
|
+
entry.task.currentAttemptId = member.attemptId;
|
|
132
|
+
const status = applyReport(entry.task, report, report.reportedAt);
|
|
133
|
+
await Promise.all([
|
|
134
|
+
writeJsonAtomic(join(entry.directory, "task.json"), entry.task),
|
|
135
|
+
writeJsonAtomic(join(entry.directory, "report.json"), report),
|
|
136
|
+
]);
|
|
137
|
+
if (status === "blocked") member.phase = "blocked";
|
|
138
|
+
else delete run.resume.active[taskId];
|
|
139
|
+
result.settledTasks += 1;
|
|
140
|
+
continue;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
result.cleanupErrors.push(`Invalid historical resume report ${scopedResumeReport}: ${error instanceof Error ? error.message : String(error)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (member.phase === "allocating" || attemptMatches) {
|
|
146
|
+
entry.task.currentAttemptId = member.attemptId;
|
|
147
|
+
const reportFile = join(entry.directory, "report.json");
|
|
148
|
+
await settleAttemptFailure(entry.task, reportFile, { cancelled: true, error: "Historical resume owner disappeared before the attempt settled.", name: `${entry.task.name ?? entry.task.agent} follow-up`, replaceReport: true });
|
|
149
|
+
await writeJsonAtomic(join(entry.directory, "task.json"), entry.task);
|
|
150
|
+
result.cancelledTasks += 1; result.interruptedTasks += 1;
|
|
138
151
|
}
|
|
152
|
+
delete run.resume.active[taskId];
|
|
139
153
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
const differentOwner = owner?.sessionId !== undefined && run.ownerSessionId !== owner.sessionId && (run.ownerProcessId === (owner.processId ?? process.pid) || !isProcessAlive(run.ownerProcessId));
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const task = entry.task;
|
|
157
|
+
const member = run.resume?.active[task.id];
|
|
158
|
+
if (member) continue;
|
|
159
|
+
if (!isActiveStatus(task.status)) continue;
|
|
160
|
+
const canonicalReportFile = join(entry.directory, "report.json");
|
|
161
|
+
let scopedReportFile: string | undefined;
|
|
162
|
+
if (task.currentAttemptId) try { scopedReportFile = attemptReportPath(entry.directory, task.currentAttemptId); } catch (error) { result.cleanupErrors.push(`Invalid current attempt for Subagent task ${task.id}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
163
|
+
const reportFile = task.currentAttemptId ? scopedReportFile : canonicalReportFile;
|
|
164
|
+
if (reportFile && existsSync(reportFile) && (task.status === "running" || task.status === "starting")) {
|
|
165
|
+
try {
|
|
166
|
+
const submitted = await readReport(reportFile);
|
|
167
|
+
const report = submitted;
|
|
168
|
+
if (task.currentAttemptId && report.attemptId !== task.currentAttemptId) throw new Error(`Report attempt '${report.attemptId ?? "unknown"}' does not match current attempt '${task.currentAttemptId}'.`);
|
|
169
|
+
applyReport(task, report, report.reportedAt);
|
|
170
|
+
if (report !== submitted) await writeJsonAtomic(reportFile, report);
|
|
171
|
+
await writeJsonAtomic(canonicalReportFile, report);
|
|
172
|
+
result.settledTasks += 1;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
175
|
+
result.cleanupErrors.push(`Invalid Subagent report ${reportFile}: ${detail}`);
|
|
176
|
+
await persistFailureReport(canonicalReportFile, { status: "failed", summary: "Recovered task had an invalid structured report.", documents: [], error: detail }, false);
|
|
177
|
+
applyTaskStatus(task, "failed", { error: `Invalid structured report: ${detail}` }); result.interruptedTasks += 1; result.settledTasks += 1;
|
|
151
178
|
}
|
|
179
|
+
if (differentOwner && task.status === "blocked") {
|
|
180
|
+
const cancelledReport = { status: "failed" as const, summary: "Blocked task was cancelled because its parent Pi session is no longer active.", documents: [], error: "Owning parent Pi session is no longer active.", ...(task.currentAttemptId ? { attemptId: task.currentAttemptId } : {}) };
|
|
181
|
+
await writeJsonAtomic(canonicalReportFile, cancelledReport);
|
|
182
|
+
if (scopedReportFile) await writeJsonAtomic(scopedReportFile, cancelledReport);
|
|
183
|
+
applyTaskStatus(task, "cancelled", { error: cancelledReport.error }); result.cancelledTasks += 1; result.interruptedTasks += 1;
|
|
184
|
+
}
|
|
185
|
+
} else if (differentOwner) {
|
|
186
|
+
applyTaskStatus(task, "cancelled", { error: "Owning parent Pi session is no longer active." }); result.cancelledTasks += 1; result.interruptedTasks += 1;
|
|
187
|
+
} else if (task.status === "running" || task.status === "starting") {
|
|
188
|
+
let live = false;
|
|
189
|
+
try { live = task.paneId ? await herdr.isTaskRunning(task.paneId) : false; } catch (error) { result.cleanupErrors.push(`Could not inspect Subagent pane ${task.paneId}: ${error instanceof Error ? error.message : String(error)}`); live = true; }
|
|
190
|
+
if (live) result.liveTasks += 1;
|
|
191
|
+
else { applyTaskStatus(task, "cancelled", { error: "Child pane disappeared before a structured report was recorded." }); result.cancelledTasks += 1; result.interruptedTasks += 1; }
|
|
152
192
|
}
|
|
153
|
-
|
|
154
|
-
else {
|
|
155
|
-
applyTaskStatus(task, "cancelled", { error: "Child pane disappeared before a structured report was recorded." });
|
|
156
|
-
result.cancelledTasks += 1;
|
|
157
|
-
result.interruptedTasks += 1;
|
|
158
|
-
}
|
|
193
|
+
await writeJsonAtomic(join(entry.directory, "task.json"), task);
|
|
159
194
|
}
|
|
160
|
-
await
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
result.interruptedTasks +=
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (!statuses.some((status) => status === "running" || status === "starting")) {
|
|
173
|
-
applyRunStatus(run, aggregateTaskStatus(statuses));
|
|
174
|
-
await writeJsonAtomic(runFile, run);
|
|
175
|
-
if (differentOwner || run.status === "cancelled") await closeRunTab(run, runFile, herdr, result.cleanupErrors);
|
|
195
|
+
const refreshed = await readTaskEntries(runDirectory);
|
|
196
|
+
applyRunStatus(run, run.resume ? aggregateTasksWithResume(refreshed.map(({ task }) => task), run.resume.active) : aggregateTaskStatus(refreshed.map(({ task }) => task.status)));
|
|
197
|
+
claim = claimResumeCleanup(run);
|
|
198
|
+
closeRegular = !run.resume && !isActiveStatus(run.status) && (differentOwner || result.interruptedTasks > 0);
|
|
199
|
+
return run;
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
203
|
+
result.cleanupErrors.push(`Could not inspect Subagent task in ${tasksDirectory}: ${detail}`);
|
|
204
|
+
result.interruptedTasks += 1;
|
|
205
|
+
latest = await withRunStateLock(runFile, undefined, (run) => applyRunStatus(run, "failed", { error: detail }));
|
|
206
|
+
closeRegular = true;
|
|
176
207
|
}
|
|
208
|
+
if (claim) await closeRunTab(latest, runFile, herdr, result.cleanupErrors, claim);
|
|
209
|
+
else if (closeRegular) await closeRunTab(latest, runFile, herdr, result.cleanupErrors);
|
|
177
210
|
}
|
|
178
211
|
return result;
|
|
179
212
|
}
|
|
@@ -4,6 +4,7 @@ import { writeJsonAtomic } from "./state.ts";
|
|
|
4
4
|
|
|
5
5
|
export type TaskStatus = "starting" | "queued" | "running" | "blocked" | "completed" | "failed" | "cancelled";
|
|
6
6
|
export type RunStatus = TaskStatus | "partial";
|
|
7
|
+
export type TaskRuntimeState = "working" | "waiting_interaction" | "retrying" | "delivery_unknown";
|
|
7
8
|
export const ACTIVE_STATUSES = new Set<RunStatus>(["starting", "queued", "running", "blocked"]);
|
|
8
9
|
const SETTLED_RUN_STATUSES = new Set<RunStatus>(["completed", "partial", "failed", "cancelled"]);
|
|
9
10
|
|
|
@@ -16,7 +17,7 @@ export interface PersistedDocument {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export interface PersistedReport {
|
|
19
|
-
status: "completed" | "
|
|
20
|
+
status: "completed" | "blocked" | "failed";
|
|
20
21
|
summary: string;
|
|
21
22
|
documents: PersistedDocument[];
|
|
22
23
|
attemptId?: string;
|
|
@@ -25,6 +26,25 @@ export interface PersistedReport {
|
|
|
25
26
|
reportedAt?: string;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
export interface ResumeMember {
|
|
30
|
+
paneId: string;
|
|
31
|
+
generation: number;
|
|
32
|
+
agentName: string;
|
|
33
|
+
attemptId: string;
|
|
34
|
+
ownerSessionId?: string;
|
|
35
|
+
ownerProcessId?: number;
|
|
36
|
+
ownerToken: string;
|
|
37
|
+
phase: "allocating" | "running" | "blocked";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResumeState {
|
|
41
|
+
generation: number;
|
|
42
|
+
phase: "active" | "closing";
|
|
43
|
+
tabId: string;
|
|
44
|
+
paneIds: string[];
|
|
45
|
+
active: Record<string, ResumeMember>;
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
export interface RunRecord {
|
|
29
49
|
id: string;
|
|
30
50
|
label: string;
|
|
@@ -47,6 +67,7 @@ export interface RunRecord {
|
|
|
47
67
|
error?: string;
|
|
48
68
|
cleanupError?: string;
|
|
49
69
|
cleanupPendingTabIds?: string[];
|
|
70
|
+
resume?: ResumeState;
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
export interface TaskRecord {
|
|
@@ -76,6 +97,9 @@ export interface TaskRecord {
|
|
|
76
97
|
attempt?: number;
|
|
77
98
|
currentTurnFile?: string;
|
|
78
99
|
currentAttemptId?: string;
|
|
100
|
+
runtimeState?: TaskRuntimeState;
|
|
101
|
+
runtimeDiagnostic?: string;
|
|
102
|
+
runtimeUpdatedAt?: string;
|
|
79
103
|
question?: string;
|
|
80
104
|
error?: string;
|
|
81
105
|
}
|
|
@@ -93,6 +117,9 @@ export interface TaskActivity {
|
|
|
93
117
|
elapsedMs?: number;
|
|
94
118
|
completedAt?: string;
|
|
95
119
|
updatedAt?: string;
|
|
120
|
+
runtimeState?: TaskRuntimeState;
|
|
121
|
+
runtimeDiagnostic?: string;
|
|
122
|
+
runtimeUpdatedAt?: string;
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
export function projectTaskActivity(task: TaskRecord, index = task.order - 1): TaskActivity {
|
|
@@ -109,6 +136,9 @@ export function projectTaskActivity(task: TaskRecord, index = task.order - 1): T
|
|
|
109
136
|
...(typeof task.elapsedMs === "number" ? { elapsedMs: task.elapsedMs } : {}),
|
|
110
137
|
...(task.completedAt ? { completedAt: task.completedAt } : {}),
|
|
111
138
|
...(task.updatedAt ? { updatedAt: task.updatedAt } : {}),
|
|
139
|
+
...(task.runtimeState ? { runtimeState: task.runtimeState } : {}),
|
|
140
|
+
...(task.runtimeDiagnostic ? { runtimeDiagnostic: task.runtimeDiagnostic } : {}),
|
|
141
|
+
...(task.runtimeUpdatedAt ? { runtimeUpdatedAt: task.runtimeUpdatedAt } : {}),
|
|
112
142
|
};
|
|
113
143
|
}
|
|
114
144
|
|
|
@@ -168,14 +198,14 @@ export async function readTurnRecord(path: string): Promise<TurnRecord> {
|
|
|
168
198
|
export function parseReport(value: unknown, source = "structured report"): PersistedReport {
|
|
169
199
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Expected a JSON object in ${source}.`);
|
|
170
200
|
const report = value as Record<string, unknown>;
|
|
171
|
-
if (report.status !== "completed" && report.status !== "
|
|
201
|
+
if (report.status !== "completed" && report.status !== "blocked" && report.status !== "failed") throw new Error(`Invalid report status in ${source}.`);
|
|
172
202
|
if (typeof report.summary !== "string" || !report.summary.trim()) throw new Error(`Missing report summary in ${source}.`);
|
|
173
203
|
if (!Array.isArray(report.documents) || !report.documents.every((document) => {
|
|
174
204
|
if (!document || typeof document !== "object" || Array.isArray(document)) return false;
|
|
175
205
|
const entry = document as Record<string, unknown>;
|
|
176
206
|
return typeof entry.path === "string" && typeof entry.description === "string";
|
|
177
207
|
})) throw new Error(`Invalid report documents in ${source}.`);
|
|
178
|
-
if (report.status === "
|
|
208
|
+
if (report.status === "blocked" && (typeof report.question !== "string" || !report.question.trim())) throw new Error(`Missing report question in ${source}.`);
|
|
179
209
|
if (report.error !== undefined && typeof report.error !== "string") throw new Error(`Invalid report error in ${source}.`);
|
|
180
210
|
if (report.question !== undefined && typeof report.question !== "string") throw new Error(`Invalid report question in ${source}.`);
|
|
181
211
|
if (report.attemptId !== undefined && (typeof report.attemptId !== "string" || !report.attemptId.trim())) throw new Error(`Invalid report attemptId in ${source}.`);
|
|
@@ -250,6 +280,7 @@ export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
|
|
|
250
280
|
const status = options.cancelled ? "cancelled" : "failed";
|
|
251
281
|
const at = options.at ?? new Date().toISOString();
|
|
252
282
|
applyTaskStatus(record, status, { error: options.error, at });
|
|
283
|
+
if ("order" in record) clearTaskRuntime(record);
|
|
253
284
|
const report: PersistedReport = {
|
|
254
285
|
status: "failed",
|
|
255
286
|
summary: options.summary ?? `${options.name} ${options.cancelled ? "was cancelled" : "failed"}.`,
|
|
@@ -261,8 +292,21 @@ export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
|
|
|
261
292
|
return { status, report };
|
|
262
293
|
}
|
|
263
294
|
|
|
295
|
+
export function clearTaskRuntime(record: TaskRecord): void {
|
|
296
|
+
delete record.runtimeState;
|
|
297
|
+
delete record.runtimeDiagnostic;
|
|
298
|
+
delete record.runtimeUpdatedAt;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function applyTaskRuntime(record: TaskRecord, state: TaskRuntimeState, diagnostic?: string, at = new Date().toISOString()): void {
|
|
302
|
+
record.runtimeState = state;
|
|
303
|
+
record.runtimeUpdatedAt = at;
|
|
304
|
+
if (diagnostic) record.runtimeDiagnostic = diagnostic;
|
|
305
|
+
else delete record.runtimeDiagnostic;
|
|
306
|
+
}
|
|
307
|
+
|
|
264
308
|
export function applyReport(record: TaskRecord | TurnRecord, report: PersistedReport, completedAt = new Date().toISOString()): "blocked" | "completed" | "failed" {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return status;
|
|
309
|
+
applyTaskStatus(record, report.status, { at: completedAt, question: report.question, error: report.error });
|
|
310
|
+
if ("order" in record) clearTaskRuntime(record);
|
|
311
|
+
return report.status;
|
|
268
312
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isProcessAlive, type OwnerIdentity } from "./ownership.ts";
|
|
4
|
+
import { aggregateTaskStatus, type AggregateRunStatus } from "./run-status.ts";
|
|
5
|
+
import type { ResumeMember, RunRecord, TaskRecord, TaskStatus } from "./records.ts";
|
|
6
|
+
import { readTaskRecord } from "./records.ts";
|
|
7
|
+
|
|
8
|
+
export interface TaskEntry { directory: string; task: TaskRecord }
|
|
9
|
+
export interface ResumeCleanupClaim { tabId: string; generation: number }
|
|
10
|
+
|
|
11
|
+
export async function readTaskEntries(runDirectory: string): Promise<TaskEntry[]> {
|
|
12
|
+
return Promise.all(readdirSync(join(runDirectory, "tasks")).sort().map(async (entry) => {
|
|
13
|
+
const directory = join(runDirectory, "tasks", entry);
|
|
14
|
+
return { directory, task: await readTaskRecord(join(directory, "task.json")) };
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function memberCoversTask(member: ResumeMember | undefined, task: TaskRecord): boolean {
|
|
19
|
+
if (!member) return false;
|
|
20
|
+
if (member.phase === "allocating") return true;
|
|
21
|
+
return member.attemptId === task.currentAttemptId && member.phase === "running";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function aggregateTasksWithResume(tasks: readonly TaskRecord[], active: Record<string, ResumeMember> = {}): AggregateRunStatus {
|
|
25
|
+
return aggregateTaskStatus(tasks.map((task): TaskStatus => memberCoversTask(active[task.id], task) ? "running" : task.status));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function memberOwnedBy(member: ResumeMember, owner?: OwnerIdentity): boolean {
|
|
29
|
+
if (!owner) return true;
|
|
30
|
+
if (owner.sessionId !== undefined && member.ownerSessionId !== owner.sessionId) return false;
|
|
31
|
+
if (owner.processId !== undefined && member.ownerProcessId !== owner.processId) return false;
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function memberOwnerIsStale(member: ResumeMember, currentOwner?: OwnerIdentity): boolean {
|
|
36
|
+
if (currentOwner && memberOwnedBy(member, currentOwner)) return false;
|
|
37
|
+
if (currentOwner?.sessionId && member.ownerSessionId && currentOwner.sessionId !== member.ownerSessionId && member.ownerProcessId === currentOwner.processId) return true;
|
|
38
|
+
return !isProcessAlive(member.ownerProcessId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function claimResumeCleanup(run: RunRecord): ResumeCleanupClaim | undefined {
|
|
42
|
+
if (!run.resume || run.resume.phase !== "active" || Object.keys(run.resume.active).length > 0) return undefined;
|
|
43
|
+
run.resume.phase = "closing";
|
|
44
|
+
run.cleanupPendingTabIds = [...new Set([...(run.cleanupPendingTabIds ?? []), run.resume.tabId])];
|
|
45
|
+
return { tabId: run.resume.tabId, generation: run.resume.generation };
|
|
46
|
+
}
|
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { readRunRecord } from "./records.ts";
|
|
4
4
|
|
|
5
5
|
export async function findRunDirectory(runsDirectory: string, runId: string): Promise<string | undefined> {
|
|
6
6
|
if (!existsSync(runsDirectory)) return undefined;
|
|
7
|
+
const reference = basename(runId.trim());
|
|
7
8
|
const errors: string[] = [];
|
|
9
|
+
const matches: Array<{ id: string; directory: string }> = [];
|
|
8
10
|
for (const entry of readdirSync(runsDirectory)) {
|
|
9
11
|
const directory = join(runsDirectory, entry);
|
|
10
12
|
const runFile = join(directory, "run.json");
|
|
11
13
|
try {
|
|
12
|
-
|
|
14
|
+
const id = (await readRunRecord(runFile)).id;
|
|
15
|
+
if (id === reference || entry === reference) return directory;
|
|
16
|
+
if (id.startsWith(reference) || entry.endsWith(`-${reference}`)) matches.push({ id, directory });
|
|
13
17
|
} catch (error) {
|
|
14
18
|
errors.push(`${runFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
15
19
|
}
|
|
16
20
|
}
|
|
21
|
+
if (matches.length === 1) return matches[0].directory;
|
|
22
|
+
if (matches.length > 1) throw new Error(`Ambiguous Subagent run reference '${runId}': ${matches.map((match) => match.id).join(", ")}.`);
|
|
17
23
|
if (errors.length) throw new Error(`Could not inspect Subagent runs while looking for '${runId}': ${errors.join("; ")}`);
|
|
18
24
|
return undefined;
|
|
19
25
|
}
|
|
26
|
+
|
|
27
|
+
export function resolveRunIdReference(reference: string, candidates: readonly string[]): string | undefined {
|
|
28
|
+
const trimmed = basename(reference.trim());
|
|
29
|
+
const exact = candidates.find((candidate) => candidate === trimmed);
|
|
30
|
+
if (exact) return exact;
|
|
31
|
+
const matches = [...new Set(candidates.filter((candidate) => candidate.startsWith(trimmed)))];
|
|
32
|
+
if (matches.length > 1) throw new Error(`Ambiguous Subagent run reference '${reference}': ${matches.join(", ")}.`);
|
|
33
|
+
return matches[0];
|
|
34
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HerdrAutomation } from "./herdr.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { withRunStateLock } from "./run-state.ts";
|
|
3
3
|
import type { RunRecord } from "./records.ts";
|
|
4
4
|
import { applyRunStatus } from "./run-status.ts";
|
|
5
5
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
@@ -33,45 +33,47 @@ export class RunPaneAllocator {
|
|
|
33
33
|
this.allocation = this.allocation.then(async () => {
|
|
34
34
|
const { herdr, run, runFile, signal } = this.options;
|
|
35
35
|
const env = childEnvironment(taskDirectory);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
const allocated = await withRunStateLock(runFile, signal, async (latest) => {
|
|
37
|
+
const currentPaneIds = [...(latest.paneIds ?? [])];
|
|
38
|
+
let lastPaneId = currentPaneIds.at(-1);
|
|
39
|
+
let anchorExists = Boolean(latest.tabId && lastPaneId);
|
|
40
|
+
if (anchorExists && this.options.validateExistingPane) {
|
|
41
|
+
const surviving: string[] = [];
|
|
42
|
+
for (const existingPaneId of currentPaneIds) if (await herdr.paneExists(existingPaneId, signal)) surviving.push(existingPaneId);
|
|
43
|
+
currentPaneIds.splice(0, currentPaneIds.length, ...surviving);
|
|
44
|
+
lastPaneId = currentPaneIds.at(-1);
|
|
45
|
+
anchorExists = Boolean(lastPaneId);
|
|
41
46
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (staleTabId && staleTabId !== tab.tabId) {
|
|
53
|
-
run.staleTabIds = [...new Set([...(run.staleTabIds ?? []), staleTabId])];
|
|
54
|
-
await writeJsonAtomic(runFile, run);
|
|
55
|
-
await cleanupRunTab({
|
|
56
|
-
herdr, tabId: staleTabId, runFile, runRecord: run, signal, onError: this.options.onCleanupError,
|
|
57
|
-
errorPrefix: `Could not close stale Herdr tab ${staleTabId}`,
|
|
58
|
-
onClosed: (latest) => {
|
|
59
|
-
latest.staleTabIds = latest.staleTabIds?.filter((id) => id !== staleTabId);
|
|
60
|
-
if (!latest.staleTabIds?.length) delete latest.staleTabIds;
|
|
61
|
-
},
|
|
62
|
-
}).catch((error) => {
|
|
63
|
-
if (!this.options.onCleanupError) throw error;
|
|
64
|
-
});
|
|
47
|
+
let staleTabId: string | undefined;
|
|
48
|
+
if (!anchorExists) {
|
|
49
|
+
staleTabId = latest.tabId;
|
|
50
|
+
const tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd: latest.cwd, label: `SA · ${latest.label}`, env, focus: false, signal });
|
|
51
|
+
latest.tabId = tab.tabId;
|
|
52
|
+
paneId = tab.paneId;
|
|
53
|
+
currentPaneIds.splice(0);
|
|
54
|
+
if (staleTabId && staleTabId !== tab.tabId) latest.staleTabIds = [...new Set([...(latest.staleTabIds ?? []), staleTabId])];
|
|
55
|
+
} else {
|
|
56
|
+
paneId = (await herdr.splitPane({ paneId: lastPaneId as string, cwd: latest.cwd, direction: currentPaneIds.length % 2 ? "right" : "down", focus: false, env, signal })).paneId;
|
|
65
57
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
currentPaneIds.push(paneId);
|
|
59
|
+
applyRunStatus(latest, "running");
|
|
60
|
+
latest.paneIds = currentPaneIds;
|
|
61
|
+
return { latest, staleTabId: staleTabId && staleTabId !== latest.tabId ? staleTabId : undefined };
|
|
62
|
+
});
|
|
63
|
+
this.paneIds.splice(0, this.paneIds.length, ...(allocated.latest.paneIds ?? []));
|
|
64
|
+
this.lastPaneId = this.paneIds.at(-1);
|
|
65
|
+
Object.assign(run, allocated.latest);
|
|
66
|
+
if (allocated.staleTabId) {
|
|
67
|
+
const staleTabId = allocated.staleTabId;
|
|
68
|
+
await cleanupRunTab({
|
|
69
|
+
herdr, tabId: staleTabId, runFile, runRecord: run, signal, onError: this.options.onCleanupError,
|
|
70
|
+
errorPrefix: `Could not close stale Herdr tab ${staleTabId}`,
|
|
71
|
+
onClosed: (latest) => {
|
|
72
|
+
latest.staleTabIds = latest.staleTabIds?.filter((id) => id !== staleTabId);
|
|
73
|
+
if (!latest.staleTabIds?.length) delete latest.staleTabIds;
|
|
74
|
+
},
|
|
75
|
+
}).catch((error) => { if (!this.options.onCleanupError) throw error; });
|
|
69
76
|
}
|
|
70
|
-
this.lastPaneId = paneId;
|
|
71
|
-
this.paneIds.push(paneId);
|
|
72
|
-
applyRunStatus(run, "running");
|
|
73
|
-
run.paneIds = [...this.paneIds];
|
|
74
|
-
await writeJsonAtomic(runFile, run);
|
|
75
77
|
});
|
|
76
78
|
await this.allocation;
|
|
77
79
|
return paneId;
|