@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
@@ -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, type RunRecord, type TaskStatus } from "./records.ts";
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
- if (!run.tabId) return;
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({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: forceClose.signal, onError: (message) => cleanupErrors.push(message), errorPrefix: `Could not close Subagent tab ${run.tabId}` });
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 ${run.tabId}: ${error instanceof Error ? error.message : String(error)}`);
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 run: RunRecord;
38
- try { run = await readRunRecord(runFile); }
39
- catch (error) {
40
- result.cleanupErrors.push(`Could not inspect Subagent run ${runFile}: ${error instanceof Error ? error.message : String(error)}`);
41
- continue;
42
- }
43
- if (options.runId && run.id !== options.runId) continue;
44
- if (!isActiveStatus(run.status)) continue;
45
- if (options.owner?.sessionId && run.ownerSessionId !== options.owner.sessionId) continue;
46
- const now = new Date().toISOString();
47
- const tasksDirectory = join(runDirectory, "tasks");
48
- if (existsSync(tasksDirectory)) for (const taskEntry of readdirSync(tasksDirectory)) {
49
- const taskFile = join(tasksDirectory, taskEntry, "task.json");
50
- let task;
51
- try { task = await readTaskRecord(taskFile); }
52
- catch (error) {
53
- result.cleanupErrors.push(`Could not cancel Subagent task ${taskFile}: ${error instanceof Error ? error.message : String(error)}`);
54
- continue;
55
- }
56
- if (!isActiveStatus(task.status)) continue;
57
- applyTaskStatus(task, "cancelled", { error: reason, at: now });
58
- await writeJsonAtomic(taskFile, task);
59
- }
60
- applyRunStatus(run, "cancelled", { error: reason, at: now });
61
- await writeJsonAtomic(runFile, run);
62
- await closeRunTab(run, runFile, herdr, result.cleanupErrors);
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 run: RunRecord;
76
- try { run = await readRunRecord(runFile); }
77
- catch (error) {
78
- result.cleanupErrors.push(`Could not inspect Subagent run ${runFile}: ${error instanceof Error ? error.message : String(error)}`);
79
- continue;
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 ${run.id} has no tasks directory.`;
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
- await writeJsonAtomic(runFile, run);
98
- await closeRunTab(run, runFile, herdr, result.cleanupErrors);
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
- const statuses: TaskStatus[] = [];
102
- let unreadableTasks = 0;
103
- for (const taskEntry of readdirSync(tasksDirectory)) {
104
- const taskFile = join(tasksDirectory, taskEntry, "task.json");
105
- let task;
106
- try { task = await readTaskRecord(taskFile); }
107
- catch (error) {
108
- result.cleanupErrors.push(`Could not inspect Subagent task ${taskFile}: ${error instanceof Error ? error.message : String(error)}`);
109
- unreadableTasks += 1;
110
- continue;
111
- }
112
- if (isActiveStatus(task.status)) {
113
- let recoveredCurrentReport = false;
114
- const canonicalReportFile = join(tasksDirectory, taskEntry, "report.json");
115
- let currentAttemptReport: string | undefined;
116
- if (task.currentAttemptId) {
117
- try { currentAttemptReport = attemptReportPath(join(tasksDirectory, taskEntry), task.currentAttemptId); }
118
- catch (error) { result.cleanupErrors.push(`Invalid current attempt for Subagent task ${taskFile}: ${error instanceof Error ? error.message : String(error)}`); }
119
- }
120
- // Once an attempt is identified, the canonical report is only history from
121
- // an earlier attempt until this attempt publishes its scoped report.
122
- const reportFile = task.currentAttemptId ? currentAttemptReport : canonicalReportFile;
123
- if (reportFile && existsSync(reportFile) && (task.status === "running" || task.status === "starting")) {
124
- try {
125
- const report = await readReport(reportFile);
126
- if (task.currentAttemptId && report.attemptId !== task.currentAttemptId) throw new Error(`Report attempt '${report.attemptId ?? "unknown"}' does not match current attempt '${task.currentAttemptId}'.`);
127
- applyReport(task, report, report.reportedAt ?? new Date().toISOString());
128
- recoveredCurrentReport = reportFile !== canonicalReportFile;
129
- if (reportFile !== canonicalReportFile) await writeJsonAtomic(canonicalReportFile, report);
130
- result.settledTasks += 1;
131
- } catch (error) {
132
- const detail = error instanceof Error ? error.message : String(error);
133
- result.cleanupErrors.push(`Invalid Subagent report ${reportFile}: ${detail}`);
134
- await persistFailureReport(canonicalReportFile, { status: "failed", summary: "Recovered task had an invalid structured report.", documents: [], error: detail, reportedAt: new Date().toISOString() }, reportFile !== canonicalReportFile);
135
- applyTaskStatus(task, "failed", { error: `Invalid structured report: ${detail}` });
136
- result.settledTasks += 1;
137
- result.interruptedTasks += 1;
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
- if (differentOwner && isActiveStatus(task.status) && !(recoveredCurrentReport && task.status === "blocked")) {
141
- applyTaskStatus(task, "cancelled", { error: "Owning parent Pi session is no longer active." });
142
- result.cancelledTasks += 1;
143
- result.interruptedTasks += 1;
144
- } else if (!differentOwner && (task.status === "running" || task.status === "starting")) {
145
- let childIsLive = false;
146
- if (task.paneId) {
147
- try { childIsLive = await herdr.isTaskRunning(task.paneId); }
148
- catch (error) {
149
- result.cleanupErrors.push(`Could not inspect Subagent pane ${task.paneId}: ${error instanceof Error ? error.message : String(error)}`);
150
- childIsLive = true;
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
- if (childIsLive) result.liveTasks += 1;
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 writeJsonAtomic(taskFile, task);
161
- }
162
- statuses.push(task.status);
163
- }
164
- if (unreadableTasks > 0) {
165
- const message = `Could not reconcile ${unreadableTasks} unreadable task record${unreadableTasks === 1 ? "" : "s"}.`;
166
- applyRunStatus(run, "failed", { error: message });
167
- await writeJsonAtomic(runFile, run);
168
- await closeRunTab(run, runFile, herdr, result.cleanupErrors);
169
- result.interruptedTasks += unreadableTasks;
170
- continue;
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" | "needs-input" | "failed";
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 !== "needs-input" && report.status !== "failed") throw new Error(`Invalid report status in ${source}.`);
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 === "needs-input" && (typeof report.question !== "string" || !report.question.trim())) throw new Error(`Missing report question in ${source}.`);
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
- const status = report.status === "needs-input" ? "blocked" : report.status;
266
- applyTaskStatus(record, status, { at: completedAt, question: report.question, error: report.error });
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
- if ((await readRunRecord(runFile)).id === runId) return directory;
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 { writeJsonAtomic } from "./state.ts";
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
- let anchorExists = Boolean(run.tabId && this.lastPaneId);
37
- if (anchorExists && this.options.validateExistingPane) {
38
- const survivingPaneIds: string[] = [];
39
- for (const existingPaneId of this.paneIds) {
40
- if (await herdr.paneExists(existingPaneId, signal)) survivingPaneIds.push(existingPaneId);
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
- this.paneIds.splice(0, this.paneIds.length, ...survivingPaneIds);
43
- this.lastPaneId = this.paneIds.at(-1);
44
- anchorExists = Boolean(this.lastPaneId);
45
- }
46
- if (!anchorExists) {
47
- const staleTabId = run.tabId;
48
- const tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd: run.cwd, label: `SA · ${run.label}`, env, focus: false, signal });
49
- run.tabId = tab.tabId;
50
- paneId = tab.paneId;
51
- this.paneIds.splice(0);
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
- } else {
67
- const split = await herdr.splitPane({ paneId: this.lastPaneId as string, cwd: run.cwd, direction: this.paneIds.length % 2 ? "right" : "down", focus: false, env, signal });
68
- paneId = split.paneId;
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;