@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
|
@@ -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");
|