@maxiaochao/pi-toolkit 0.10.15 → 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 +9 -0
- 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/references/attachment-download.md +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
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
|
+
|
|
1
10
|
## 2026-10-09 - v0.10.15
|
|
2
11
|
|
|
3
12
|
- redesign the TAPD skill around executable workflows, explicit workspace and JSON contracts, and safe attachment handling
|
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
|
}
|
|
@@ -19,6 +19,7 @@ export interface PersistedReport {
|
|
|
19
19
|
status: "completed" | "needs-input" | "failed";
|
|
20
20
|
summary: string;
|
|
21
21
|
documents: PersistedDocument[];
|
|
22
|
+
attemptId?: string;
|
|
22
23
|
error?: string;
|
|
23
24
|
question?: string;
|
|
24
25
|
reportedAt?: string;
|
|
@@ -74,6 +75,7 @@ export interface TaskRecord {
|
|
|
74
75
|
agentName?: string;
|
|
75
76
|
attempt?: number;
|
|
76
77
|
currentTurnFile?: string;
|
|
78
|
+
currentAttemptId?: string;
|
|
77
79
|
question?: string;
|
|
78
80
|
error?: string;
|
|
79
81
|
}
|
|
@@ -176,6 +178,7 @@ export function parseReport(value: unknown, source = "structured report"): Persi
|
|
|
176
178
|
if (report.status === "needs-input" && (typeof report.question !== "string" || !report.question.trim())) throw new Error(`Missing report question in ${source}.`);
|
|
177
179
|
if (report.error !== undefined && typeof report.error !== "string") throw new Error(`Invalid report error in ${source}.`);
|
|
178
180
|
if (report.question !== undefined && typeof report.question !== "string") throw new Error(`Invalid report question in ${source}.`);
|
|
181
|
+
if (report.attemptId !== undefined && (typeof report.attemptId !== "string" || !report.attemptId.trim())) throw new Error(`Invalid report attemptId in ${source}.`);
|
|
179
182
|
return report as unknown as PersistedReport;
|
|
180
183
|
}
|
|
181
184
|
|
|
@@ -5,3 +5,7 @@ export function resultPathForAttempt(taskDirectory: string, attempt: number): st
|
|
|
5
5
|
? resolve(taskDirectory, "turns", `${String(attempt).padStart(2, "0")}-result.md`)
|
|
6
6
|
: resolve(taskDirectory, "result.md");
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
export function resultPathForAttemptId(taskDirectory: string, attemptId: string): string {
|
|
10
|
+
return resolve(taskDirectory, "attempt-results", `${attemptId}.md`);
|
|
11
|
+
}
|
|
@@ -7,9 +7,9 @@ export function aggregateTaskStatus(statuses: readonly TaskStatus[]): AggregateR
|
|
|
7
7
|
if (statuses.length === 0) return "failed";
|
|
8
8
|
if (statuses.some((status) => status === "running" || status === "starting")) return "running";
|
|
9
9
|
if (statuses.some((status) => status === "blocked" || status === "queued")) return "blocked";
|
|
10
|
-
|
|
11
|
-
if (statuses.
|
|
12
|
-
if (statuses.some((status) => status === "
|
|
10
|
+
if (statuses.every((status) => status === "cancelled")) return "cancelled";
|
|
11
|
+
if (statuses.every((status) => status === "failed")) return "failed";
|
|
12
|
+
if (statuses.some((status) => status === "cancelled" || status === "failed")) return "partial";
|
|
13
13
|
if (statuses.every((status) => status === "completed")) return "completed";
|
|
14
14
|
throw new Error(`Cannot aggregate unknown task status: ${statuses.join(", ")}`);
|
|
15
15
|
}
|
|
@@ -184,7 +184,9 @@ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsO
|
|
|
184
184
|
await writeJson(taskFile, taskRecord);
|
|
185
185
|
update("starting", "Starting child Pi in Herdr...");
|
|
186
186
|
update("running", "Child Pi is working...");
|
|
187
|
-
const
|
|
187
|
+
const prompted = await promptLiveAgent({ herdr, task: { ...taskRecord, id: taskId, sessionFile }, paneId, prompt, systemPromptFile, agentName: name, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${label} appears stalled; it is still running.`) });
|
|
188
|
+
taskRecord.currentAttemptId = prompted.attemptId;
|
|
189
|
+
const report = prompted.report;
|
|
188
190
|
attempt = { status: applyReport(taskRecord, report), report };
|
|
189
191
|
} catch (error) {
|
|
190
192
|
const settled = await settleAttemptFailure(taskRecord, reportFile, {
|
|
@@ -224,19 +226,7 @@ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsO
|
|
|
224
226
|
}
|
|
225
227
|
} finally {
|
|
226
228
|
if (tabId && result?.status !== "blocked") {
|
|
227
|
-
|
|
228
|
-
await cleanupRunTab({ herdr, tabId, runFile, runRecord, signal: options.signal, onError: options.onCleanupError, failureStatus: "failed" });
|
|
229
|
-
} catch (error) {
|
|
230
|
-
const cleanupError = error instanceof Error ? error.message : String(error);
|
|
231
|
-
if (result?.ok) {
|
|
232
|
-
const errorMessage = `Task completed but Herdr tab cleanup failed: ${cleanupError}`;
|
|
233
|
-
const completedAt = new Date().toISOString();
|
|
234
|
-
applyRunStatus(runRecord, "failed", { at: completedAt, error: errorMessage });
|
|
235
|
-
applyTaskStatus(taskRecord, "failed", { at: completedAt, error: errorMessage });
|
|
236
|
-
await Promise.all([writeJson(runFile, runRecord), writeJson(taskFile, taskRecord)]);
|
|
237
|
-
result = failure(cwd, startedAt, errorMessage, common());
|
|
238
|
-
}
|
|
239
|
-
}
|
|
229
|
+
await cleanupRunTab({ herdr, tabId, runFile, runRecord, signal: options.signal, onError: options.onCleanupError });
|
|
240
230
|
}
|
|
241
231
|
}
|
|
242
232
|
|
|
@@ -15,7 +15,6 @@ export async function cleanupRunTab(options: {
|
|
|
15
15
|
signal?: AbortSignal;
|
|
16
16
|
onError?: (message: string) => void;
|
|
17
17
|
onClosed?: (runRecord: RunRecord) => void | Promise<void>;
|
|
18
|
-
failureStatus?: "failed";
|
|
19
18
|
errorPrefix?: string;
|
|
20
19
|
}): Promise<{ deferred: boolean }> {
|
|
21
20
|
const mergeLatest = async (update: (run: RunRecord) => void | Promise<void>, requirePending = false, clearPending = true) => {
|
|
@@ -34,14 +33,17 @@ export async function cleanupRunTab(options: {
|
|
|
34
33
|
const reportFailure = async (error: unknown, requirePending = false) => {
|
|
35
34
|
const detail = error instanceof Error ? error.message : String(error);
|
|
36
35
|
try {
|
|
37
|
-
await mergeLatest((latest) => Object.assign(latest, { cleanupPendingTabIds: [...new Set([...(latest.cleanupPendingTabIds ?? []), options.tabId])], cleanupError: detail
|
|
36
|
+
await mergeLatest((latest) => Object.assign(latest, { cleanupPendingTabIds: [...new Set([...(latest.cleanupPendingTabIds ?? []), options.tabId])], cleanupError: detail }), requirePending, false);
|
|
38
37
|
} catch (persistError) {
|
|
39
38
|
options.onError?.(`${options.errorPrefix ?? "Herdr tab cleanup failed"}: ${detail}; could not persist cleanup state: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
|
|
40
39
|
return;
|
|
41
40
|
}
|
|
42
41
|
options.onError?.(`${options.errorPrefix ?? "Herdr tab cleanup failed"}: ${detail}`);
|
|
43
42
|
};
|
|
44
|
-
const finishClosed = async (requirePending = false) => mergeLatest(async (latest) =>
|
|
43
|
+
const finishClosed = async (requirePending = false) => mergeLatest(async (latest) => {
|
|
44
|
+
await options.onClosed?.(latest);
|
|
45
|
+
if (!latest.cleanupPendingTabIds?.length) delete latest.cleanupError;
|
|
46
|
+
}, requirePending);
|
|
45
47
|
try {
|
|
46
48
|
if (options.signal?.aborted) {
|
|
47
49
|
await options.herdr.closeTab(options.tabId);
|
|
@@ -78,7 +80,7 @@ export async function cleanupRunTab(options: {
|
|
|
78
80
|
return { deferred: false };
|
|
79
81
|
}
|
|
80
82
|
await reportFailure(error);
|
|
81
|
-
|
|
83
|
+
return { deferred: false };
|
|
82
84
|
}
|
|
83
85
|
return { deferred: false };
|
|
84
86
|
}
|
|
@@ -216,7 +216,8 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
|
|
|
216
216
|
});
|
|
217
217
|
const lines = [result.summary, ...result.tasks.map((task) => `- ${task.name}: ${task.status} — ${task.summary}`)];
|
|
218
218
|
if (result.documents.length) lines.push("", "Documents:", ...result.documents.map((document) => `- ${document.description}: ${document.path}`));
|
|
219
|
-
|
|
219
|
+
const isError = result.status === "failed" || result.status === "partial" || result.status === "cancelled";
|
|
220
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt) }, ...(isError ? { isError: true } : {}) };
|
|
220
221
|
} catch (error) {
|
|
221
222
|
const text = error instanceof Error ? error.message : String(error);
|
|
222
223
|
const diagnostic = error as { recordDirectory?: string; runId?: string };
|
|
@@ -248,10 +249,11 @@ async function executeSingle({ params, signal, onUpdate, ctx, dependencies, disc
|
|
|
248
249
|
herdr: dependencies.herdr,
|
|
249
250
|
stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
|
|
250
251
|
onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "running", stalled: true } }),
|
|
251
|
-
onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "
|
|
252
|
+
onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "warning", cleanupError: text } }),
|
|
252
253
|
onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: details(partial) }),
|
|
253
254
|
});
|
|
254
|
-
|
|
255
|
+
const isError = result.status === "failed" || result.status === "cancelled";
|
|
256
|
+
return { content: [{ type: "text", text: summaryText(result) }], details: details(result), ...(isError ? { isError: true } : {}) };
|
|
255
257
|
}
|
|
256
258
|
|
|
257
259
|
export async function executeHerdrSubagents(
|
|
@@ -56,6 +56,14 @@ function fakeHerdr(mode = "completed") {
|
|
|
56
56
|
let maxActive = 0;
|
|
57
57
|
let taskRuns = 0;
|
|
58
58
|
const agents = new Map();
|
|
59
|
+
const writeAttemptReport = (directory, report) => {
|
|
60
|
+
const task = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
|
|
61
|
+
const attemptId = task.currentAttemptId;
|
|
62
|
+
if (!attemptId) throw new Error("test task has no currentAttemptId");
|
|
63
|
+
const reports = join(directory, "reports");
|
|
64
|
+
mkdirSync(reports, { recursive: true });
|
|
65
|
+
writeFileSync(join(reports, `${attemptId}.json`), typeof report === "string" ? report : JSON.stringify({ attemptId, ...report }));
|
|
66
|
+
};
|
|
59
67
|
return {
|
|
60
68
|
calls,
|
|
61
69
|
get maxActive() { return maxActive; },
|
|
@@ -77,8 +85,33 @@ function fakeHerdr(mode = "completed") {
|
|
|
77
85
|
active += 1;
|
|
78
86
|
maxActive = Math.max(maxActive, active);
|
|
79
87
|
await new Promise((resolve) => setTimeout(resolve, request.prompt.includes("slow") ? 15 : 2));
|
|
88
|
+
if (mode === "delayed-report") {
|
|
89
|
+
const task = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
|
|
90
|
+
const attempts = join(directory, "attempts");
|
|
91
|
+
mkdirSync(attempts, { recursive: true });
|
|
92
|
+
writeFileSync(join(attempts, `${task.currentAttemptId}.json`), JSON.stringify({ attemptId: task.currentAttemptId, state: "working" }));
|
|
93
|
+
setTimeout(() => writeAttemptReport(directory, { status: "completed", summary: "Retry eventually completed.", documents: [] }), 25);
|
|
94
|
+
active -= 1;
|
|
95
|
+
return { status: "idle" };
|
|
96
|
+
}
|
|
97
|
+
if (mode === "working-state-crash") {
|
|
98
|
+
const task = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
|
|
99
|
+
const attempts = join(directory, "attempts");
|
|
100
|
+
mkdirSync(attempts, { recursive: true });
|
|
101
|
+
writeFileSync(join(attempts, `${task.currentAttemptId}.json`), JSON.stringify({ attemptId: task.currentAttemptId, state: "working" }));
|
|
102
|
+
active -= 1;
|
|
103
|
+
throw new Error("child process exited");
|
|
104
|
+
}
|
|
105
|
+
if (mode === "working-state-dead-return") {
|
|
106
|
+
const task = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
|
|
107
|
+
const attempts = join(directory, "attempts");
|
|
108
|
+
mkdirSync(attempts, { recursive: true });
|
|
109
|
+
writeFileSync(join(attempts, `${task.currentAttemptId}.json`), JSON.stringify({ attemptId: task.currentAttemptId, state: "working" }));
|
|
110
|
+
active -= 1;
|
|
111
|
+
return { status: "idle" };
|
|
112
|
+
}
|
|
80
113
|
if (mode === "report-then-post-fail") {
|
|
81
|
-
|
|
114
|
+
writeAttemptReport(directory, { status: "completed", summary: "Authoritative report completed before transport failed.", documents: [] });
|
|
82
115
|
active -= 1;
|
|
83
116
|
throw new Error("connection lost after authoritative report");
|
|
84
117
|
}
|
|
@@ -87,28 +120,28 @@ function fakeHerdr(mode = "completed") {
|
|
|
87
120
|
if (mode === "completed" || mode === "close-failed") {
|
|
88
121
|
const failed = request.prompt.includes("FAIL");
|
|
89
122
|
writeFileSync(join(directory, "result.md"), request.prompt.startsWith("Review") ? "FULL RESULT THAT STAYS OUT OF PARENT CONTEXT" : `FULL ${request.prompt}`);
|
|
90
|
-
|
|
123
|
+
writeAttemptReport(directory, {
|
|
91
124
|
status: failed ? "failed" : "completed",
|
|
92
125
|
summary: request.prompt.startsWith("Review") ? "Authentication review completed." : failed ? `${request.prompt} failed.` : `${request.prompt} completed.`,
|
|
93
126
|
...(failed ? { error: "expected failure" } : {}),
|
|
94
127
|
documents: [{ path: join(directory, "result.md"), description: "Full result" }],
|
|
95
|
-
})
|
|
128
|
+
});
|
|
96
129
|
} else if (mode === "failed") {
|
|
97
130
|
writeFileSync(join(directory, "result.md"), "Provider stopped before completing the review.");
|
|
98
|
-
|
|
131
|
+
writeAttemptReport(directory, {
|
|
99
132
|
status: "failed",
|
|
100
133
|
summary: "Authentication review failed.",
|
|
101
134
|
error: "provider unavailable",
|
|
102
135
|
documents: [],
|
|
103
|
-
})
|
|
136
|
+
});
|
|
104
137
|
} else if (mode === "malformed") {
|
|
105
|
-
|
|
138
|
+
writeAttemptReport(directory, "{truncated");
|
|
106
139
|
} else if (mode === "blocked" || mode === "blocked-twice" || mode === "blocked-then-post-fail") {
|
|
107
140
|
const needsInput = ((mode === "blocked" || mode === "blocked-then-post-fail") && taskRuns === 1) || (mode === "blocked-twice" && (taskRuns === 1 || taskRuns === 3));
|
|
108
141
|
const failed = request.prompt.includes("FAIL");
|
|
109
142
|
const question = taskRuns === 3 ? "Which environment should I inspect?" : "Which branch should I inspect?";
|
|
110
143
|
writeFileSync(join(directory, "result.md"), `FULL ${request.prompt}`);
|
|
111
|
-
|
|
144
|
+
writeAttemptReport(directory, { status: needsInput ? "needs-input" : failed ? "failed" : "completed", summary: needsInput ? "Need more input." : failed ? `${request.prompt} failed.` : `${request.prompt} completed.`, ...(needsInput ? { question } : {}), ...(failed ? { error: "expected failure" } : {}), documents: [] });
|
|
112
145
|
}
|
|
113
146
|
active -= 1;
|
|
114
147
|
return { status: mode === "failed" ? "done" : "idle" };
|
|
@@ -124,7 +157,7 @@ function fakeHerdr(mode = "completed") {
|
|
|
124
157
|
async isTabFocused(tabId) { calls.push(["isTabFocused", { tabId }]); return mode === "focused"; },
|
|
125
158
|
async waitForTabUnfocused(tabId) { calls.push(["waitForTabUnfocused", { tabId }]); },
|
|
126
159
|
async paneExists(paneId) { calls.push(["paneExists", { paneId }]); return mode !== "missing-pane"; },
|
|
127
|
-
async isTaskRunning(paneId) { calls.push(["isTaskRunning", { paneId }]); return mode !== "missing-pane" && mode !== "shell-only"; },
|
|
160
|
+
async isTaskRunning(paneId) { calls.push(["isTaskRunning", { paneId }]); return mode !== "missing-pane" && mode !== "shell-only" && mode !== "working-state-crash" && mode !== "working-state-dead-return"; },
|
|
128
161
|
async closeTab(tabId) {
|
|
129
162
|
calls.push(["closeTab", { tabId }]);
|
|
130
163
|
if (mode === "close-failed") throw new Error("tab still busy");
|
|
@@ -308,6 +341,7 @@ try {
|
|
|
308
341
|
let unknownStatusRejected = false;
|
|
309
342
|
try { aggregateTaskStatus(["unknown"]); } catch { unknownStatusRejected = true; }
|
|
310
343
|
check("task status aggregation rejects unknown states", unknownStatusRejected);
|
|
344
|
+
check("mixed failed and cancelled tasks remain partial", aggregateTaskStatus(["failed", "cancelled"]) === "partial" && aggregateTaskStatus(["failed", "failed"]) === "failed" && aggregateTaskStatus(["cancelled", "cancelled"]) === "cancelled");
|
|
311
345
|
check("all execution paths share abort classification", isAbortError(Object.assign(new Error("stopped"), { name: "AbortError" })) && isAbortError(new Error("operation aborted")) && !isAbortError(new Error("provider unavailable")));
|
|
312
346
|
check("respond releases ownership only for explicit terminal statuses", ["completed", "partial", "failed", "cancelled"].every(isSettledRunStatus) && !isSettledRunStatus("blocked") && !isSettledRunStatus(undefined));
|
|
313
347
|
|
|
@@ -322,11 +356,13 @@ try {
|
|
|
322
356
|
const childEvents = new Map();
|
|
323
357
|
const childExecCalls = [];
|
|
324
358
|
const childStatuses = [];
|
|
359
|
+
let childReportTool;
|
|
325
360
|
registerSubagentReport({
|
|
326
|
-
registerTool() {},
|
|
361
|
+
registerTool(definition) { childReportTool = definition; },
|
|
327
362
|
registerCommand(name, options) { childCommands.set(name, options); },
|
|
328
363
|
registerShortcut(key, options) { childShortcuts.set(key, options); },
|
|
329
364
|
on(name, handler) { childEvents.set(name, handler); },
|
|
365
|
+
events: { on() {} },
|
|
330
366
|
async exec(command, args) { childExecCalls.push([command, args]); return { stdout: "", stderr: "", code: 0, killed: false }; },
|
|
331
367
|
});
|
|
332
368
|
process.env.PI_HERDR_SUBAGENTS_PARENT_PANE_ID = "w1:p-parent";
|
|
@@ -335,6 +371,23 @@ try {
|
|
|
335
371
|
await childCommands.get("parent")?.handler("", childContext);
|
|
336
372
|
await childShortcuts.get("alt+p")?.handler(childContext);
|
|
337
373
|
check("child command and shortcut return to the exact parent Agent pane", childExecCalls.length === 2 && childExecCalls.every(([command, args]) => command === "herdr" && args.join(",") === "agent,focus,w1:p-parent") && childStatuses.some(([, value]) => value.includes("alt+p")));
|
|
374
|
+
const reportToolDirectory = join(root, "child-report-tool");
|
|
375
|
+
const reportAttemptId = "11111111-1111-4111-8111-111111111111";
|
|
376
|
+
mkdirSync(reportToolDirectory, { recursive: true });
|
|
377
|
+
writeFileSync(join(reportToolDirectory, "task.json"), JSON.stringify({ id: "report-task", runId: "report-run", order: 1, status: "running", currentAttemptId: reportAttemptId }));
|
|
378
|
+
process.env.PI_HERDR_SUBAGENTS_TASK_DIR = reportToolDirectory;
|
|
379
|
+
await childEvents.get("before_agent_start")?.({}, childContext);
|
|
380
|
+
await childReportTool.execute("report", { status: "completed", summary: "Attempt completed.", result: "Full attempt result.", documents: [] });
|
|
381
|
+
const scopedReport = JSON.parse(readFileSync(join(reportToolDirectory, "reports", `${reportAttemptId}.json`), "utf8"));
|
|
382
|
+
check("child report commits only to its captured attempt identity", scopedReport.attemptId === reportAttemptId && scopedReport.status === "completed" && !existsSync(join(reportToolDirectory, "report.json")) && JSON.parse(readFileSync(join(reportToolDirectory, "attempts", `${reportAttemptId}.json`), "utf8")).state === "working");
|
|
383
|
+
const firstAttemptResult = scopedReport.documents[0].path;
|
|
384
|
+
const nextReportAttemptId = "33333333-3333-4333-8333-333333333333";
|
|
385
|
+
writeFileSync(join(reportToolDirectory, "task.json"), JSON.stringify({ id: "report-task", runId: "report-run", order: 1, status: "running", currentAttemptId: nextReportAttemptId }));
|
|
386
|
+
await childEvents.get("before_agent_start")?.({}, childContext);
|
|
387
|
+
await childReportTool.execute("report", { status: "completed", summary: "New attempt completed.", result: "New full result.", documents: [] });
|
|
388
|
+
const nextScopedReport = JSON.parse(readFileSync(join(reportToolDirectory, "reports", `${nextReportAttemptId}.json`), "utf8"));
|
|
389
|
+
check("full results are isolated by attempt identity", nextScopedReport.documents[0].path !== firstAttemptResult && readFileSync(firstAttemptResult, "utf8") === "Full attempt result." && readFileSync(nextScopedReport.documents[0].path, "utf8") === "New full result.");
|
|
390
|
+
delete process.env.PI_HERDR_SUBAGENTS_TASK_DIR;
|
|
338
391
|
const herdr = fakeHerdr();
|
|
339
392
|
const success = await runHerdrSubagents("Review authentication", {
|
|
340
393
|
cwd: root,
|
|
@@ -381,6 +434,12 @@ try {
|
|
|
381
434
|
const silentHerdr = fakeHerdr("silent");
|
|
382
435
|
const silent = await runHerdrSubagents("Forget report", { cwd: root, herdr: silentHerdr });
|
|
383
436
|
check("missing report is visible failure", !silent.ok && silent.errorMessage?.includes("did not submit") && silentHerdr.calls.at(-1)[0] === "closeTab");
|
|
437
|
+
const delayedReport = await runHerdrSubagents("Retry after a transient provider error", { cwd: root, herdr: fakeHerdr("delayed-report") });
|
|
438
|
+
check("authoritative lifecycle waits for a report after a false screen-settled state", delayedReport.ok && delayedReport.status === "completed" && delayedReport.summary === "Retry eventually completed.");
|
|
439
|
+
const crashedAttempt = await runHerdrSubagents("Crash after reporting working", { cwd: root, herdr: fakeHerdr("working-state-crash") });
|
|
440
|
+
check("a dead child cannot leave an authoritative working attempt waiting forever", !crashedAttempt.ok && crashedAttempt.errorMessage?.includes("child process exited"));
|
|
441
|
+
const deadWorkingReturn = await runHerdrSubagents("Return idle then exit", { cwd: root, herdr: fakeHerdr("working-state-dead-return") });
|
|
442
|
+
check("a normal wait return cannot leave a dead working attempt waiting forever", !deadWorkingReturn.ok && deadWorkingReturn.errorMessage?.includes("Child Pi stopped while"));
|
|
384
443
|
const malformedExecutionHerdr = fakeHerdr("malformed");
|
|
385
444
|
const malformedExecution = await runHerdrSubagents("Write malformed report", { cwd: root, herdr: malformedExecutionHerdr });
|
|
386
445
|
const malformedExecutionTaskDirectory = malformedExecution.recordDirectory;
|
|
@@ -396,7 +455,7 @@ try {
|
|
|
396
455
|
const cleanupRunRecord = JSON.parse(readFileSync(join(cleanupRunDirectory, "run.json"), "utf8"));
|
|
397
456
|
const cleanupTaskDirectory = join(cleanupRunDirectory, "tasks", readdirSync(join(cleanupRunDirectory, "tasks"))[0]);
|
|
398
457
|
const cleanupTaskRecord = JSON.parse(readFileSync(join(cleanupTaskDirectory, "task.json"), "utf8"));
|
|
399
|
-
check("tab cleanup failure
|
|
458
|
+
check("tab cleanup failure preserves completed business records", cleanupFailure.ok && cleanupRunRecord.status === "completed" && cleanupTaskRecord.status === "completed" && cleanupRunRecord.cleanupError.includes("tab still busy"));
|
|
400
459
|
|
|
401
460
|
const definition = parseAgentMarkdown(`---\nname: reviewer\ndescription: Review code\nmodel: fake/model\ntools: read, grep\n---\nReview carefully.`, "reviewer.md");
|
|
402
461
|
check("persona frontmatter parsing", definition.name === "reviewer" && definition.model === "fake/model" && definition.tools?.join(",") === "read,grep" && definition.systemPrompt === "Review carefully.");
|
|
@@ -474,16 +533,25 @@ try {
|
|
|
474
533
|
check("read-only batch uses bounded concurrency", batchDetails.effectiveConcurrency === 2 && batchHerdr.maxActive === 2 && batchHerdr.calls.filter(([name]) => name === "splitPane").length === 3);
|
|
475
534
|
check("batch is FIFO with a dedicated pane per task", batchPrompts.join(",") === "one slow,two,three FAIL,four" && new Set(batchStarts.map((request) => request.paneId)).size === 4);
|
|
476
535
|
check("failed sibling does not cancel batch", batch.isError && batchDetails.status === "partial" && batchDetails.tasks.map((task) => task.name).join(",") === "one,two,three,four" && batchDetails.tasks[2].status === "failed" && batchDetails.tasks[3].status === "completed");
|
|
536
|
+
const mixedCancelHerdr = fakeHerdr();
|
|
537
|
+
const mixedCancelPrompt = mixedCancelHerdr.promptAgent.bind(mixedCancelHerdr);
|
|
538
|
+
mixedCancelHerdr.promptAgent = async (request) => request.prompt.includes("WAIT") ? await new Promise((_resolve, reject) => request.signal?.addEventListener("abort", () => reject(request.signal.reason), { once: true })) : mixedCancelPrompt(request);
|
|
539
|
+
const mixedCancelController = new AbortController();
|
|
540
|
+
const mixedCancelPromise = executeHerdrSubagents({ concurrency: 2, tasks: [{ name: "fail-first", prompt: "FAIL", agent: "explorer" }, { name: "cancel-second", prompt: "WAIT", agent: "reviewer" }] }, mixedCancelController.signal, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: mixedCancelHerdr });
|
|
541
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
542
|
+
mixedCancelController.abort(new Error("Parent Pi session closed."));
|
|
543
|
+
const mixedCancel = await mixedCancelPromise;
|
|
544
|
+
check("failed and cancelled production tasks aggregate to partial", mixedCancel.details.status === "partial" && mixedCancel.details.tasks.some((task) => task.status === "failed") && mixedCancel.details.tasks.some((task) => task.status === "cancelled"));
|
|
477
545
|
const batchCleanupHerdr = fakeHerdr("close-failed");
|
|
478
546
|
const batchCleanupUpdates = [];
|
|
479
547
|
const batchCleanup = await executeHerdrSubagents({ tasks: [{ name: "cleanup", prompt: "cleanup", agent: "explorer" }] }, undefined, (update) => batchCleanupUpdates.push(update), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: batchCleanupHerdr });
|
|
480
548
|
const batchCleanupRecord = JSON.parse(readFileSync(join(batchCleanup.details.recordDirectory, "run.json"), "utf8"));
|
|
481
|
-
check("batch cleanup failure is persisted
|
|
549
|
+
check("batch cleanup failure is persisted without changing the business result", !batchCleanup.isError && batchCleanupRecord.status === "completed" && batchCleanupRecord.cleanupError.includes("tab still busy") && batchCleanupUpdates.filter((update) => update.content[0]?.text.includes("cleanup failed")).length === 1);
|
|
482
550
|
const cleanupProbeHerdr = fakeHerdr();
|
|
483
551
|
cleanupProbeHerdr.isTabFocused = async () => { throw new Error("tab lookup failed"); };
|
|
484
552
|
const cleanupProbe = await executeHerdrSubagents({ tasks: [{ name: "cleanup-probe", prompt: "cleanup probe", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: cleanupProbeHerdr });
|
|
485
553
|
const cleanupProbeRecord = JSON.parse(readFileSync(join(cleanupProbe.details.recordDirectory, "run.json"), "utf8"));
|
|
486
|
-
check("cleanup focus-probe failure is persisted
|
|
554
|
+
check("cleanup focus-probe failure is persisted without changing the business result", !cleanupProbe.isError && cleanupProbeRecord.status === "completed" && cleanupProbeRecord.cleanupError === "tab lookup failed");
|
|
487
555
|
const missingTabHerdr = fakeHerdr();
|
|
488
556
|
missingTabHerdr.isTabFocused = async () => { throw new Error('{"error":{"code":"tab_not_found","message":"tab w1:t2 not found"}}'); };
|
|
489
557
|
const missingTabCleanup = await executeHerdrSubagents({ tasks: [{ name: "already-closed", prompt: "already closed", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: missingTabHerdr });
|
|
@@ -549,7 +617,7 @@ try {
|
|
|
549
617
|
const deferredSingle = await executeHerdrSubagents({ prompt: "single deferred cleanup", agent: "explorer" }, undefined, (update) => deferredSingleUpdates.push(update), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: deferredSingleHerdr });
|
|
550
618
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
551
619
|
const deferredSingleRun = JSON.parse(readFileSync(join(deferredSingle.details.recordDirectory, "..", "..", "run.json"), "utf8"));
|
|
552
|
-
check("deferred single-task cleanup failure is persisted and surfaced", deferredSingleRun.status === "
|
|
620
|
+
check("deferred single-task cleanup failure is persisted and surfaced without changing completion", deferredSingleRun.status === "completed" && deferredSingleRun.cleanupError === "single deferred close failed" && deferredSingleUpdates.some((update) => update.content[0].text.includes("cleanup failed") && update.details.status === "warning"));
|
|
553
621
|
const cancelledHerdr = fakeHerdr();
|
|
554
622
|
cancelledHerdr.isTabFocused = async (tabId) => { cancelledHerdr.calls.push(["isTabFocused", { tabId }]); return true; };
|
|
555
623
|
cancelledHerdr.waitForTabUnfocused = async () => { throw new Error("shutdown cleanup must not wait for focus"); };
|
|
@@ -667,11 +735,21 @@ try {
|
|
|
667
735
|
|
|
668
736
|
const blockedHerdr = fakeHerdr("blocked");
|
|
669
737
|
const blocked = await executeHerdrSubagents({ concurrency: 1, tasks: [{ name: "question", prompt: "inspect branch", agent: "explorer" }, { name: "after-answer", prompt: "continue queued", agent: "reviewer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
|
|
670
|
-
check("needs-input returns foreground control and preserves tab", blocked.details.status === "blocked" && blocked.details.tasks[0].question === "Which branch should I inspect?" && !blockedHerdr.calls.some(([name]) => name === "closeTab"));
|
|
738
|
+
check("needs-input returns foreground control without a tool error and preserves tab", !blocked.isError && blocked.details.status === "blocked" && blocked.details.tasks[0].question === "Which branch should I inspect?" && !blockedHerdr.calls.some(([name]) => name === "closeTab"));
|
|
671
739
|
const blockedStatus = await executeHerdrSubagents({ action: "status", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
|
|
672
740
|
check("status exposes blocked task questions for respond", blockedStatus.details.status === "blocked" && blockedStatus.details.settled === false && blockedStatus.details.tasks[0].question === "Which branch should I inspect?" && blockedStatus.content[0].text.includes("Which branch should I inspect?"));
|
|
673
741
|
const activeCleanup = await executeHerdrSubagents({ action: "cleanup", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
|
|
674
742
|
check("cleanup refuses to delete an active blocked run", activeCleanup.isError && activeCleanup.content[0].text.includes("still active") && existsSync(blocked.details.recordDirectory));
|
|
743
|
+
mkdirSync(join(blocked.details.recordDirectory, ".respond.lock"));
|
|
744
|
+
const concurrentResponse = await executeHerdrSubagents({ action: "respond", runId: blocked.details.runId, answer: "duplicate" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
|
|
745
|
+
rmSync(join(blocked.details.recordDirectory, ".respond.lock"), { recursive: true });
|
|
746
|
+
check("concurrent blocked responses are rejected before a second prompt", concurrentResponse.isError && concurrentResponse.content[0].text.includes("already has a blocked response in progress"));
|
|
747
|
+
const staleLockHerdr = fakeHerdr("blocked");
|
|
748
|
+
const staleLockBlocked = await executeHerdrSubagents({ tasks: [{ name: "stale-lock-question", prompt: "question", agent: "explorer" }] }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: staleLockHerdr });
|
|
749
|
+
writeFileSync(join(staleLockBlocked.details.recordDirectory, ".respond.lock"), JSON.stringify({ processId: 99999999, createdAt: "2000-01-01T00:00:00.000Z" }));
|
|
750
|
+
const diagnosedStaleResponse = await executeHerdrSubagents({ action: "respond", runId: staleLockBlocked.details.runId, answer: "Inspect main." }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: staleLockHerdr });
|
|
751
|
+
check("blocked response diagnoses a lock owned by a dead process without unsafe reclamation", diagnosedStaleResponse.isError && diagnosedStaleResponse.content[0].text.includes("stale blocked-response lock"));
|
|
752
|
+
rmSync(join(staleLockBlocked.details.recordDirectory, ".respond.lock"));
|
|
675
753
|
const resumeUpdates = [];
|
|
676
754
|
const resumed = await executeHerdrSubagents({ action: "respond", runId: blocked.details.runId, answer: "Inspect main." }, undefined, (update) => resumeUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
|
|
677
755
|
const blockedStarts = blockedHerdr.calls.filter(([name]) => name === "startAgent").map(([, request]) => request);
|
|
@@ -714,7 +792,7 @@ try {
|
|
|
714
792
|
const cancelledContinuationTask = JSON.parse(readFileSync(join(abortedTaskDirectories[1], "task.json"), "utf8"));
|
|
715
793
|
const cancelledContinuationRun = JSON.parse(readFileSync(join(abortedContinuationRun.details.recordDirectory, "run.json"), "utf8"));
|
|
716
794
|
const cancelledContinuationReport = JSON.parse(readFileSync(join(abortedTaskDirectories[1], "report.json"), "utf8"));
|
|
717
|
-
check("aborted queued continuation
|
|
795
|
+
check("aborted queued continuation preserves a partial completed/cancelled run", cancelledContinuationTask.status === "cancelled" && cancelledContinuationRun.status === "partial" && cancelledContinuationReport.summary.includes("cancelled"));
|
|
718
796
|
const deadPaneHerdr = fakeHerdr("blocked");
|
|
719
797
|
const deadPaneRun = await executeHerdrSubagents({ tasks: [{ name: "dead-pane", prompt: "question", agent: "explorer" }] }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: deadPaneHerdr });
|
|
720
798
|
deadPaneHerdr.paneExists = async () => false;
|
|
@@ -980,6 +1058,27 @@ try {
|
|
|
980
1058
|
writeFileSync(join(recoveryTask, "task.json"), JSON.stringify({ id: "task", runId: "recover", order: 1, name: "lost", status: "running", paneId: "w1:missing", sessionFile: join(recoveryTask, "session.jsonl") }));
|
|
981
1059
|
const reconciliation = await reconcileSubagentRuns(recoveryRoot, fakeHerdr("missing-pane"), { sessionId: "new-owner" });
|
|
982
1060
|
check("reconciliation marks stale child cancelled", reconciliation.cancelledTasks === 1 && JSON.parse(readFileSync(join(recoveryTask, "task.json"), "utf8")).status === "cancelled");
|
|
1061
|
+
const isolatedAttemptRoot = join(root, "isolated-attempt-recovery");
|
|
1062
|
+
const isolatedAttemptRun = join(isolatedAttemptRoot, ".pi", "herdr-subagents", "runs", "run");
|
|
1063
|
+
const isolatedAttemptTask = join(isolatedAttemptRun, "tasks", "01-task");
|
|
1064
|
+
mkdirSync(isolatedAttemptTask, { recursive: true });
|
|
1065
|
+
const currentAttemptId = "22222222-2222-4222-8222-222222222222";
|
|
1066
|
+
writeFileSync(join(isolatedAttemptRun, "run.json"), JSON.stringify({ id: "isolated-attempt", label: "isolated-attempt", status: "running", startedAt: new Date().toISOString() }));
|
|
1067
|
+
writeFileSync(join(isolatedAttemptTask, "task.json"), JSON.stringify({ id: "isolated-attempt-task", runId: "isolated-attempt", order: 1, status: "running", paneId: "w1:p-live", currentAttemptId, sessionFile: join(isolatedAttemptTask, "session.jsonl") }));
|
|
1068
|
+
writeFileSync(join(isolatedAttemptTask, "report.json"), JSON.stringify({ attemptId: "11111111-1111-4111-8111-111111111111", status: "needs-input", summary: "Prior attempt blocked.", question: "Old question?", documents: [] }));
|
|
1069
|
+
const isolatedReconciliation = await reconcileSubagentRuns(isolatedAttemptRoot, fakeHerdr());
|
|
1070
|
+
const isolatedTaskRecord = JSON.parse(readFileSync(join(isolatedAttemptTask, "task.json"), "utf8"));
|
|
1071
|
+
check("reconciliation ignores a prior canonical report while the current attempt is live", isolatedReconciliation.liveTasks === 1 && isolatedTaskRecord.status === "running" && isolatedTaskRecord.currentAttemptId === currentAttemptId && JSON.parse(readFileSync(join(isolatedAttemptTask, "report.json"), "utf8")).summary === "Prior attempt blocked.");
|
|
1072
|
+
const ownerLossRoot = join(root, "owner-loss-report-recovery");
|
|
1073
|
+
const ownerLossRun = join(ownerLossRoot, ".pi", "herdr-subagents", "runs", "run");
|
|
1074
|
+
const ownerLossTask = join(ownerLossRun, "tasks", "01-task");
|
|
1075
|
+
const ownerLossAttempt = "44444444-4444-4444-8444-444444444444";
|
|
1076
|
+
mkdirSync(join(ownerLossTask, "reports"), { recursive: true });
|
|
1077
|
+
writeFileSync(join(ownerLossRun, "run.json"), JSON.stringify({ id: "owner-loss", label: "owner-loss", status: "running", ownerSessionId: "dead-owner", ownerProcessId: 99999999, tabId: "w1:t-owner-loss", startedAt: new Date().toISOString() }));
|
|
1078
|
+
writeFileSync(join(ownerLossTask, "task.json"), JSON.stringify({ id: "owner-loss-task", runId: "owner-loss", order: 1, status: "running", paneId: "w1:p-owner-loss", currentAttemptId: ownerLossAttempt, sessionFile: join(ownerLossTask, "session.jsonl") }));
|
|
1079
|
+
writeFileSync(join(ownerLossTask, "reports", `${ownerLossAttempt}.json`), JSON.stringify({ attemptId: ownerLossAttempt, status: "completed", summary: "Completed before owner loss.", documents: [] }));
|
|
1080
|
+
await reconcileSubagentRuns(ownerLossRoot, fakeHerdr(), { sessionId: "new-owner", processId: process.pid });
|
|
1081
|
+
check("matching attempt report survives prior owner loss", JSON.parse(readFileSync(join(ownerLossTask, "task.json"), "utf8")).status === "completed" && JSON.parse(readFileSync(join(ownerLossRun, "run.json"), "utf8")).status === "completed" && JSON.parse(readFileSync(join(ownerLossTask, "report.json"), "utf8")).attemptId === ownerLossAttempt);
|
|
983
1082
|
const malformedRoot = join(root, "malformed-recovery");
|
|
984
1083
|
const malformedRun = join(malformedRoot, ".pi", "herdr-subagents", "runs", "run");
|
|
985
1084
|
const malformedTask = join(malformedRun, "tasks", "01-task");
|
|
@@ -989,7 +1088,7 @@ try {
|
|
|
989
1088
|
writeFileSync(join(malformedTask, "report.json"), "{truncated");
|
|
990
1089
|
const malformedHerdr = fakeHerdr();
|
|
991
1090
|
const malformedReconciliation = await reconcileSubagentRuns(malformedRoot, malformedHerdr, { sessionId: "new-owner", processId: process.pid });
|
|
992
|
-
check("malformed report cannot abort orphan reconciliation", malformedReconciliation.
|
|
1091
|
+
check("malformed report cannot abort orphan reconciliation", malformedReconciliation.settledTasks === 1 && malformedReconciliation.cleanupErrors.some((message) => message.includes("Invalid Subagent report")) && JSON.parse(readFileSync(join(malformedTask, "task.json"), "utf8")).status === "failed" && malformedHerdr.calls.some(([name]) => name === "closeTab"));
|
|
993
1092
|
const invalidReportRoot = join(root, "invalid-report-recovery");
|
|
994
1093
|
const invalidReportRun = join(invalidReportRoot, ".pi", "herdr-subagents", "runs", "run");
|
|
995
1094
|
const invalidReportTask = join(invalidReportRun, "tasks", "01-task");
|
|
@@ -22,6 +22,8 @@ URL="$(
|
|
|
22
22
|
设置来自附件列表的目标文件名,并完整执行下载命令:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
25
27
|
OUTPUT='<附件文件名>'
|
|
26
28
|
PART="${OUTPUT}.part"
|
|
27
29
|
if [ -e "$OUTPUT" ] || [ -e "$PART" ]; then
|
|
@@ -43,4 +45,4 @@ test -s "$PART"
|
|
|
43
45
|
5. 按格式做完整性检查:ZIP 使用 `unzip -t "$PART"`,图片使用环境可用的格式识别/解码能力,其他格式使用对应检查。
|
|
44
46
|
6. 全部检查通过后执行 `mv -- "$PART" "$OUTPUT"`。目标文件或 `.part` 已存在时必须在下载前失败,避免把旧文件误认为本次结果;任何检查失败都不得创建最终文件。
|
|
45
47
|
|
|
46
|
-
本流程不续传失败的 `.part
|
|
48
|
+
本流程不续传失败的 `.part`。附件大小未知或大于 50 MB 时,工具超时至少设为 600 秒;timeout 仅表示本地执行被终止。删除残留 `.part`,重新获取临时 URL 后从头下载;临时 URL 过期不能说明原附件损坏。
|