@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +1 -1
  3. package/packages/herdr-subagents/CHANGELOG.md +22 -0
  4. package/packages/herdr-subagents/README.md +16 -8
  5. package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
  6. package/packages/herdr-subagents/TESTING.md +13 -3
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +4 -17
  8. package/packages/herdr-subagents/extensions/subagent-report.ts +2 -2
  9. package/packages/herdr-subagents/package.json +1 -1
  10. package/packages/herdr-subagents/src/batch-runner.ts +56 -15
  11. package/packages/herdr-subagents/src/blocking.ts +115 -72
  12. package/packages/herdr-subagents/src/continue-run.ts +22 -7
  13. package/packages/herdr-subagents/src/fleet.ts +2 -1
  14. package/packages/herdr-subagents/src/herdr.ts +10 -1
  15. package/packages/herdr-subagents/src/historical-resume.ts +184 -0
  16. package/packages/herdr-subagents/src/history.ts +5 -131
  17. package/packages/herdr-subagents/src/live-agent.ts +99 -35
  18. package/packages/herdr-subagents/src/parameters.ts +2 -2
  19. package/packages/herdr-subagents/src/protocol.ts +3 -1
  20. package/packages/herdr-subagents/src/reconcile.ts +156 -135
  21. package/packages/herdr-subagents/src/records.ts +50 -32
  22. package/packages/herdr-subagents/src/resume-state.ts +46 -0
  23. package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
  24. package/packages/herdr-subagents/src/run-state.ts +120 -0
  25. package/packages/herdr-subagents/src/status.ts +0 -6
  26. package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
  27. package/packages/herdr-subagents/src/tool.ts +1 -54
  28. package/packages/herdr-subagents/src/validation.ts +1 -1
  29. package/packages/herdr-subagents/tests/extension-contract.ts +4 -4
  30. package/packages/herdr-subagents/tests/run-tests.mjs +579 -60
  31. 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, normalizeNeedsInputReport, 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,120 +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
- const canonicalReportFile = join(tasksDirectory, taskEntry, "report.json");
114
- let currentAttemptReport: string | undefined;
115
- if (task.currentAttemptId) {
116
- try { currentAttemptReport = attemptReportPath(join(tasksDirectory, taskEntry), task.currentAttemptId); }
117
- catch (error) { result.cleanupErrors.push(`Invalid current attempt for Subagent task ${taskFile}: ${error instanceof Error ? error.message : String(error)}`); }
118
- }
119
- // Once an attempt is identified, the canonical report is only history from
120
- // an earlier attempt until this attempt publishes its scoped report.
121
- const reportFile = task.currentAttemptId ? currentAttemptReport : canonicalReportFile;
122
- if (reportFile && existsSync(reportFile) && (task.status === "running" || task.status === "starting")) {
123
- try {
124
- const submittedReport = await readReport(reportFile);
125
- const report = normalizeNeedsInputReport(submittedReport, Number(run.effectiveConcurrency) || 1);
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
- if (report !== submittedReport) await writeJsonAtomic(reportFile, report);
129
- if (reportFile !== canonicalReportFile || report !== submittedReport) 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
+ }
138
144
  }
139
- }
140
- if (differentOwner && isActiveStatus(task.status)) {
141
- if (task.status === "blocked") {
142
- const cancelledReport = {
143
- status: "failed",
144
- summary: "Blocked task was cancelled because its parent Pi session is no longer active.",
145
- documents: [],
146
- error: "Owning parent Pi session is no longer active.",
147
- ...(task.currentAttemptId ? { attemptId: task.currentAttemptId } : {}),
148
- reportedAt: new Date().toISOString(),
149
- } as const;
150
- await persistFailureReport(canonicalReportFile, cancelledReport, true);
151
- if (currentAttemptReport) await writeJsonAtomic(currentAttemptReport, cancelledReport);
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;
152
151
  }
153
- applyTaskStatus(task, "cancelled", { error: "Owning parent Pi session is no longer active." });
154
- result.cancelledTasks += 1;
155
- result.interruptedTasks += 1;
156
- } else if (!differentOwner && (task.status === "running" || task.status === "starting")) {
157
- let childIsLive = false;
158
- if (task.paneId) {
159
- try { childIsLive = await herdr.isTaskRunning(task.paneId); }
160
- catch (error) {
161
- result.cleanupErrors.push(`Could not inspect Subagent pane ${task.paneId}: ${error instanceof Error ? error.message : String(error)}`);
162
- childIsLive = true;
152
+ delete run.resume.active[taskId];
153
+ }
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;
163
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; }
164
192
  }
165
- if (childIsLive) result.liveTasks += 1;
166
- else {
167
- applyTaskStatus(task, "cancelled", { error: "Child pane disappeared before a structured report was recorded." });
168
- result.cancelledTasks += 1;
169
- result.interruptedTasks += 1;
170
- }
193
+ await writeJsonAtomic(join(entry.directory, "task.json"), task);
171
194
  }
172
- await writeJsonAtomic(taskFile, task);
173
- }
174
- statuses.push(task.status);
175
- }
176
- if (unreadableTasks > 0) {
177
- const message = `Could not reconcile ${unreadableTasks} unreadable task record${unreadableTasks === 1 ? "" : "s"}.`;
178
- applyRunStatus(run, "failed", { error: message });
179
- await writeJsonAtomic(runFile, run);
180
- await closeRunTab(run, runFile, herdr, result.cleanupErrors);
181
- result.interruptedTasks += unreadableTasks;
182
- continue;
183
- }
184
- if (!statuses.some((status) => status === "running" || status === "starting")) {
185
- applyRunStatus(run, aggregateTaskStatus(statuses));
186
- await writeJsonAtomic(runFile, run);
187
- 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;
188
207
  }
208
+ if (claim) await closeRunTab(latest, runFile, herdr, result.cleanupErrors, claim);
209
+ else if (closeRegular) await closeRunTab(latest, runFile, herdr, result.cleanupErrors);
189
210
  }
190
211
  return result;
191
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,17 +17,34 @@ 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;
23
24
  error?: string;
24
25
  question?: string;
25
- failureKind?: "missing_input";
26
- requiredInput?: string;
27
26
  reportedAt?: string;
28
27
  }
29
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
+
30
48
  export interface RunRecord {
31
49
  id: string;
32
50
  label: string;
@@ -47,10 +65,9 @@ export interface RunRecord {
47
65
  staleTabIds?: string[];
48
66
  question?: string;
49
67
  error?: string;
50
- failureKind?: "missing_input";
51
- requiredInput?: string;
52
68
  cleanupError?: string;
53
69
  cleanupPendingTabIds?: string[];
70
+ resume?: ResumeState;
54
71
  }
55
72
 
56
73
  export interface TaskRecord {
@@ -80,6 +97,9 @@ export interface TaskRecord {
80
97
  attempt?: number;
81
98
  currentTurnFile?: string;
82
99
  currentAttemptId?: string;
100
+ runtimeState?: TaskRuntimeState;
101
+ runtimeDiagnostic?: string;
102
+ runtimeUpdatedAt?: string;
83
103
  question?: string;
84
104
  error?: string;
85
105
  }
@@ -97,6 +117,9 @@ export interface TaskActivity {
97
117
  elapsedMs?: number;
98
118
  completedAt?: string;
99
119
  updatedAt?: string;
120
+ runtimeState?: TaskRuntimeState;
121
+ runtimeDiagnostic?: string;
122
+ runtimeUpdatedAt?: string;
100
123
  }
101
124
 
102
125
  export function projectTaskActivity(task: TaskRecord, index = task.order - 1): TaskActivity {
@@ -113,6 +136,9 @@ export function projectTaskActivity(task: TaskRecord, index = task.order - 1): T
113
136
  ...(typeof task.elapsedMs === "number" ? { elapsedMs: task.elapsedMs } : {}),
114
137
  ...(task.completedAt ? { completedAt: task.completedAt } : {}),
115
138
  ...(task.updatedAt ? { updatedAt: task.updatedAt } : {}),
139
+ ...(task.runtimeState ? { runtimeState: task.runtimeState } : {}),
140
+ ...(task.runtimeDiagnostic ? { runtimeDiagnostic: task.runtimeDiagnostic } : {}),
141
+ ...(task.runtimeUpdatedAt ? { runtimeUpdatedAt: task.runtimeUpdatedAt } : {}),
116
142
  };
117
143
  }
118
144
 
@@ -172,20 +198,16 @@ export async function readTurnRecord(path: string): Promise<TurnRecord> {
172
198
  export function parseReport(value: unknown, source = "structured report"): PersistedReport {
173
199
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Expected a JSON object in ${source}.`);
174
200
  const report = value as Record<string, unknown>;
175
- 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}.`);
176
202
  if (typeof report.summary !== "string" || !report.summary.trim()) throw new Error(`Missing report summary in ${source}.`);
177
203
  if (!Array.isArray(report.documents) || !report.documents.every((document) => {
178
204
  if (!document || typeof document !== "object" || Array.isArray(document)) return false;
179
205
  const entry = document as Record<string, unknown>;
180
206
  return typeof entry.path === "string" && typeof entry.description === "string";
181
207
  })) throw new Error(`Invalid report documents in ${source}.`);
182
- 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}.`);
183
209
  if (report.error !== undefined && typeof report.error !== "string") throw new Error(`Invalid report error in ${source}.`);
184
210
  if (report.question !== undefined && typeof report.question !== "string") throw new Error(`Invalid report question in ${source}.`);
185
- if (report.failureKind !== undefined && report.failureKind !== "missing_input") throw new Error(`Invalid report failureKind in ${source}.`);
186
- if (report.requiredInput !== undefined && (typeof report.requiredInput !== "string" || !report.requiredInput.trim())) throw new Error(`Invalid report requiredInput in ${source}.`);
187
- if (report.failureKind === "missing_input" && report.status !== "failed") throw new Error(`missing_input must be a failed report in ${source}.`);
188
- if (report.failureKind === "missing_input" && (typeof report.requiredInput !== "string" || !report.requiredInput.trim())) throw new Error(`Missing report requiredInput in ${source}.`);
189
211
  if (report.attemptId !== undefined && (typeof report.attemptId !== "string" || !report.attemptId.trim())) throw new Error(`Invalid report attemptId in ${source}.`);
190
212
  return report as unknown as PersistedReport;
191
213
  }
@@ -194,18 +216,6 @@ export async function readReport(path: string): Promise<PersistedReport> {
194
216
  return parseReport(await readObject(path), path);
195
217
  }
196
218
 
197
- export function normalizeNeedsInputReport(report: PersistedReport, effectiveConcurrency: number): PersistedReport {
198
- if (report.status !== "needs-input" || effectiveConcurrency <= 1) return report;
199
- return parseReport({
200
- ...report,
201
- status: "failed",
202
- error: `Missing required input: ${report.question}`,
203
- failureKind: "missing_input",
204
- requiredInput: report.question,
205
- question: undefined,
206
- }, "normalized concurrent input request");
207
- }
208
-
209
219
  export async function persistFailureReport(path: string, report: PersistedReport, replaceExisting = false): Promise<string | undefined> {
210
220
  if (replaceExisting) {
211
221
  await writeJsonAtomic(path, parseReport(report, "generated failure report"));
@@ -270,6 +280,7 @@ export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
270
280
  const status = options.cancelled ? "cancelled" : "failed";
271
281
  const at = options.at ?? new Date().toISOString();
272
282
  applyTaskStatus(record, status, { error: options.error, at });
283
+ if ("order" in record) clearTaskRuntime(record);
273
284
  const report: PersistedReport = {
274
285
  status: "failed",
275
286
  summary: options.summary ?? `${options.name} ${options.cancelled ? "was cancelled" : "failed"}.`,
@@ -281,14 +292,21 @@ export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
281
292
  return { status, report };
282
293
  }
283
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
+
284
308
  export function applyReport(record: TaskRecord | TurnRecord, report: PersistedReport, completedAt = new Date().toISOString()): "blocked" | "completed" | "failed" {
285
- const status = report.status === "needs-input" ? "blocked" : report.status;
286
- applyTaskStatus(record, status, { at: completedAt, question: report.question, error: report.error });
287
- if ("order" in record) {
288
- if (report.failureKind) record.failureKind = report.failureKind;
289
- else delete record.failureKind;
290
- if (report.requiredInput) record.requiredInput = report.requiredInput;
291
- else delete record.requiredInput;
292
- }
293
- 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;
294
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
+ }