@maxiaochao/pi-toolkit 0.10.14 → 0.10.17
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 +13 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +8 -0
- package/packages/herdr-subagents/README.md +6 -4
- package/packages/herdr-subagents/TESTING.md +3 -1
- package/packages/herdr-subagents/extensions/subagent-report.ts +48 -8
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/attempts.ts +21 -0
- package/packages/herdr-subagents/src/batch-runner.ts +11 -17
- package/packages/herdr-subagents/src/blocking.ts +47 -7
- package/packages/herdr-subagents/src/continue-run.ts +5 -3
- package/packages/herdr-subagents/src/history.ts +5 -3
- package/packages/herdr-subagents/src/live-agent.ts +49 -7
- package/packages/herdr-subagents/src/reconcile.ts +22 -11
- package/packages/herdr-subagents/src/records.ts +3 -0
- package/packages/herdr-subagents/src/result-path.ts +4 -0
- package/packages/herdr-subagents/src/run-status.ts +3 -3
- package/packages/herdr-subagents/src/runner.ts +4 -14
- package/packages/herdr-subagents/src/tab-cleanup.ts +6 -4
- package/packages/herdr-subagents/src/tool.ts +5 -3
- package/packages/herdr-subagents/tests/run-tests.mjs +115 -16
- package/skills/tapd/SKILL.md +65 -467
- package/skills/tapd/references/attachment-download.md +48 -0
- package/skills/tapd/scripts/extract-download-url.mjs +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 2026-10-10 - v0.10.17
|
|
2
|
+
|
|
3
|
+
- bundle herdr-subagents v0.4.5: fix transient retry lifecycle, attempt isolation, and recovery semantics
|
|
4
|
+
- bundle Herdr Subagents lifecycle and attempt-isolation fixes so provider retries cannot create false failed tasks or overwrite newer attempts
|
|
5
|
+
|
|
6
|
+
## 2026-10-10 - v0.10.16
|
|
7
|
+
|
|
8
|
+
- tapd skill: handle large attachment timeouts without misdiagnosing download failures
|
|
9
|
+
|
|
10
|
+
## 2026-10-09 - v0.10.15
|
|
11
|
+
|
|
12
|
+
- redesign the TAPD skill around executable workflows, explicit workspace and JSON contracts, and safe attachment handling
|
|
13
|
+
|
|
1
14
|
## 2026-10-09 - v0.10.14
|
|
2
15
|
|
|
3
16
|
- bundle worktree v0.3.2: continue Agent tasks automatically after switching into a worktree
|
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ bash scripts/install.sh
|
|
|
87
87
|
| `pdlog` | Decompress and inspect Pudu `.pdlog` files |
|
|
88
88
|
| `herdr` | Control Herdr workspaces, tabs, panes, and agents |
|
|
89
89
|
| `agent-team` | Run tool-agnostic, cross-vendor adversarial review with independent concurrent agents |
|
|
90
|
-
| `tapd` |
|
|
90
|
+
| `tapd` | Investigate and operate on TAPD items with workspace, evidence, and safe-download guardrails |
|
|
91
91
|
|
|
92
92
|
Package-specific skills, such as `pi-worktree`, are documented by their child package.
|
|
93
93
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.17",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2026-10-10 - v0.4.5
|
|
2
|
+
|
|
3
|
+
- fix transient retry lifecycle, attempt isolation, and recovery semantics
|
|
4
|
+
- prevent transient Herdr states from finalizing live child attempts
|
|
5
|
+
- Report authoritative child Pi lifecycle state even under `--no-extensions`, wait through provider retry/fallback until the matching structured report arrives, and bind every report to a unique attempt ID.
|
|
6
|
+
- Reconcile matching attempt reports after interruption, serialize blocked responses, keep blocked results non-error, and preserve business outcomes when tab cleanup fails.
|
|
7
|
+
- Aggregate mixed failed/cancelled task sets as partial instead of hiding one terminal outcome.
|
|
8
|
+
|
|
1
9
|
## 2026-10-09 - v0.4.4
|
|
2
10
|
|
|
3
11
|
- return failed foreground batches early while surviving subagents continue in background
|
|
@@ -82,6 +82,8 @@ A child missing required information reports `needs-input` with an exact questio
|
|
|
82
82
|
herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Blocked is a normal control state, not a failed tool call. Only terminal failure, cancellation, or a partial aggregate is returned as a tool error.
|
|
86
|
+
|
|
85
87
|
Cancel one active or queued run with `herdr_subagents_control({ action: "cancel", runId: "<run-id>" })`. Cancellation aborts its live child prompts, archives active or queued tasks as cancelled, closes its run tab, and leaves unrelated runs alone.
|
|
86
88
|
|
|
87
89
|
While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, status, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and refreshes session usage from both file events and the running one-second timer.
|
|
@@ -96,13 +98,13 @@ Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({
|
|
|
96
98
|
|
|
97
99
|
Read-only tasks start FIFO up to the configured concurrency limit. A pane is created only when its task starts, belongs exclusively to that task, and is never reused. Completed panes remain inspectable while another task in the batch is unsettled. Results remain in input order, and one failed task does not cancel its siblings. Any batch containing a write-capable persona is forced to concurrency one.
|
|
98
100
|
|
|
99
|
-
The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
|
|
101
|
+
The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning without changing the completed/failed business result. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
|
|
100
102
|
|
|
101
|
-
Foreground calls wait until all children report completion or a request for input when no task fails. On the first task failure, they return early as described above while the surviving children continue. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
|
|
103
|
+
Foreground calls wait until all children report completion or a request for input when no task fails. On the first task failure, they return early as described above while the surviving children continue. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane. The child extension reports Pi's authoritative `agent_start`/`agent_settled` lifecycle to Herdr, so provider retry and fallback screens cannot end a task early through screen detection alone.
|
|
102
104
|
|
|
103
105
|
## Reports and records
|
|
104
106
|
|
|
105
|
-
The child must finish through the package's structured `subagent_report` tool. The parent receives only:
|
|
107
|
+
The child must finish through the package's structured `subagent_report` tool. Every prompt gets a unique attempt ID; the child commits to an attempt-specific report, and the parent accepts only the matching report before updating the canonical task result. A late report from an older retry, response, or historical follow-up cannot settle a newer attempt. The parent receives only:
|
|
106
108
|
|
|
107
109
|
- completion or failure status
|
|
108
110
|
- a concise summary
|
|
@@ -115,7 +117,7 @@ The complete result is not copied into the parent model context. Each run is ret
|
|
|
115
117
|
.pi/herdr-subagents/runs/<run>/tasks/<task>/
|
|
116
118
|
```
|
|
117
119
|
|
|
118
|
-
The task directory contains the full result, structured report, task metadata, system prompt, and persistent Pi session. Run-level metadata is stored in the parent run directory.
|
|
120
|
+
The task directory contains the full result, canonical structured report, attempt-scoped reports and lifecycle markers, task metadata, system prompt, and persistent Pi session. Run-level metadata is stored in the parent run directory.
|
|
119
121
|
|
|
120
122
|
## Configuration and personas
|
|
121
123
|
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
- Verify selected active/queued run cancellation, blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, idempotent missing-tab cleanup, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
|
|
28
28
|
- Verify `status(runId)` isolates one run, projects queue-only state, prefers durable state over a stale queue record, exposes blocked questions, marks terminal states settled, rejects unknown IDs, and leaves full `history` semantics unchanged.
|
|
29
29
|
- Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
|
|
30
|
+
- Simulate a false screen-settled return while the authoritative Pi lifecycle remains working, then write the report later and verify the task completes without a transient failed state.
|
|
31
|
+
- Verify reports are bound to unique attempt IDs, blocked results are not tool errors, duplicate blocked responses are serialized, mixed failed/cancelled aggregates remain partial, and cleanup failures never overwrite business outcomes.
|
|
30
32
|
|
|
31
33
|
## Latest baseline
|
|
32
34
|
|
|
33
|
-
All
|
|
35
|
+
All 194 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, lifecycle, attempt-isolation, and persona cases plus the extension-contract lifecycle simulations must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed a read-only package inspection with matching durable attempt/report IDs and automatic tab cleanup.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
-
import
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
3
4
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import { Type } from "typebox";
|
|
6
|
-
import {
|
|
7
|
+
import { resultPathForAttemptId } from "../src/result-path.ts";
|
|
7
8
|
import { PARENT_PANE_ENV } from "../src/live-agent.ts";
|
|
9
|
+
import { attemptReportPath, attemptStatePath, requireAttemptId, type AttemptState } from "../src/attempts.ts";
|
|
8
10
|
|
|
9
11
|
const RETURN_SHORTCUT = "alt+p";
|
|
10
12
|
|
|
@@ -33,12 +35,52 @@ const ReportParams = Type.Object({
|
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
function writeAtomic(path: string, content: string): void {
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
36
39
|
const temporary = `${path}.tmp-${process.pid}`;
|
|
37
40
|
writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
38
41
|
renameSync(temporary, path);
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
function currentAttemptId(taskDirectory: string): string {
|
|
45
|
+
const task = JSON.parse(readFileSync(resolve(taskDirectory, "task.json"), "utf8")) as { currentAttemptId?: unknown };
|
|
46
|
+
return requireAttemptId(task.currentAttemptId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeAttemptState(taskDirectory: string, attemptId: string, state: AttemptState): void {
|
|
50
|
+
writeAtomic(attemptStatePath(taskDirectory, attemptId), `${JSON.stringify({ attemptId, state, at: new Date().toISOString() }, null, 2)}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let herdrReportSeq = Date.now() * 1000;
|
|
54
|
+
|
|
55
|
+
function reportHerdrState(state: "working" | "blocked" | "idle", message?: string): void {
|
|
56
|
+
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
57
|
+
const paneId = process.env.HERDR_PANE_ID;
|
|
58
|
+
if (process.env.HERDR_ENV !== "1" || !socketPath || !paneId) return;
|
|
59
|
+
const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
|
60
|
+
const socket = net.createConnection(endpoint);
|
|
61
|
+
socket.on("error", () => undefined);
|
|
62
|
+
socket.on("connect", () => socket.end(`${JSON.stringify({ id: `herdr:subagent:${Date.now()}:${Math.random().toString(36).slice(2)}`, method: "pane.report_agent", params: { pane_id: paneId, source: "herdr:pi", agent: "pi", state, message, seq: ++herdrReportSeq } })}\n`));
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
export default function (pi: ExtensionAPI) {
|
|
66
|
+
let activeAttemptId: string | undefined;
|
|
67
|
+
let blockedCount = 0;
|
|
68
|
+
pi.on("before_agent_start", async () => {
|
|
69
|
+
const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
|
|
70
|
+
if (!taskDirectory) return;
|
|
71
|
+
activeAttemptId = currentAttemptId(taskDirectory);
|
|
72
|
+
writeAttemptState(taskDirectory, activeAttemptId, "working");
|
|
73
|
+
});
|
|
74
|
+
pi.on("agent_start", () => reportHerdrState("working"));
|
|
75
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
76
|
+
const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
|
|
77
|
+
if (taskDirectory && activeAttemptId) writeAttemptState(taskDirectory, activeAttemptId, "settled");
|
|
78
|
+
if (ctx?.isIdle?.() === true) reportHerdrState("idle");
|
|
79
|
+
});
|
|
80
|
+
pi.events.on("herdr:blocked", (data: { active?: boolean; label?: string }) => {
|
|
81
|
+
blockedCount = Math.max(0, blockedCount + (data?.active ? 1 : -1));
|
|
82
|
+
reportHerdrState(blockedCount > 0 ? "blocked" : "working", blockedCount > 0 ? data?.label : undefined);
|
|
83
|
+
});
|
|
42
84
|
pi.registerCommand("parent", {
|
|
43
85
|
description: "Return focus to the parent Agent pane",
|
|
44
86
|
async handler(_args, ctx) { await returnToParent(pi, ctx); },
|
|
@@ -58,12 +100,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
58
100
|
async execute(_toolCallId, params) {
|
|
59
101
|
const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
|
|
60
102
|
if (!taskDirectory) throw new Error("PI_HERDR_SUBAGENTS_TASK_DIR is not set");
|
|
103
|
+
const attemptId = activeAttemptId ?? currentAttemptId(taskDirectory);
|
|
61
104
|
mkdirSync(taskDirectory, { recursive: true, mode: 0o700 });
|
|
62
|
-
|
|
63
|
-
const task = JSON.parse(readFileSync(resolve(taskDirectory, "task.json"), "utf8")) as { attempt?: unknown };
|
|
64
|
-
if (Number.isInteger(task.attempt) && Number(task.attempt) > 1) attempt = Number(task.attempt);
|
|
65
|
-
const resultPath = resultPathForAttempt(taskDirectory, attempt);
|
|
66
|
-
if (attempt > 1) mkdirSync(resolve(taskDirectory, "turns"), { recursive: true, mode: 0o700 });
|
|
105
|
+
const resultPath = resultPathForAttemptId(taskDirectory, attemptId);
|
|
67
106
|
writeAtomic(resultPath, params.result);
|
|
68
107
|
const documents = (params.documents ?? []).map((document) => ({
|
|
69
108
|
path: resolve(process.cwd(), document.path),
|
|
@@ -73,6 +112,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
73
112
|
documents.unshift({ path: resultPath, description: "Complete task result" });
|
|
74
113
|
}
|
|
75
114
|
const report = {
|
|
115
|
+
attemptId,
|
|
76
116
|
status: params.status,
|
|
77
117
|
summary: params.summary,
|
|
78
118
|
documents,
|
|
@@ -80,7 +120,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
80
120
|
...(params.question ? { question: params.question } : {}),
|
|
81
121
|
reportedAt: new Date().toISOString(),
|
|
82
122
|
};
|
|
83
|
-
writeAtomic(
|
|
123
|
+
writeAtomic(attemptReportPath(taskDirectory, attemptId), `${JSON.stringify(report, null, 2)}\n`);
|
|
84
124
|
return {
|
|
85
125
|
content: [{ type: "text" as const, text: `${params.status}: ${params.summary}` }],
|
|
86
126
|
details: report,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const ATTEMPT_ID = /^[a-f0-9-]{36}$/i;
|
|
5
|
+
|
|
6
|
+
export type AttemptState = "working" | "settled";
|
|
7
|
+
|
|
8
|
+
export function createAttemptId(): string { return randomUUID(); }
|
|
9
|
+
|
|
10
|
+
export function requireAttemptId(value: unknown): string {
|
|
11
|
+
if (typeof value !== "string" || !ATTEMPT_ID.test(value)) throw new Error("Subagent attempt has no valid attempt ID.");
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function attemptReportPath(taskDirectory: string, attemptId: string): string {
|
|
16
|
+
return join(taskDirectory, "reports", `${requireAttemptId(attemptId)}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function attemptStatePath(taskDirectory: string, attemptId: string): string {
|
|
20
|
+
return join(taskDirectory, "attempts", `${requireAttemptId(attemptId)}.json`);
|
|
21
|
+
}
|
|
@@ -13,7 +13,7 @@ import { RunPaneAllocator } from "./run-pane-allocator.ts";
|
|
|
13
13
|
import { applyReport, applyTaskStatus, projectTaskActivity, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
14
14
|
import { reportProtocolPrompt } from "./protocol.ts";
|
|
15
15
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
16
|
-
import { applyRunStatus } from "./run-status.ts";
|
|
16
|
+
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
17
17
|
import { isAbortError } from "./errors.ts";
|
|
18
18
|
|
|
19
19
|
export interface BatchTask {
|
|
@@ -150,6 +150,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
150
150
|
emit("running", `${settled}/${tasks.length} tasks settled.`);
|
|
151
151
|
const prompted = await promptLiveAgent({ herdr, task: { ...task, id: context.taskId, sessionFile: context.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: context.systemPromptFile, agentName, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => emit("running", `${task.name} appears stalled; it is still running.`) });
|
|
152
152
|
agentName = prompted.agentName;
|
|
153
|
+
context.taskRecord.currentAttemptId = prompted.attemptId;
|
|
153
154
|
report = prompted.report;
|
|
154
155
|
status = applyReport(context.taskRecord, report);
|
|
155
156
|
} catch (error) {
|
|
@@ -190,9 +191,10 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
190
191
|
const settledResults = results.filter(Boolean);
|
|
191
192
|
const blocked = settledResults.find((result) => result.status === "blocked");
|
|
192
193
|
const failed = settledResults.filter((result) => result.status === "failed").length;
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
194
|
+
const completed = settledResults.filter((result) => result.status === "completed").length;
|
|
195
|
+
const cancelled = settledResults.filter((result) => result.status === "cancelled").length;
|
|
196
|
+
const status = aggregateTaskStatus(activity.map((entry) => entry.status)) as BatchResult["status"];
|
|
197
|
+
const summary = status === "cancelled" ? "Subagent batch cancelled." : blocked ? `${blocked.name} needs input: ${blocked.question}` : status === "partial" ? `${completed}/${tasks.length} tasks completed; ${failed} failed; ${cancelled} cancelled.` : failed ? `${completed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
|
|
196
198
|
applyRunStatus(runRecord, status, { question: blocked?.question });
|
|
197
199
|
await writeJsonAtomic(runFile, runRecord);
|
|
198
200
|
return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId };
|
|
@@ -203,19 +205,11 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
203
205
|
} finally {
|
|
204
206
|
const keepTab = !options.signal?.aborted && activity.some((entry) => entry.status === "blocked" || entry.status === "queued" || entry.status === "running");
|
|
205
207
|
if (tabId && !keepTab) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
});
|
|
212
|
-
} catch (error) {
|
|
213
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
214
|
-
const cleanupError = new Error(`Task results settled, but Herdr tab cleanup failed: ${message}`) as Error & { recordDirectory: string; runId: string };
|
|
215
|
-
cleanupError.recordDirectory = runDirectory;
|
|
216
|
-
cleanupError.runId = runId;
|
|
217
|
-
throw cleanupError;
|
|
218
|
-
}
|
|
208
|
+
await cleanupRunTab({
|
|
209
|
+
herdr, tabId, runFile, runRecord, signal: options.signal,
|
|
210
|
+
errorPrefix: "Task results settled, but Herdr tab cleanup failed",
|
|
211
|
+
onError: (message) => options.onCleanupError?.(message),
|
|
212
|
+
});
|
|
219
213
|
}
|
|
220
214
|
}
|
|
221
215
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { mkdir, rename } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
|
|
5
6
|
import { continueQueuedRun } from "./continue-run.ts";
|
|
@@ -7,7 +8,7 @@ import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
|
|
|
7
8
|
import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
|
|
8
9
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
9
10
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
10
|
-
import { ownerRecord } from "./ownership.ts";
|
|
11
|
+
import { isProcessAlive, ownerRecord } from "./ownership.ts";
|
|
11
12
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
12
13
|
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, projectTaskActivity, readReport, readRunRecord, readTaskRecord, readTurnRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus, type TurnRecord } from "./records.ts";
|
|
13
14
|
import { isAbortError } from "./errors.ts";
|
|
@@ -32,6 +33,36 @@ export interface ResumeBlockedResult {
|
|
|
32
33
|
tabId?: string;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
interface ResponseLock { token?: string; processId?: unknown; sessionId?: unknown; createdAt?: unknown; }
|
|
37
|
+
|
|
38
|
+
async function readResponseLock(lockFile: string): Promise<ResponseLock | undefined> {
|
|
39
|
+
try {
|
|
40
|
+
const lock = JSON.parse(await readFile(lockFile, "utf8")) as unknown;
|
|
41
|
+
return lock && typeof lock === "object" ? lock as ResponseLock : undefined;
|
|
42
|
+
} catch { return undefined; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function releaseResponseLock(lockFile: string, token: string): Promise<void> {
|
|
46
|
+
if ((await readResponseLock(lockFile))?.token !== token) return;
|
|
47
|
+
try { await unlink(lockFile); }
|
|
48
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function acquireResponseLock(lockFile: string, runId: string, owner?: OwnerIdentity): Promise<string> {
|
|
52
|
+
const token = randomUUID();
|
|
53
|
+
try {
|
|
54
|
+
await writeFile(lockFile, `${JSON.stringify({ token, processId: process.pid, sessionId: owner?.sessionId, createdAt: new Date().toISOString() })}\n`, { flag: "wx", mode: 0o600 });
|
|
55
|
+
return token;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
58
|
+
const lock = await readResponseLock(lockFile);
|
|
59
|
+
if (lock && Number.isInteger(lock.processId) && !isProcessAlive(lock.processId)) {
|
|
60
|
+
throw new Error(`Subagent run '${runId}' has a stale blocked-response lock from process ${lock.processId}; remove ${lockFile} and retry.`);
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Subagent run '${runId}' already has a blocked response in progress (lock: ${lockFile}).`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
35
66
|
async function emitProgress(options: RespondToBlockedTaskOptions, runDirectory: string, run: RunRecord, taskDirectories: string[], summary: string): Promise<void> {
|
|
36
67
|
if (!options.onUpdate) return;
|
|
37
68
|
const tasks = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
|
|
@@ -71,7 +102,7 @@ async function projectSettledRun(runDirectory: string, runFile: string, run: Run
|
|
|
71
102
|
run = await readRunRecord(runFile);
|
|
72
103
|
applyRunStatus(run, runStatus, { question: blockedReport?.question });
|
|
73
104
|
await writeJsonAtomic(runFile, run);
|
|
74
|
-
if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError
|
|
105
|
+
if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError });
|
|
75
106
|
const summary = runStatus === "completed" ? "All tasks completed after the answer." : runStatus === "blocked" ? `${finalTasks[blockedIndex]?.name ?? "Task"} needs input: ${blockedReport?.question ?? "Input required."}` : finalReports.map((entry, index) => `${finalTasks[index].name}: ${entry.summary}`).join("\n");
|
|
76
107
|
const activity = finalTasks.map((entry, index) => projectTaskActivity(entry, index));
|
|
77
108
|
const tasks = finalTasks.map((entry, index) => ({ index, name: activity[index].name, status: entry.status, summary: String(finalReports[index].summary ?? "Task has no report."), documents: finalReports[index].documents ?? [], ...(entry.error ? { error: String(entry.error) } : {}), ...(entry.question ? { question: String(entry.question) } : {}), paneId: String(entry.paneId ?? ""), recordDirectory: taskDirectories[index], sessionFile: String(entry.sessionFile ?? "") }));
|
|
@@ -79,11 +110,18 @@ async function projectSettledRun(runDirectory: string, runFile: string, run: Run
|
|
|
79
110
|
}
|
|
80
111
|
|
|
81
112
|
export async function respondToBlockedTask(cwd: string, runId: string, answer: string, options: RespondToBlockedTaskOptions = {}): Promise<ResumeBlockedResult> {
|
|
82
|
-
const herdr = options.herdr ?? new CliHerdrAutomation();
|
|
83
|
-
const { taskNumber, signal, onCleanupError } = options;
|
|
84
113
|
const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
|
|
85
114
|
const runDirectory = await findRunDirectory(runsDirectory, runId);
|
|
86
115
|
if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
|
|
116
|
+
const lockFile = join(runDirectory, ".respond.lock");
|
|
117
|
+
const lockToken = await acquireResponseLock(lockFile, runId, options.owner);
|
|
118
|
+
try { return await respondToBlockedTaskLocked(cwd, runId, answer, runDirectory, options); }
|
|
119
|
+
finally { await releaseResponseLock(lockFile, lockToken); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function respondToBlockedTaskLocked(cwd: string, runId: string, answer: string, runDirectory: string, options: RespondToBlockedTaskOptions): Promise<ResumeBlockedResult> {
|
|
123
|
+
const herdr = options.herdr ?? new CliHerdrAutomation();
|
|
124
|
+
const { taskNumber, signal, onCleanupError } = options;
|
|
87
125
|
const runFile = join(runDirectory, "run.json");
|
|
88
126
|
const run = await readRunRecord(runFile);
|
|
89
127
|
const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
|
|
@@ -131,7 +169,9 @@ export async function respondToBlockedTask(cwd: string, runId: string, answer: s
|
|
|
131
169
|
let status: "blocked" | "completed" | "failed" | "cancelled";
|
|
132
170
|
let attemptError: unknown;
|
|
133
171
|
try {
|
|
134
|
-
|
|
172
|
+
const prompted = await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: answer, systemPromptFile: resumePrompt, agentName, start: !live, signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) });
|
|
173
|
+
task.currentAttemptId = prompted.attemptId;
|
|
174
|
+
report = prompted.report;
|
|
135
175
|
status = applyReport(task, report);
|
|
136
176
|
} catch (error) {
|
|
137
177
|
attemptError = error;
|
|
@@ -158,7 +198,7 @@ export async function respondToBlockedTask(cwd: string, runId: string, answer: s
|
|
|
158
198
|
await writeJsonAtomic(siblingFile, sibling);
|
|
159
199
|
}
|
|
160
200
|
await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
|
|
161
|
-
if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError
|
|
201
|
+
if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError });
|
|
162
202
|
return { ok: false, runId, label: String(run.label ?? "batch"), status: "cancelled", summary: "Subagent response cancelled.", documents: [], recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
|
|
163
203
|
}
|
|
164
204
|
turn.events.push({ type: "report", status, summary: report.summary, question: report.question, error: report.error, at: new Date().toISOString() });
|
|
@@ -48,7 +48,9 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
|
|
|
48
48
|
Object.assign(task, { tabId: run.tabId, paneId, paneLabel, agentName, attempt: Number(task.attempt ?? 0) + 1, startedAt: new Date().toISOString() });
|
|
49
49
|
await writeJsonAtomic(taskFile, task);
|
|
50
50
|
await options.onUpdate?.();
|
|
51
|
-
const
|
|
51
|
+
const prompted = await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: join(directory, "system-prompt.md"), agentName, signal: options.signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) });
|
|
52
|
+
task.currentAttemptId = prompted.attemptId;
|
|
53
|
+
const report = prompted.report;
|
|
52
54
|
applyReport(task, report);
|
|
53
55
|
} catch (error) {
|
|
54
56
|
await settleAttemptFailure(task, reportFile, {
|
|
@@ -79,9 +81,9 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
|
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
const statuses = await Promise.all(taskDirectories.map(async (directory) => (await readTaskRecord(join(directory, "task.json"))).status));
|
|
82
|
-
applyRunStatus(run,
|
|
84
|
+
applyRunStatus(run, aggregateTaskStatus(statuses));
|
|
83
85
|
await writeJsonAtomic(runFile, run);
|
|
84
86
|
if (options.cleanup !== false && run.status !== "blocked" && run.tabId) {
|
|
85
|
-
await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: options.signal, onError: options.onCleanupError
|
|
87
|
+
await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: options.signal, onError: options.onCleanupError });
|
|
86
88
|
}
|
|
87
89
|
}
|
|
@@ -161,7 +161,9 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
161
161
|
]);
|
|
162
162
|
const runningActivity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
163
163
|
options.onUpdate?.({ runId, label: run.label, status: "running", summary: `${historical.name} is continuing.`, documents: [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity: runningActivity });
|
|
164
|
-
|
|
164
|
+
const prompted = await promptLiveAgent({ herdr, task: { ...task, id: historical.id, sessionFile: historical.sessionFile }, paneId: tab.paneId, prompt, systemPromptFile: followupPrompt, agentName, signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${historical.name} appears stalled; it is still running.`) });
|
|
165
|
+
task.currentAttemptId = prompted.attemptId;
|
|
166
|
+
report = prompted.report;
|
|
165
167
|
status = applyReport(task, report);
|
|
166
168
|
} catch (error) {
|
|
167
169
|
const settled = await settleAttemptFailure(task, join(historical.recordDirectory, "report.json"), {
|
|
@@ -183,7 +185,7 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
183
185
|
Object.assign(runRecord, { ...ownerRecord(options.owner), tabId: tab.tabId });
|
|
184
186
|
await writeJsonAtomic(join(run.recordDirectory, "run.json"), runRecord);
|
|
185
187
|
if (status !== "blocked") {
|
|
186
|
-
await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError
|
|
188
|
+
await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError });
|
|
187
189
|
}
|
|
188
190
|
const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
189
191
|
return { runId, label: run.label, status, summary: report.summary, documents: report.documents ?? [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity };
|
|
@@ -210,7 +212,7 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
210
212
|
]);
|
|
211
213
|
const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
212
214
|
options.onUpdate?.({ runId, label: run.label, status, summary: report.summary, documents: report.documents, tabId: tab?.tabId ?? "", paneId: tab?.paneId ?? "", sessionFile: historical.sessionFile, activity });
|
|
213
|
-
if (tab) await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError
|
|
215
|
+
if (tab) await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError });
|
|
214
216
|
throw error;
|
|
215
217
|
}
|
|
216
218
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readFile,
|
|
2
|
+
import { mkdir, readFile, rename } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import type { HerdrAutomation } from "./herdr.ts";
|
|
6
6
|
import type { HerdrSubagentsReport } from "./runner.ts";
|
|
7
7
|
import { parseReport } from "./records.ts";
|
|
8
|
+
import { attemptReportPath, attemptStatePath, createAttemptId, type AttemptState } from "./attempts.ts";
|
|
9
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
8
10
|
|
|
9
11
|
export interface LiveTaskRecord {
|
|
10
12
|
id: string;
|
|
@@ -15,6 +17,7 @@ export interface LiveTaskRecord {
|
|
|
15
17
|
thinking?: string;
|
|
16
18
|
tools?: string[];
|
|
17
19
|
skills?: string[];
|
|
20
|
+
currentAttemptId?: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export const PARENT_PANE_ENV = "PI_HERDR_SUBAGENTS_PARENT_PANE_ID";
|
|
@@ -61,10 +64,17 @@ export async function promptLiveAgent(options: {
|
|
|
61
64
|
start?: boolean;
|
|
62
65
|
stalledWarningMs?: number;
|
|
63
66
|
onStalled?: () => void;
|
|
64
|
-
}): Promise<{ report: HerdrSubagentsReport; agentName: string }> {
|
|
67
|
+
}): Promise<{ report: HerdrSubagentsReport; agentName: string; attemptId: string }> {
|
|
65
68
|
const taskDirectory = dirname(options.task.sessionFile);
|
|
66
69
|
const reportFile = join(taskDirectory, "report.json");
|
|
67
|
-
|
|
70
|
+
const taskFile = join(taskDirectory, "task.json");
|
|
71
|
+
const attemptId = createAttemptId();
|
|
72
|
+
const attemptReportFile = attemptReportPath(taskDirectory, attemptId);
|
|
73
|
+
const attemptStateFile = attemptStatePath(taskDirectory, attemptId);
|
|
74
|
+
await Promise.all([mkdir(dirname(attemptReportFile), { recursive: true, mode: 0o700 }), mkdir(dirname(attemptStateFile), { recursive: true, mode: 0o700 })]);
|
|
75
|
+
const durableTask = JSON.parse(await readFile(taskFile, "utf8")) as Record<string, unknown>;
|
|
76
|
+
durableTask.currentAttemptId = attemptId;
|
|
77
|
+
await writeJsonAtomic(taskFile, durableTask);
|
|
68
78
|
const agentName = options.agentName ?? liveAgentName(options.task.id, options.attempt);
|
|
69
79
|
if (options.start !== false) {
|
|
70
80
|
await options.herdr.startAgent({
|
|
@@ -80,18 +90,50 @@ export async function promptLiveAgent(options: {
|
|
|
80
90
|
try { await options.herdr.promptAgent({ target: agentName, prompt: options.prompt, signal: options.signal }); }
|
|
81
91
|
catch (error) { promptError = error; }
|
|
82
92
|
finally { if (stalledTimer) clearTimeout(stalledTimer); }
|
|
83
|
-
|
|
93
|
+
while (!existsSync(attemptReportFile)) {
|
|
94
|
+
let state: AttemptState | undefined;
|
|
95
|
+
if (existsSync(attemptStateFile)) {
|
|
96
|
+
try {
|
|
97
|
+
const value = JSON.parse(await readFile(attemptStateFile, "utf8")) as { attemptId?: unknown; state?: unknown };
|
|
98
|
+
if (value.attemptId === attemptId && (value.state === "working" || value.state === "settled")) state = value.state;
|
|
99
|
+
} catch { /* a concurrent atomic replacement will be retried */ }
|
|
100
|
+
}
|
|
101
|
+
if (state === "working") {
|
|
102
|
+
try {
|
|
103
|
+
if (!(await options.herdr.isTaskRunning(options.paneId, options.signal))) {
|
|
104
|
+
if (promptError) throw promptError;
|
|
105
|
+
throw new Error("Child Pi stopped while its authoritative attempt state was still working and did not submit a subagent_report.");
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error === promptError || (error instanceof Error && error.message.startsWith("Child Pi stopped while"))) throw error;
|
|
109
|
+
// A transient liveness query is not evidence that the child stopped.
|
|
110
|
+
}
|
|
111
|
+
await new Promise<void>((resolve, reject) => {
|
|
112
|
+
const onAbort = () => { clearTimeout(timer); reject(options.signal?.reason ?? new Error("Subagent attempt aborted.")); };
|
|
113
|
+
const timer = setTimeout(() => { options.signal?.removeEventListener("abort", onAbort); resolve(); }, 100);
|
|
114
|
+
if (options.signal?.aborted) onAbort();
|
|
115
|
+
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
84
119
|
if (promptError) throw promptError;
|
|
85
|
-
throw new Error("Child Pi settled but did not submit a subagent_report.");
|
|
120
|
+
throw new Error(state === "settled" ? "Child Pi settled but did not submit a subagent_report." : "Child Pi stopped without an authoritative lifecycle state and did not submit a subagent_report.");
|
|
86
121
|
}
|
|
87
122
|
let value: unknown;
|
|
88
|
-
try { value = JSON.parse(await readFile(
|
|
123
|
+
try { value = JSON.parse(await readFile(attemptReportFile, "utf8")); }
|
|
89
124
|
catch {
|
|
125
|
+
await rename(attemptReportFile, `${reportFile}.invalid-${Date.now()}`).catch(() => undefined);
|
|
90
126
|
if (promptError) throw new Error(`${promptError instanceof Error ? promptError.message : String(promptError)}; Child Pi also submitted an invalid subagent_report.`);
|
|
91
127
|
throw new Error("Child Pi submitted an invalid subagent_report.");
|
|
92
128
|
}
|
|
93
|
-
try {
|
|
129
|
+
try {
|
|
130
|
+
const report = parseReport(value, "child subagent_report") as HerdrSubagentsReport;
|
|
131
|
+
if (report.attemptId !== attemptId) throw new Error(`Report belongs to attempt '${report.attemptId ?? "unknown"}', expected '${attemptId}'.`);
|
|
132
|
+
await writeJsonAtomic(reportFile, report);
|
|
133
|
+
return { report, agentName, attemptId };
|
|
134
|
+
}
|
|
94
135
|
catch (error) {
|
|
136
|
+
await rename(attemptReportFile, `${reportFile}.invalid-${Date.now()}`).catch(() => undefined);
|
|
95
137
|
const detail = `Child Pi submitted an invalid subagent_report: ${error instanceof Error ? error.message : String(error)}`;
|
|
96
138
|
throw new Error(promptError ? `${promptError instanceof Error ? promptError.message : String(promptError)}; ${detail}` : detail);
|
|
97
139
|
}
|
|
@@ -6,6 +6,7 @@ import { writeJsonAtomic } from "./state.ts";
|
|
|
6
6
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
7
7
|
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, type RunRecord, type TaskStatus } from "./records.ts";
|
|
8
8
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
9
|
+
import { attemptReportPath } from "./attempts.ts";
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
export interface CancellationResult { cancelledRuns: number; cleanupErrors: string[]; }
|
|
@@ -109,24 +110,34 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
|
|
|
109
110
|
continue;
|
|
110
111
|
}
|
|
111
112
|
if (isActiveStatus(task.status)) {
|
|
112
|
-
|
|
113
|
-
|
|
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")) {
|
|
114
124
|
try {
|
|
115
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}'.`);
|
|
116
127
|
applyReport(task, report, report.reportedAt ?? new Date().toISOString());
|
|
128
|
+
recoveredCurrentReport = reportFile !== canonicalReportFile;
|
|
129
|
+
if (reportFile !== canonicalReportFile) await writeJsonAtomic(canonicalReportFile, report);
|
|
117
130
|
result.settledTasks += 1;
|
|
118
131
|
} catch (error) {
|
|
119
132
|
const detail = error instanceof Error ? error.message : String(error);
|
|
120
133
|
result.cleanupErrors.push(`Invalid Subagent report ${reportFile}: ${detail}`);
|
|
121
|
-
await persistFailureReport(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
result.interruptedTasks += 1;
|
|
126
|
-
}
|
|
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;
|
|
127
138
|
}
|
|
128
139
|
}
|
|
129
|
-
if (differentOwner && isActiveStatus(task.status)) {
|
|
140
|
+
if (differentOwner && isActiveStatus(task.status) && !(recoveredCurrentReport && task.status === "blocked")) {
|
|
130
141
|
applyTaskStatus(task, "cancelled", { error: "Owning parent Pi session is no longer active." });
|
|
131
142
|
result.cancelledTasks += 1;
|
|
132
143
|
result.interruptedTasks += 1;
|
|
@@ -158,8 +169,8 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
|
|
|
158
169
|
result.interruptedTasks += unreadableTasks;
|
|
159
170
|
continue;
|
|
160
171
|
}
|
|
161
|
-
if (
|
|
162
|
-
applyRunStatus(run,
|
|
172
|
+
if (!statuses.some((status) => status === "running" || status === "starting")) {
|
|
173
|
+
applyRunStatus(run, aggregateTaskStatus(statuses));
|
|
163
174
|
await writeJsonAtomic(runFile, run);
|
|
164
175
|
if (differentOwner || run.status === "cancelled") await closeRunTab(run, runFile, herdr, result.cleanupErrors);
|
|
165
176
|
}
|