@maxiaochao/pi-toolkit 0.10.19 → 0.10.21
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 +11 -0
- package/packages/herdr-subagents/README.md +7 -1
- package/packages/herdr-subagents/TESTING.md +4 -1
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +12 -7
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +10 -4
- package/packages/herdr-subagents/src/blocking.ts +1 -1
- package/packages/herdr-subagents/src/continue-run.ts +4 -2
- package/packages/herdr-subagents/src/dispatcher.ts +44 -4
- package/packages/herdr-subagents/src/history.ts +15 -6
- package/packages/herdr-subagents/src/reconcile.ts +18 -6
- package/packages/herdr-subagents/src/records.ts +26 -0
- package/packages/herdr-subagents/src/run-locator.ts +17 -2
- package/packages/herdr-subagents/src/status.ts +14 -3
- package/packages/herdr-subagents/src/tool.ts +13 -8
- package/packages/herdr-subagents/tests/extension-contract.ts +75 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +65 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2026-10-10 - v0.10.21
|
|
2
|
+
|
|
3
|
+
- bundle herdr-subagents v0.4.7: fix blocked-run scheduling and limit interactive blocking to serial runs
|
|
4
|
+
- bundle the Herdr Subagents blocked-dispatch deadlock fix, serial-only interactive blocking, structured concurrent `missing_input` failures, and flexible run ID controls
|
|
5
|
+
|
|
6
|
+
## 2026-10-10 - v0.10.20
|
|
7
|
+
|
|
8
|
+
- bundle herdr-subagents v0.4.6: reduce global prompt noise by keeping Subagent workflow guidance in tool descriptions
|
|
9
|
+
|
|
1
10
|
## 2026-10-10 - v0.10.19
|
|
2
11
|
|
|
3
12
|
- bundle codex-edit v0.1.10: remove redundant apply_patch prompt metadata
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.21",
|
|
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,14 @@
|
|
|
1
|
+
## 2026-10-10 - v0.4.7
|
|
2
|
+
|
|
3
|
+
- fix blocked-run scheduling and limit interactive blocking to serial runs
|
|
4
|
+
- prevent foreground runs from hanging behind a blocked dispatcher owner, return the blocking run ID immediately, and remove cancelled foreground calls from the queue while preserving background queuing
|
|
5
|
+
- limit interactive blocking to effectively serial runs; concurrent input requests become structured `missing_input` failures while independent siblings continue
|
|
6
|
+
- accept full run UUIDs, saved run-directory names, and unique UUID prefixes in run controls
|
|
7
|
+
|
|
8
|
+
## 2026-10-10 - v0.4.6
|
|
9
|
+
|
|
10
|
+
- reduce global prompt noise by keeping Subagent workflow guidance in tool descriptions
|
|
11
|
+
|
|
1
12
|
## 2026-10-10 - v0.4.5
|
|
2
13
|
|
|
3
14
|
- fix transient retry lifecycle, attempt isolation, and recovery semantics
|
|
@@ -64,7 +64,7 @@ herdr_subagents({
|
|
|
64
64
|
})
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
`tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues. The dispatcher starts batches FIFO, with one batch holding its active slot at a time; a failed foreground handoff releases that slot as described below. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
|
|
67
|
+
`tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues. The dispatcher starts batches FIFO, with one batch holding its active slot at a time; a failed foreground handoff releases that slot as described below. A blocked run retains the slot so its queued siblings can continue after an answer. While that slot is blocked, a new foreground submission fails immediately with the blocking run ID instead of waiting indefinitely; a background submission remains queued. Cancelling a foreground tool call while it is queued removes it from the queue immediately. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
|
|
68
68
|
|
|
69
69
|
A foreground concurrent batch normally waits for the whole batch. If any task fails while siblings are still running, the call immediately returns the stable `runId` and that failure's details, then durably continues the same batch in the background without cancelling its siblings. The handed-off batch releases its dispatcher slot immediately, so a later submission can start while its surviving tasks are still running. The detached batch remains cancellable and is still joined during session shutdown. Its final aggregate is delivered exactly once through the same follow-up notification used by an explicit background run.
|
|
70
70
|
|
|
@@ -76,6 +76,8 @@ herdr_subagents_control({ action: "status", runId: "<run-id>" })
|
|
|
76
76
|
|
|
77
77
|
The snapshot reports the aggregate run state and every task's state, including a blocked question when present. It checks durable run state before queued state, never returns unrelated runs, and does not wait or trigger a parent turn. Do not poll full `history` for progress.
|
|
78
78
|
|
|
79
|
+
Run controls accept the complete run UUID, the saved run-directory name, or any unique UUID prefix. Results always return the canonical complete UUID.
|
|
80
|
+
|
|
79
81
|
A child missing required information reports `needs-input` with an exact question. The run becomes blocked, keeps its Herdr tab, pane, partial report, and Pi session, and returns control to the parent. Answer through the control tool to continue the original session:
|
|
80
82
|
|
|
81
83
|
```text
|
|
@@ -84,6 +86,10 @@ herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the
|
|
|
84
86
|
|
|
85
87
|
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
88
|
|
|
89
|
+
Blocking is limited to effectively serial runs (`effectiveConcurrency: 1`), where task order can depend on the answer. In a concurrent read-only run, tasks must be independent: a task that requests input is recorded as `failed` with `failureKind: "missing_input"` and `requiredInput`, while its independent siblings continue. This prevents a blocked concurrent task from holding the parent tool call behind unrelated long-running siblings.
|
|
90
|
+
|
|
91
|
+
Only work owned by the current parent session's main dispatcher can remain blocked. Historical follow-ups that need more input settle as `missing_input` failures, and startup reconciliation cancels blocked work whose parent session disappeared; neither path creates an unowned blocked run.
|
|
92
|
+
|
|
87
93
|
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.
|
|
88
94
|
|
|
89
95
|
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.
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
- Let a detached run later block or be explicitly cancelled and verify the newer dispatcher owner remains intact and each detached run emits exactly one final notification.
|
|
18
18
|
- Shut down the parent while a handed-off sibling is active and verify it is cancelled and joined, durable queue state is removed, and no stale follow-up is sent.
|
|
19
19
|
- Verify all-success foreground batches still wait for every task and explicit `background: true` submissions still return immediately.
|
|
20
|
+
- Verify a blocked dispatcher owner immediately rejects both newly submitted and already-queued foreground runs with its full run ID, keeps explicit background runs queued, and removes queued foreground runs when their caller cancels.
|
|
21
|
+
- Verify only effectively serial runs can block; concurrent input requests settle as structured `missing_input` failures, including owner-loss reconciliation, while independent siblings continue.
|
|
22
|
+
- Verify historical follow-ups cannot create unowned blocked work and owner-loss reconciliation cancels serial blocked work instead of reconstructing hidden dispatcher ownership.
|
|
20
23
|
- Verify all paths use interactive Agent start/prompt without print mode, shell injection, markers, or pane reuse.
|
|
21
24
|
- Force write-capable batches to serial execution while allowing bounded read-only parallelism, including three concurrent `analyst` tasks with independent task-level models.
|
|
22
25
|
- Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
|
|
@@ -32,4 +35,4 @@
|
|
|
32
35
|
|
|
33
36
|
## Latest baseline
|
|
34
37
|
|
|
35
|
-
All
|
|
38
|
+
All 198 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.
|
|
@@ -195,13 +195,18 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
195
195
|
pi.registerTool({
|
|
196
196
|
name: "herdr_subagents",
|
|
197
197
|
label: "Herdr Subagents",
|
|
198
|
-
description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Foreground calls wait for the batch unless a task fails, when the run continues in the background and returns its run ID immediately. Explicit background calls always return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
|
|
198
|
+
description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Only effectively serial runs can pause for input; concurrent input requests become missing_input failures while independent siblings continue. Foreground calls wait for the batch unless a task fails, when the run continues in the background and returns its run ID immediately. Explicit background calls always return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
|
|
199
199
|
promptSnippet: "Run focused work in visible Herdr subagents",
|
|
200
|
-
promptGuidelines: ["Call herdr_subagents_control with action=list once before the first execution. Submit an ordered tasks list; use one item for a single task and concurrency only for independent read-only tasks. Foreground calls wait for full success or blocking input, but return a run ID as soon as one task fails while surviving siblings continue in the background. Explicit background calls return a run ID immediately."],
|
|
201
200
|
parameters: HerdrSubagentsParams,
|
|
202
201
|
prepareArguments: normalizeHerdrSubagentsArguments,
|
|
203
202
|
|
|
204
203
|
async execute(_toolCallId, params: HerdrSubagentsRunParams, signal, onUpdate, ctx) {
|
|
204
|
+
const blockedForegroundResponse = (blockingRunId: string) => {
|
|
205
|
+
const text = `Cannot start a foreground Subagent run while blocked run ${blockingRunId} is waiting for input. Respond to or cancel that run first, or submit this run with background: true.`;
|
|
206
|
+
return { content: [{ type: "text" as const, text }], details: { errorMessage: text, blockingRunId }, isError: true };
|
|
207
|
+
};
|
|
208
|
+
const blockingRunId = dispatcher.snapshot().retainedRunId;
|
|
209
|
+
if (!params.background && blockingRunId) return blockedForegroundResponse(blockingRunId);
|
|
205
210
|
const generation = sessionGeneration;
|
|
206
211
|
const runId = randomUUID();
|
|
207
212
|
const controller = new AbortController();
|
|
@@ -250,7 +255,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
250
255
|
dispatcher.retain(runId);
|
|
251
256
|
}
|
|
252
257
|
return result;
|
|
253
|
-
});
|
|
258
|
+
}, params.background ? undefined : { ...(signal ? { signal } : {}), onRetained: blockedForegroundResponse });
|
|
254
259
|
runPromises.set(runId, promise);
|
|
255
260
|
inFlight.add(promise);
|
|
256
261
|
void promise.finally(() => {
|
|
@@ -324,7 +329,6 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
324
329
|
name: "herdr_subagents_control",
|
|
325
330
|
label: "Herdr Subagents Control",
|
|
326
331
|
description: "Inspect Herdr personas, query one run by ID, list run history, cancel active or queued work, answer a blocked task, resume a saved task, or remove a selected archived run. Use status for one run and history only for the full archive. Use herdr_subagents to start new work.",
|
|
327
|
-
promptSnippet: "Inspect and control Herdr subagent runs",
|
|
328
332
|
parameters: HerdrSubagentsControlParams,
|
|
329
333
|
|
|
330
334
|
async execute(_toolCallId, params: HerdrSubagentsControlToolParams, signal, onUpdate, ctx) {
|
|
@@ -371,11 +375,12 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
371
375
|
const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
|
|
372
376
|
fleet.sync(result.details);
|
|
373
377
|
if (params.action === "respond") {
|
|
374
|
-
const details = result.details as { status?: string };
|
|
378
|
+
const details = result.details as { runId?: string; status?: string };
|
|
375
379
|
if (details.status === "blocked") fleet.sync(result.details);
|
|
376
380
|
else if (isSettledRunStatus(details.status)) {
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
const canonicalRunId = details.runId ?? params.runId;
|
|
382
|
+
fleet.clearRun(canonicalRunId);
|
|
383
|
+
if (canonicalRunId) dispatcher.release(canonicalRunId);
|
|
379
384
|
}
|
|
380
385
|
}
|
|
381
386
|
return result;
|
|
@@ -10,11 +10,12 @@ import { writeJsonAtomic } from "./state.ts";
|
|
|
10
10
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
11
11
|
import { ownerRecord } from "./ownership.ts";
|
|
12
12
|
import { RunPaneAllocator } from "./run-pane-allocator.ts";
|
|
13
|
-
import { applyReport, applyTaskStatus, projectTaskActivity, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
13
|
+
import { applyReport, applyTaskStatus, normalizeNeedsInputReport, 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
16
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
17
17
|
import { isAbortError } from "./errors.ts";
|
|
18
|
+
import { attemptReportPath } from "./attempts.ts";
|
|
18
19
|
|
|
19
20
|
export interface BatchTask {
|
|
20
21
|
name: string;
|
|
@@ -51,6 +52,8 @@ export interface BatchTaskResult {
|
|
|
51
52
|
documents: HerdrSubagentsDocument[];
|
|
52
53
|
error?: string;
|
|
53
54
|
question?: string;
|
|
55
|
+
failureKind?: "missing_input";
|
|
56
|
+
requiredInput?: string;
|
|
54
57
|
paneId: string;
|
|
55
58
|
recordDirectory: string;
|
|
56
59
|
sessionFile: string;
|
|
@@ -68,6 +71,8 @@ export interface BatchResult {
|
|
|
68
71
|
documents: HerdrSubagentsDocument[];
|
|
69
72
|
recordDirectory: string;
|
|
70
73
|
tabId?: string;
|
|
74
|
+
question?: string;
|
|
75
|
+
allowedActions?: Array<"respond" | "cancel">;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
type TaskContext = {
|
|
@@ -151,7 +156,8 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
151
156
|
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
157
|
agentName = prompted.agentName;
|
|
153
158
|
context.taskRecord.currentAttemptId = prompted.attemptId;
|
|
154
|
-
report = prompted.report;
|
|
159
|
+
report = normalizeNeedsInputReport(prompted.report, effectiveConcurrency);
|
|
160
|
+
if (report !== prompted.report) await Promise.all([writeJsonAtomic(context.reportFile, report), writeJsonAtomic(attemptReportPath(context.taskDirectory, prompted.attemptId), report)]);
|
|
155
161
|
status = applyReport(context.taskRecord, report);
|
|
156
162
|
} catch (error) {
|
|
157
163
|
const settled = await settleAttemptFailure(context.taskRecord, context.reportFile, {
|
|
@@ -164,7 +170,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
164
170
|
}
|
|
165
171
|
Object.assign(context.taskRecord, { paneId, agentName });
|
|
166
172
|
await writeJsonAtomic(context.taskFile, context.taskRecord);
|
|
167
|
-
results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
|
|
173
|
+
results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), ...(report.failureKind ? { failureKind: report.failureKind } : {}), ...(report.requiredInput ? { requiredInput: report.requiredInput } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
|
|
168
174
|
activity[index] = projectTaskActivity(context.taskRecord, index);
|
|
169
175
|
settled += 1;
|
|
170
176
|
emit("running", `${settled}/${tasks.length} tasks settled.`);
|
|
@@ -197,7 +203,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
197
203
|
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.`;
|
|
198
204
|
applyRunStatus(runRecord, status, { question: blocked?.question });
|
|
199
205
|
await writeJsonAtomic(runFile, runRecord);
|
|
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 };
|
|
206
|
+
return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId, ...(blocked?.question ? { question: blocked.question, allowedActions: ["respond", "cancel"] } : {}) };
|
|
201
207
|
} catch (error) {
|
|
202
208
|
applyRunStatus(runRecord, options.signal?.aborted ? "cancelled" : "failed", { error: error instanceof Error ? error.message : String(error) });
|
|
203
209
|
await writeJsonAtomic(runFile, runRecord);
|
|
@@ -199,7 +199,7 @@ async function respondToBlockedTaskLocked(cwd: string, runId: string, answer: st
|
|
|
199
199
|
}
|
|
200
200
|
await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
|
|
201
201
|
if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError });
|
|
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) } : {}) };
|
|
202
|
+
return { ok: false, runId: run.id, 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) } : {}) };
|
|
203
203
|
}
|
|
204
204
|
turn.events.push({ type: "report", status, summary: report.summary, question: report.question, error: report.error, at: new Date().toISOString() });
|
|
205
205
|
Object.assign(turn, { summary: report.summary, documents: report.documents ?? [] });
|
|
@@ -6,8 +6,9 @@ import { writeJsonAtomic } from "./state.ts";
|
|
|
6
6
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
7
7
|
import { RunPaneAllocator } from "./run-pane-allocator.ts";
|
|
8
8
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
9
|
-
import { applyReport, applyTaskStatus, readRunRecord, readTaskRecord, settleAttemptFailure } from "./records.ts";
|
|
9
|
+
import { applyReport, applyTaskStatus, normalizeNeedsInputReport, readRunRecord, readTaskRecord, settleAttemptFailure } from "./records.ts";
|
|
10
10
|
import { isAbortError } from "./errors.ts";
|
|
11
|
+
import { attemptReportPath } from "./attempts.ts";
|
|
11
12
|
|
|
12
13
|
export interface ContinueQueuedRunOptions {
|
|
13
14
|
cleanup?: boolean;
|
|
@@ -50,7 +51,8 @@ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutoma
|
|
|
50
51
|
await options.onUpdate?.();
|
|
51
52
|
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
53
|
task.currentAttemptId = prompted.attemptId;
|
|
53
|
-
const report = prompted.report;
|
|
54
|
+
const report = normalizeNeedsInputReport(prompted.report, Number(run.effectiveConcurrency) || 1);
|
|
55
|
+
if (report !== prompted.report) await Promise.all([writeJsonAtomic(reportFile, report), writeJsonAtomic(attemptReportPath(directory, prompted.attemptId), report)]);
|
|
54
56
|
applyReport(task, report);
|
|
55
57
|
} catch (error) {
|
|
56
58
|
await settleAttemptFailure(task, reportFile, {
|
|
@@ -3,10 +3,19 @@ interface QueuedRun<T> {
|
|
|
3
3
|
execute: () => Promise<T>;
|
|
4
4
|
resolve: (value: T) => void;
|
|
5
5
|
reject: (error: unknown) => void;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
onAbort?: () => void;
|
|
8
|
+
onRetained?: (retainedRunId: string) => T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SubmitOptions<T> {
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
onRetained?: (retainedRunId: string) => T;
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
export interface DispatcherSnapshot {
|
|
9
17
|
activeRunId?: string;
|
|
18
|
+
retainedRunId?: string;
|
|
10
19
|
queuedRunIds: string[];
|
|
11
20
|
}
|
|
12
21
|
|
|
@@ -19,15 +28,33 @@ export class RunDispatcher {
|
|
|
19
28
|
|
|
20
29
|
constructor(onChange?: (snapshot: DispatcherSnapshot) => void) { this.onChange = onChange; }
|
|
21
30
|
|
|
22
|
-
submit<T>(id: string, execute: () => Promise<T>): Promise<T> {
|
|
23
|
-
const
|
|
31
|
+
submit<T>(id: string, execute: () => Promise<T>, options: SubmitOptions<T> = {}): Promise<T> {
|
|
32
|
+
const { signal, onRetained } = options;
|
|
33
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error(`Queued run '${id}' was cancelled.`));
|
|
34
|
+
if (this.retainedRunId && onRetained) return Promise.resolve(onRetained(this.retainedRunId));
|
|
35
|
+
const promise = new Promise<T>((resolve, reject) => {
|
|
36
|
+
const queued: QueuedRun<T> = { id, execute, resolve, reject, signal, onRetained };
|
|
37
|
+
queued.onAbort = () => {
|
|
38
|
+
const index = this.queue.indexOf(queued as QueuedRun<unknown>);
|
|
39
|
+
if (index < 0) return;
|
|
40
|
+
this.queue.splice(index, 1);
|
|
41
|
+
reject(signal?.reason ?? new Error(`Queued run '${id}' was cancelled.`));
|
|
42
|
+
this.changed();
|
|
43
|
+
};
|
|
44
|
+
signal?.addEventListener("abort", queued.onAbort, { once: true });
|
|
45
|
+
this.queue.push(queued as QueuedRun<unknown>);
|
|
46
|
+
});
|
|
24
47
|
this.changed();
|
|
25
48
|
void this.drain();
|
|
26
49
|
return promise;
|
|
27
50
|
}
|
|
28
51
|
|
|
29
52
|
snapshot(): DispatcherSnapshot {
|
|
30
|
-
return {
|
|
53
|
+
return {
|
|
54
|
+
...(this.activeRunId ? { activeRunId: this.activeRunId } : {}),
|
|
55
|
+
...(this.retainedRunId ? { retainedRunId: this.retainedRunId } : {}),
|
|
56
|
+
queuedRunIds: this.queue.map((run) => run.id),
|
|
57
|
+
};
|
|
31
58
|
}
|
|
32
59
|
|
|
33
60
|
pause(): void { this.paused = true; this.changed(); }
|
|
@@ -35,6 +62,14 @@ export class RunDispatcher {
|
|
|
35
62
|
retain(id: string): void {
|
|
36
63
|
if (this.activeRunId !== id) throw new Error(`Cannot retain inactive run '${id}'.`);
|
|
37
64
|
this.retainedRunId = id;
|
|
65
|
+
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
|
|
66
|
+
const queued = this.queue[index];
|
|
67
|
+
if (!queued.onRetained) continue;
|
|
68
|
+
this.queue.splice(index, 1);
|
|
69
|
+
if (queued.onAbort) queued.signal?.removeEventListener("abort", queued.onAbort);
|
|
70
|
+
try { queued.resolve(queued.onRetained(id)); }
|
|
71
|
+
catch (error) { queued.reject(error); }
|
|
72
|
+
}
|
|
38
73
|
this.changed();
|
|
39
74
|
}
|
|
40
75
|
detach(id: string): boolean {
|
|
@@ -54,7 +89,10 @@ export class RunDispatcher {
|
|
|
54
89
|
}
|
|
55
90
|
cancelQueued(reason: Error): string[] {
|
|
56
91
|
const cancelled = this.queue.splice(0);
|
|
57
|
-
for (const run of cancelled)
|
|
92
|
+
for (const run of cancelled) {
|
|
93
|
+
if (run.onAbort) run.signal?.removeEventListener("abort", run.onAbort);
|
|
94
|
+
run.reject(reason);
|
|
95
|
+
}
|
|
58
96
|
this.changed();
|
|
59
97
|
return cancelled.map((run) => run.id);
|
|
60
98
|
}
|
|
@@ -62,6 +100,7 @@ export class RunDispatcher {
|
|
|
62
100
|
const index = this.queue.findIndex((run) => run.id === id);
|
|
63
101
|
if (index < 0) return false;
|
|
64
102
|
const [cancelled] = this.queue.splice(index, 1);
|
|
103
|
+
if (cancelled.onAbort) cancelled.signal?.removeEventListener("abort", cancelled.onAbort);
|
|
65
104
|
cancelled.reject(reason);
|
|
66
105
|
this.changed();
|
|
67
106
|
return true;
|
|
@@ -73,6 +112,7 @@ export class RunDispatcher {
|
|
|
73
112
|
if (this.activeRunId || this.paused) return;
|
|
74
113
|
const run = this.queue.shift();
|
|
75
114
|
if (!run) { this.changed(); return; }
|
|
115
|
+
if (run.onAbort) run.signal?.removeEventListener("abort", run.onAbort);
|
|
76
116
|
this.activeRunId = run.id;
|
|
77
117
|
this.changed();
|
|
78
118
|
try { run.resolve(await run.execute()); }
|
|
@@ -8,7 +8,8 @@ import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
|
8
8
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
9
9
|
import { ownerRecord } from "./ownership.ts";
|
|
10
10
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
11
|
-
import { applyReport, applyTaskStatus, isActiveStatus, projectTaskActivity, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
11
|
+
import { applyReport, applyTaskStatus, isActiveStatus, normalizeNeedsInputReport, projectTaskActivity, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
12
|
+
import { attemptReportPath } from "./attempts.ts";
|
|
12
13
|
import { continuationSystemPrompt } from "./protocol.ts";
|
|
13
14
|
import { isAbortError } from "./errors.ts";
|
|
14
15
|
import { findRunDirectory } from "./run-locator.ts";
|
|
@@ -88,10 +89,13 @@ export function historyText(history: HistoricalRun[]): string {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
export async function cleanSubagentRun(cwd: string, runId: string): Promise<boolean> {
|
|
91
|
-
const
|
|
92
|
+
const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), runId);
|
|
93
|
+
if (!runDirectory) return false;
|
|
94
|
+
const canonicalRunId = (await readRunRecord(join(runDirectory, "run.json"))).id;
|
|
95
|
+
const run = (await listSubagentHistory(cwd)).find((candidate) => candidate.id === canonicalRunId);
|
|
92
96
|
if (!run) return false;
|
|
93
|
-
if (isActiveStatus(run.status)) throw new Error(`Subagent run '${
|
|
94
|
-
if (run.cleanupPendingTabIds?.length) throw new Error(`Subagent run '${
|
|
97
|
+
if (isActiveStatus(run.status)) throw new Error(`Subagent run '${canonicalRunId}' is still active and cannot be cleaned.`);
|
|
98
|
+
if (run.cleanupPendingTabIds?.length) throw new Error(`Subagent run '${canonicalRunId}' is still waiting to clean up Herdr tabs ${run.cleanupPendingTabIds.join(", ")}.`);
|
|
95
99
|
await rm(run.recordDirectory, { recursive: true, force: false });
|
|
96
100
|
return true;
|
|
97
101
|
}
|
|
@@ -102,13 +106,14 @@ export interface ResumeHistoricalTaskResult { runId: string; label: string; stat
|
|
|
102
106
|
export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<ResumeHistoricalTaskResult> {
|
|
103
107
|
const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), runId);
|
|
104
108
|
if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
|
|
109
|
+
const canonicalRunId = (await readRunRecord(join(runDirectory, "run.json"))).id;
|
|
105
110
|
const lockDirectory = join(runDirectory, ".resume.lock");
|
|
106
111
|
try { await mkdir(lockDirectory); }
|
|
107
112
|
catch (error) {
|
|
108
113
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Subagent run '${runId}' already has a historical resume in progress (lock: ${lockDirectory}).`);
|
|
109
114
|
throw error;
|
|
110
115
|
}
|
|
111
|
-
try { return await resumeHistoricalTaskLocked(cwd,
|
|
116
|
+
try { return await resumeHistoricalTaskLocked(cwd, canonicalRunId, taskNumber, prompt, options); }
|
|
112
117
|
finally { await rmdir(lockDirectory); }
|
|
113
118
|
}
|
|
114
119
|
|
|
@@ -163,7 +168,11 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
163
168
|
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
169
|
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
170
|
task.currentAttemptId = prompted.attemptId;
|
|
166
|
-
report = prompted.report;
|
|
171
|
+
report = normalizeNeedsInputReport(prompted.report, Number.POSITIVE_INFINITY);
|
|
172
|
+
if (report !== prompted.report) await Promise.all([
|
|
173
|
+
writeJsonAtomic(join(historical.recordDirectory, "report.json"), report),
|
|
174
|
+
writeJsonAtomic(attemptReportPath(historical.recordDirectory, prompted.attemptId), report),
|
|
175
|
+
]);
|
|
167
176
|
status = applyReport(task, report);
|
|
168
177
|
} catch (error) {
|
|
169
178
|
const settled = await settleAttemptFailure(task, join(historical.recordDirectory, "report.json"), {
|
|
@@ -4,7 +4,7 @@ import type { HerdrAutomation } from "./herdr.ts";
|
|
|
4
4
|
import { isProcessAlive, type OwnerIdentity } from "./ownership.ts";
|
|
5
5
|
import { writeJsonAtomic } from "./state.ts";
|
|
6
6
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
7
|
-
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, type RunRecord, type TaskStatus } from "./records.ts";
|
|
7
|
+
import { applyReport, applyTaskStatus, isActiveStatus, normalizeNeedsInputReport, persistFailureReport, readReport, readRunRecord, readTaskRecord, type RunRecord, type TaskStatus } from "./records.ts";
|
|
8
8
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
9
9
|
import { attemptReportPath } from "./attempts.ts";
|
|
10
10
|
|
|
@@ -110,7 +110,6 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
|
|
|
110
110
|
continue;
|
|
111
111
|
}
|
|
112
112
|
if (isActiveStatus(task.status)) {
|
|
113
|
-
let recoveredCurrentReport = false;
|
|
114
113
|
const canonicalReportFile = join(tasksDirectory, taskEntry, "report.json");
|
|
115
114
|
let currentAttemptReport: string | undefined;
|
|
116
115
|
if (task.currentAttemptId) {
|
|
@@ -122,11 +121,12 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
|
|
|
122
121
|
const reportFile = task.currentAttemptId ? currentAttemptReport : canonicalReportFile;
|
|
123
122
|
if (reportFile && existsSync(reportFile) && (task.status === "running" || task.status === "starting")) {
|
|
124
123
|
try {
|
|
125
|
-
const
|
|
124
|
+
const submittedReport = await readReport(reportFile);
|
|
125
|
+
const report = normalizeNeedsInputReport(submittedReport, Number(run.effectiveConcurrency) || 1);
|
|
126
126
|
if (task.currentAttemptId && report.attemptId !== task.currentAttemptId) throw new Error(`Report attempt '${report.attemptId ?? "unknown"}' does not match current attempt '${task.currentAttemptId}'.`);
|
|
127
127
|
applyReport(task, report, report.reportedAt ?? new Date().toISOString());
|
|
128
|
-
|
|
129
|
-
if (reportFile !== canonicalReportFile) await writeJsonAtomic(canonicalReportFile, report);
|
|
128
|
+
if (report !== submittedReport) await writeJsonAtomic(reportFile, report);
|
|
129
|
+
if (reportFile !== canonicalReportFile || report !== submittedReport) await writeJsonAtomic(canonicalReportFile, report);
|
|
130
130
|
result.settledTasks += 1;
|
|
131
131
|
} catch (error) {
|
|
132
132
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -137,7 +137,19 @@ export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation,
|
|
|
137
137
|
result.interruptedTasks += 1;
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
|
-
if (differentOwner && isActiveStatus(task.status)
|
|
140
|
+
if (differentOwner && isActiveStatus(task.status)) {
|
|
141
|
+
if (task.status === "blocked") {
|
|
142
|
+
const cancelledReport = {
|
|
143
|
+
status: "failed",
|
|
144
|
+
summary: "Blocked task was cancelled because its parent Pi session is no longer active.",
|
|
145
|
+
documents: [],
|
|
146
|
+
error: "Owning parent Pi session is no longer active.",
|
|
147
|
+
...(task.currentAttemptId ? { attemptId: task.currentAttemptId } : {}),
|
|
148
|
+
reportedAt: new Date().toISOString(),
|
|
149
|
+
} as const;
|
|
150
|
+
await persistFailureReport(canonicalReportFile, cancelledReport, true);
|
|
151
|
+
if (currentAttemptReport) await writeJsonAtomic(currentAttemptReport, cancelledReport);
|
|
152
|
+
}
|
|
141
153
|
applyTaskStatus(task, "cancelled", { error: "Owning parent Pi session is no longer active." });
|
|
142
154
|
result.cancelledTasks += 1;
|
|
143
155
|
result.interruptedTasks += 1;
|
|
@@ -22,6 +22,8 @@ export interface PersistedReport {
|
|
|
22
22
|
attemptId?: string;
|
|
23
23
|
error?: string;
|
|
24
24
|
question?: string;
|
|
25
|
+
failureKind?: "missing_input";
|
|
26
|
+
requiredInput?: string;
|
|
25
27
|
reportedAt?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -45,6 +47,8 @@ export interface RunRecord {
|
|
|
45
47
|
staleTabIds?: string[];
|
|
46
48
|
question?: string;
|
|
47
49
|
error?: string;
|
|
50
|
+
failureKind?: "missing_input";
|
|
51
|
+
requiredInput?: string;
|
|
48
52
|
cleanupError?: string;
|
|
49
53
|
cleanupPendingTabIds?: string[];
|
|
50
54
|
}
|
|
@@ -178,6 +182,10 @@ export function parseReport(value: unknown, source = "structured report"): Persi
|
|
|
178
182
|
if (report.status === "needs-input" && (typeof report.question !== "string" || !report.question.trim())) throw new Error(`Missing report question in ${source}.`);
|
|
179
183
|
if (report.error !== undefined && typeof report.error !== "string") throw new Error(`Invalid report error in ${source}.`);
|
|
180
184
|
if (report.question !== undefined && typeof report.question !== "string") throw new Error(`Invalid report question in ${source}.`);
|
|
185
|
+
if (report.failureKind !== undefined && report.failureKind !== "missing_input") throw new Error(`Invalid report failureKind in ${source}.`);
|
|
186
|
+
if (report.requiredInput !== undefined && (typeof report.requiredInput !== "string" || !report.requiredInput.trim())) throw new Error(`Invalid report requiredInput in ${source}.`);
|
|
187
|
+
if (report.failureKind === "missing_input" && report.status !== "failed") throw new Error(`missing_input must be a failed report in ${source}.`);
|
|
188
|
+
if (report.failureKind === "missing_input" && (typeof report.requiredInput !== "string" || !report.requiredInput.trim())) throw new Error(`Missing report requiredInput in ${source}.`);
|
|
181
189
|
if (report.attemptId !== undefined && (typeof report.attemptId !== "string" || !report.attemptId.trim())) throw new Error(`Invalid report attemptId in ${source}.`);
|
|
182
190
|
return report as unknown as PersistedReport;
|
|
183
191
|
}
|
|
@@ -186,6 +194,18 @@ export async function readReport(path: string): Promise<PersistedReport> {
|
|
|
186
194
|
return parseReport(await readObject(path), path);
|
|
187
195
|
}
|
|
188
196
|
|
|
197
|
+
export function normalizeNeedsInputReport(report: PersistedReport, effectiveConcurrency: number): PersistedReport {
|
|
198
|
+
if (report.status !== "needs-input" || effectiveConcurrency <= 1) return report;
|
|
199
|
+
return parseReport({
|
|
200
|
+
...report,
|
|
201
|
+
status: "failed",
|
|
202
|
+
error: `Missing required input: ${report.question}`,
|
|
203
|
+
failureKind: "missing_input",
|
|
204
|
+
requiredInput: report.question,
|
|
205
|
+
question: undefined,
|
|
206
|
+
}, "normalized concurrent input request");
|
|
207
|
+
}
|
|
208
|
+
|
|
189
209
|
export async function persistFailureReport(path: string, report: PersistedReport, replaceExisting = false): Promise<string | undefined> {
|
|
190
210
|
if (replaceExisting) {
|
|
191
211
|
await writeJsonAtomic(path, parseReport(report, "generated failure report"));
|
|
@@ -264,5 +284,11 @@ export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
|
|
|
264
284
|
export function applyReport(record: TaskRecord | TurnRecord, report: PersistedReport, completedAt = new Date().toISOString()): "blocked" | "completed" | "failed" {
|
|
265
285
|
const status = report.status === "needs-input" ? "blocked" : report.status;
|
|
266
286
|
applyTaskStatus(record, status, { at: completedAt, question: report.question, error: report.error });
|
|
287
|
+
if ("order" in record) {
|
|
288
|
+
if (report.failureKind) record.failureKind = report.failureKind;
|
|
289
|
+
else delete record.failureKind;
|
|
290
|
+
if (report.requiredInput) record.requiredInput = report.requiredInput;
|
|
291
|
+
else delete record.requiredInput;
|
|
292
|
+
}
|
|
267
293
|
return status;
|
|
268
294
|
}
|
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { readRunRecord } from "./records.ts";
|
|
4
4
|
|
|
5
5
|
export async function findRunDirectory(runsDirectory: string, runId: string): Promise<string | undefined> {
|
|
6
6
|
if (!existsSync(runsDirectory)) return undefined;
|
|
7
|
+
const reference = basename(runId.trim());
|
|
7
8
|
const errors: string[] = [];
|
|
9
|
+
const matches: Array<{ id: string; directory: string }> = [];
|
|
8
10
|
for (const entry of readdirSync(runsDirectory)) {
|
|
9
11
|
const directory = join(runsDirectory, entry);
|
|
10
12
|
const runFile = join(directory, "run.json");
|
|
11
13
|
try {
|
|
12
|
-
|
|
14
|
+
const id = (await readRunRecord(runFile)).id;
|
|
15
|
+
if (id === reference || entry === reference) return directory;
|
|
16
|
+
if (id.startsWith(reference) || entry.endsWith(`-${reference}`)) matches.push({ id, directory });
|
|
13
17
|
} catch (error) {
|
|
14
18
|
errors.push(`${runFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
15
19
|
}
|
|
16
20
|
}
|
|
21
|
+
if (matches.length === 1) return matches[0].directory;
|
|
22
|
+
if (matches.length > 1) throw new Error(`Ambiguous Subagent run reference '${runId}': ${matches.map((match) => match.id).join(", ")}.`);
|
|
17
23
|
if (errors.length) throw new Error(`Could not inspect Subagent runs while looking for '${runId}': ${errors.join("; ")}`);
|
|
18
24
|
return undefined;
|
|
19
25
|
}
|
|
26
|
+
|
|
27
|
+
export function resolveRunIdReference(reference: string, candidates: readonly string[]): string | undefined {
|
|
28
|
+
const trimmed = basename(reference.trim());
|
|
29
|
+
const exact = candidates.find((candidate) => candidate === trimmed);
|
|
30
|
+
if (exact) return exact;
|
|
31
|
+
const matches = [...new Set(candidates.filter((candidate) => candidate.startsWith(trimmed)))];
|
|
32
|
+
if (matches.length > 1) throw new Error(`Ambiguous Subagent run reference '${reference}': ${matches.join(", ")}.`);
|
|
33
|
+
return matches[0];
|
|
34
|
+
}
|
|
@@ -2,7 +2,7 @@ import { existsSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadQueuedRuns } from "./queued-runs.ts";
|
|
4
4
|
import { isSettledRunStatus, readReport, readRunRecord, readTaskRecord, type RunStatus, type TaskStatus } from "./records.ts";
|
|
5
|
-
import { findRunDirectory } from "./run-locator.ts";
|
|
5
|
+
import { findRunDirectory, resolveRunIdReference } from "./run-locator.ts";
|
|
6
6
|
|
|
7
7
|
export interface RunStatusTask {
|
|
8
8
|
order: number;
|
|
@@ -12,6 +12,8 @@ export interface RunStatusTask {
|
|
|
12
12
|
summary?: string;
|
|
13
13
|
question?: string;
|
|
14
14
|
error?: string;
|
|
15
|
+
failureKind?: "missing_input";
|
|
16
|
+
requiredInput?: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface RunStatusSnapshot {
|
|
@@ -44,6 +46,8 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
|
|
|
44
46
|
const report = reportIsCurrent && existsSync(reportFile) ? await readReport(reportFile) : undefined;
|
|
45
47
|
const question = report?.question ?? task.question;
|
|
46
48
|
const error = report?.error ?? task.error;
|
|
49
|
+
const failureKind = report?.failureKind ?? task.failureKind;
|
|
50
|
+
const requiredInput = report?.requiredInput ?? task.requiredInput;
|
|
47
51
|
tasks.push({
|
|
48
52
|
order: task.order,
|
|
49
53
|
name: task.name ?? task.agent ?? `Task ${task.order}`,
|
|
@@ -52,6 +56,8 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
|
|
|
52
56
|
...(report?.summary ? { summary: report.summary } : {}),
|
|
53
57
|
...(question ? { question } : {}),
|
|
54
58
|
...(error ? { error } : {}),
|
|
59
|
+
...(failureKind ? { failureKind } : {}),
|
|
60
|
+
...(requiredInput ? { requiredInput } : {}),
|
|
55
61
|
});
|
|
56
62
|
}
|
|
57
63
|
return {
|
|
@@ -70,9 +76,14 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
|
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
export async function getSubagentRunStatus(cwd: string, runId: string): Promise<RunStatusSnapshot | undefined> {
|
|
79
|
+
const queuedRuns = await loadQueuedRuns(cwd);
|
|
73
80
|
const durable = await loadDurableStatus(cwd, runId);
|
|
74
|
-
if (durable)
|
|
75
|
-
|
|
81
|
+
if (durable) {
|
|
82
|
+
resolveRunIdReference(runId, [durable.runId, ...queuedRuns.map((candidate) => candidate.runId)]);
|
|
83
|
+
return durable;
|
|
84
|
+
}
|
|
85
|
+
const queuedId = resolveRunIdReference(runId, queuedRuns.map((candidate) => candidate.runId));
|
|
86
|
+
const queued = queuedRuns.find((candidate) => candidate.runId === queuedId);
|
|
76
87
|
if (!queued) return undefined;
|
|
77
88
|
return {
|
|
78
89
|
runId: queued.runId,
|
|
@@ -131,17 +131,20 @@ const actionHandlers = {
|
|
|
131
131
|
},
|
|
132
132
|
async cleanup({ params, ctx }: ToolExecutionContext) {
|
|
133
133
|
try {
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
134
|
+
const requestedRunId = params.runId as string;
|
|
135
|
+
const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
|
|
136
|
+
const cleaned = await cleanSubagentRun(ctx.cwd, runId);
|
|
137
|
+
const text = cleaned ? `Removed Subagent run ${runId}.` : `Unknown Subagent run '${requestedRunId}'.`;
|
|
138
|
+
return { content: [{ type: "text", text }], details: { action: "cleanup", runId, cleaned }, ...(cleaned ? {} : { isError: true }) };
|
|
137
139
|
} catch (error) {
|
|
138
140
|
const text = error instanceof Error ? error.message : String(error);
|
|
139
141
|
return errorResponse(text, { action: "cleanup", runId: params.runId, cleaned: false });
|
|
140
142
|
}
|
|
141
143
|
},
|
|
142
144
|
async cancel({ params, ctx, dependencies }: ToolExecutionContext) {
|
|
143
|
-
const
|
|
145
|
+
const requestedRunId = (params.runId as string).trim();
|
|
144
146
|
try {
|
|
147
|
+
const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
|
|
145
148
|
let result: { cancelled: boolean; queued?: boolean; cleanupErrors?: string[] };
|
|
146
149
|
if (dependencies.cancelRun) result = await dependencies.cancelRun(runId);
|
|
147
150
|
else {
|
|
@@ -159,7 +162,7 @@ const actionHandlers = {
|
|
|
159
162
|
const text = result.cancelled ? `Cancelled Subagent run ${runId}.${suffix}` : `Subagent run '${runId}' is not active or queued.`;
|
|
160
163
|
return { content: [{ type: "text", text }], details: { action: "cancel", runId, ...result }, ...(result.cancelled ? {} : { isError: true }) };
|
|
161
164
|
} catch (error) {
|
|
162
|
-
return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId, cancelled: false });
|
|
165
|
+
return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId: requestedRunId, cancelled: false });
|
|
163
166
|
}
|
|
164
167
|
},
|
|
165
168
|
async resume({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext) {
|
|
@@ -182,8 +185,9 @@ const actionHandlers = {
|
|
|
182
185
|
onWarning: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, warning: text } }),
|
|
183
186
|
onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, cleanupError: text } }),
|
|
184
187
|
});
|
|
185
|
-
const
|
|
186
|
-
|
|
188
|
+
const blockedControl = result.status === "blocked" ? { allowedActions: ["respond", "cancel"] as const } : {};
|
|
189
|
+
const text = [result.summary, ...(result.question ? [`Question: ${result.question}`] : []), ...(result.status === "blocked" ? [`Run ID: ${result.runId}`, `Answer with herdr_subagents_control({ action: "respond", runId: "${result.runId}", answer: "..." }) or cancel this run.`] : []), ...result.documents.map((document) => `${document.description}: ${document.path}`)].join("\n");
|
|
190
|
+
return { content: [{ type: "text", text }], details: { ...result, ...blockedControl }, ...(result.status === "failed" || result.status === "partial" || result.status === "cancelled" ? { isError: true } : {}) };
|
|
187
191
|
} catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); }
|
|
188
192
|
},
|
|
189
193
|
async list({ discovery }: ToolExecutionContext) {
|
|
@@ -215,9 +219,10 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
|
|
|
215
219
|
onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: { ...partial, prompts: tasks.map((task) => task.prompt) } }),
|
|
216
220
|
});
|
|
217
221
|
const lines = [result.summary, ...result.tasks.map((task) => `- ${task.name}: ${task.status} — ${task.summary}`)];
|
|
222
|
+
if (result.status === "blocked") lines.push("", `Run ID: ${result.runId}`, `Answer with herdr_subagents_control({ action: "respond", runId: "${result.runId}", answer: "..." }) or cancel this run before starting another foreground run.`);
|
|
218
223
|
if (result.documents.length) lines.push("", "Documents:", ...result.documents.map((document) => `- ${document.description}: ${document.path}`));
|
|
219
224
|
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 } : {}) };
|
|
225
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt), ...(result.status === "blocked" ? { allowedActions: ["respond", "cancel"] } : {}) }, ...(isError ? { isError: true } : {}) };
|
|
221
226
|
} catch (error) {
|
|
222
227
|
const text = error instanceof Error ? error.message : String(error);
|
|
223
228
|
const diagnostic = error as { recordDirectory?: string; runId?: string };
|
|
@@ -240,6 +240,81 @@ async function verifyForegroundFailureHandoff() {
|
|
|
240
240
|
await successPromise;
|
|
241
241
|
|
|
242
242
|
const backgroundGate = deferred<void>();
|
|
243
|
+
const queuedCancellationRoot = join(cwd, "queued-cancellation");
|
|
244
|
+
mkdirSync(queuedCancellationRoot, { recursive: true });
|
|
245
|
+
const activeGate = deferred<void>();
|
|
246
|
+
let queuedCancellationCalls = 0;
|
|
247
|
+
const queuedCancellation = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
|
|
248
|
+
queuedCancellationCalls += 1;
|
|
249
|
+
await activeGate.promise;
|
|
250
|
+
return { content: [{ type: "text", text: "done" }], details: { runId: dependencies.runId, label: "active", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
|
|
251
|
+
});
|
|
252
|
+
await startHarness(queuedCancellation, queuedCancellationRoot);
|
|
253
|
+
const activeBackground = await queuedCancellation.tool?.execute("active", { background: true, tasks: [{ name: "active", prompt: "wait" }] }, undefined, undefined, { cwd: queuedCancellationRoot, ui: queuedCancellation.ui });
|
|
254
|
+
await waitUntil(() => queuedCancellationCalls === 1, "active background run did not start");
|
|
255
|
+
const queuedCaller = new AbortController();
|
|
256
|
+
const queuedForeground = queuedCancellation.tool?.execute("queued", { tasks: [{ name: "queued", prompt: "later" }] }, queuedCaller.signal, undefined, { cwd: queuedCancellationRoot, ui: queuedCancellation.ui });
|
|
257
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
258
|
+
queuedCaller.abort(new Error("foreground tool call cancelled"));
|
|
259
|
+
const queuedCancellationError = await Promise.race([
|
|
260
|
+
queuedForeground.then(() => "resolved", (error: Error) => error.message),
|
|
261
|
+
new Promise<string>((_, reject) => setTimeout(() => reject(new Error("queued foreground call ignored cancellation")), 100)),
|
|
262
|
+
]);
|
|
263
|
+
if (queuedCancellationError !== "foreground tool call cancelled" || queuedCancellationCalls !== 1) throw new Error("extension did not remove a cancelled foreground run from the dispatcher queue");
|
|
264
|
+
const activeNotification = queuedCancellation.waitForMessage((message) => message.details?.runId === activeBackground.details.runId);
|
|
265
|
+
activeGate.resolve();
|
|
266
|
+
await activeNotification;
|
|
267
|
+
|
|
268
|
+
const blockedOwnerRoot = join(cwd, "blocked-owner");
|
|
269
|
+
mkdirSync(blockedOwnerRoot, { recursive: true });
|
|
270
|
+
let blockedOwnerCalls = 0;
|
|
271
|
+
let blockedOwnerRunId = "";
|
|
272
|
+
let blockedSuccessorStarted = false;
|
|
273
|
+
const becomeRetained = deferred<void>();
|
|
274
|
+
const blockedOwner = harness(async (params, _signal, _onUpdate, _ctx, dependencies) => {
|
|
275
|
+
if (params.action === "respond") {
|
|
276
|
+
return { content: [{ type: "text", text: "answered" }], details: { runId: blockedOwnerRunId, label: "blocked owner", status: "completed", summary: "answered", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
|
|
277
|
+
}
|
|
278
|
+
blockedOwnerCalls += 1;
|
|
279
|
+
if (blockedOwnerCalls > 1) {
|
|
280
|
+
blockedSuccessorStarted = true;
|
|
281
|
+
return { content: [{ type: "text", text: "successor done" }], details: { runId: dependencies.runId, label: "successor", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
|
|
282
|
+
}
|
|
283
|
+
blockedOwnerRunId = dependencies.runId;
|
|
284
|
+
if (blockedOwnerCalls === 1) await becomeRetained.promise;
|
|
285
|
+
return {
|
|
286
|
+
content: [{ type: "text", text: "waiting for input" }],
|
|
287
|
+
details: {
|
|
288
|
+
runId: dependencies.runId, label: "blocked owner", status: "blocked", summary: "waiting for input", question: "Continue?", documents: [],
|
|
289
|
+
tasks: [blockedTask], activity: [{ index: 1, name: blockedTask.name, status: "blocked" }], effectiveConcurrency: 1, requestedConcurrency: 1,
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
await startHarness(blockedOwner, blockedOwnerRoot);
|
|
294
|
+
const ownerPromise = blockedOwner.tool?.execute("blocked-owner", { tasks: [{ name: blockedTask.name, prompt: "block" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
|
|
295
|
+
await waitUntil(() => blockedOwnerCalls === 1, "blocked owner did not start");
|
|
296
|
+
const queuedBeforeBlocked = blockedOwner.tool?.execute("queued-before-blocked", { tasks: [{ name: "queued", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
|
|
297
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
298
|
+
becomeRetained.resolve();
|
|
299
|
+
const ownerResult = await ownerPromise;
|
|
300
|
+
const retainedForeground = await Promise.race([
|
|
301
|
+
queuedBeforeBlocked,
|
|
302
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("foreground run queued before blocked transition remained stuck")), 100)),
|
|
303
|
+
]);
|
|
304
|
+
if (!retainedForeground.isError || retainedForeground.details?.blockingRunId !== ownerResult.details.runId || blockedOwnerCalls !== 1) throw new Error("blocked transition did not reject an already-queued foreground run");
|
|
305
|
+
const blockedForeground = await Promise.race([
|
|
306
|
+
blockedOwner.tool?.execute("blocked-successor", { tasks: [{ name: "successor", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui }),
|
|
307
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("foreground run waited behind a blocked dispatcher owner")), 100)),
|
|
308
|
+
]);
|
|
309
|
+
if (!blockedForeground.isError || blockedForeground.details?.blockingRunId !== ownerResult.details.runId || !blockedForeground.content?.[0]?.text.includes(ownerResult.details.runId)) {
|
|
310
|
+
throw new Error("foreground run blocked by dispatcher omitted the blocking run ID");
|
|
311
|
+
}
|
|
312
|
+
const queuedBehindBlocked = await blockedOwner.tool?.execute("blocked-background", { background: true, tasks: [{ name: "background", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
|
|
313
|
+
if (queuedBehindBlocked.details?.status !== "queued" || blockedOwnerCalls !== 1) throw new Error("background run did not remain queued behind the blocked dispatcher owner");
|
|
314
|
+
await blockedOwner.controlTool?.execute("respond-prefix", { action: "respond", runId: ownerResult.details.runId.slice(0, 8), answer: "continue" }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
|
|
315
|
+
await waitUntil(() => blockedSuccessorStarted, "responding through a run ID prefix did not release the blocked dispatcher owner");
|
|
316
|
+
await blockedOwner.handlers.get("session_shutdown")?.({}, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
|
|
317
|
+
|
|
243
318
|
const explicitBackground = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
|
|
244
319
|
await backgroundGate.promise;
|
|
245
320
|
return { content: [{ type: "text", text: "background done" }], details: { runId: dependencies.runId, label: "background", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { getEventListeners } from "node:events";
|
|
5
5
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
@@ -81,7 +81,7 @@ function fakeHerdr(mode = "completed") {
|
|
|
81
81
|
calls.push(["promptAgent", request]);
|
|
82
82
|
const current = agents.get(request.target) ?? { directory: taskDirectory };
|
|
83
83
|
const directory = current.directory;
|
|
84
|
-
|
|
84
|
+
const runNumber = ++taskRuns;
|
|
85
85
|
active += 1;
|
|
86
86
|
maxActive = Math.max(maxActive, active);
|
|
87
87
|
await new Promise((resolve) => setTimeout(resolve, request.prompt.includes("slow") ? 15 : 2));
|
|
@@ -115,7 +115,7 @@ function fakeHerdr(mode = "completed") {
|
|
|
115
115
|
active -= 1;
|
|
116
116
|
throw new Error("connection lost after authoritative report");
|
|
117
117
|
}
|
|
118
|
-
if (mode === "post-fail" || (mode === "blocked-then-post-fail" &&
|
|
118
|
+
if (mode === "post-fail" || (mode === "blocked-then-post-fail" && runNumber > 1)) { active -= 1; throw new Error("connection lost after prompt submission"); }
|
|
119
119
|
writeFileSync(join(directory, "session.jsonl"), `${JSON.stringify({ type: "session", version: 3, id: request.target, cwd: root })}\n`);
|
|
120
120
|
if (mode === "completed" || mode === "close-failed") {
|
|
121
121
|
const failed = request.prompt.includes("FAIL");
|
|
@@ -137,9 +137,9 @@ function fakeHerdr(mode = "completed") {
|
|
|
137
137
|
} else if (mode === "malformed") {
|
|
138
138
|
writeAttemptReport(directory, "{truncated");
|
|
139
139
|
} else if (mode === "blocked" || mode === "blocked-twice" || mode === "blocked-then-post-fail") {
|
|
140
|
-
const needsInput = ((mode === "blocked" || mode === "blocked-then-post-fail") &&
|
|
140
|
+
const needsInput = ((mode === "blocked" || mode === "blocked-then-post-fail") && runNumber === 1) || (mode === "blocked-twice" && (runNumber === 1 || runNumber === 3));
|
|
141
141
|
const failed = request.prompt.includes("FAIL");
|
|
142
|
-
const question =
|
|
142
|
+
const question = runNumber === 3 ? "Which environment should I inspect?" : "Which branch should I inspect?";
|
|
143
143
|
writeFileSync(join(directory, "result.md"), `FULL ${request.prompt}`);
|
|
144
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: [] });
|
|
145
145
|
}
|
|
@@ -533,6 +533,15 @@ try {
|
|
|
533
533
|
check("read-only batch uses bounded concurrency", batchDetails.effectiveConcurrency === 2 && batchHerdr.maxActive === 2 && batchHerdr.calls.filter(([name]) => name === "splitPane").length === 3);
|
|
534
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);
|
|
535
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 parallelQuestionHerdr = fakeHerdr("blocked");
|
|
537
|
+
const parallelQuestion = await executeHerdrSubagents({ concurrency: 2, tasks: [
|
|
538
|
+
{ name: "missing-input", prompt: "question", agent: "explorer" },
|
|
539
|
+
{ name: "independent", prompt: "continue", agent: "reviewer" },
|
|
540
|
+
] }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: parallelQuestionHerdr });
|
|
541
|
+
const parallelQuestionTask = JSON.parse(readFileSync(join(parallelQuestion.details.tasks[0].recordDirectory, "task.json"), "utf8"));
|
|
542
|
+
const parallelQuestionReport = JSON.parse(readFileSync(join(parallelQuestion.details.tasks[0].recordDirectory, "report.json"), "utf8"));
|
|
543
|
+
const parallelQuestionAttemptReport = JSON.parse(readFileSync(join(parallelQuestion.details.tasks[0].recordDirectory, "reports", `${parallelQuestionTask.currentAttemptId}.json`), "utf8"));
|
|
544
|
+
check("parallel needs-input becomes a structured failure while independent siblings finish", parallelQuestion.isError && parallelQuestion.details.status === "partial" && parallelQuestion.details.tasks[0].status === "failed" && parallelQuestion.details.tasks[0].failureKind === "missing_input" && parallelQuestion.details.tasks[0].requiredInput === "Which branch should I inspect?" && parallelQuestion.details.tasks[1].status === "completed" && parallelQuestionTask.status === "failed" && parallelQuestionReport.status === "failed" && parallelQuestionReport.failureKind === "missing_input" && parallelQuestionAttemptReport.status === "failed" && parallelQuestionAttemptReport.failureKind === "missing_input" && parallelQuestionHerdr.calls.some(([name]) => name === "closeTab"));
|
|
536
545
|
const mixedCancelHerdr = fakeHerdr();
|
|
537
546
|
const mixedCancelPrompt = mixedCancelHerdr.promptAgent.bind(mixedCancelHerdr);
|
|
538
547
|
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);
|
|
@@ -677,6 +686,20 @@ try {
|
|
|
677
686
|
check("dispatcher cancels only the selected queued run", cancellingDispatcher.cancelQueuedRun("remove-me", new Error("cancelled by user")) && await removedQueued === "cancelled by user" && cancellingDispatcher.snapshot().queuedRunIds.join(",") === "keep-me");
|
|
678
687
|
cancellingDispatcher.resume();
|
|
679
688
|
check("dispatcher still executes uncancelled queued siblings", await keptQueued === "kept");
|
|
689
|
+
const abortingDispatcher = new RunDispatcher();
|
|
690
|
+
abortingDispatcher.pause();
|
|
691
|
+
const queuedAbort = new AbortController();
|
|
692
|
+
let abortedQueuedStarted = false;
|
|
693
|
+
const abortedQueued = abortingDispatcher.submit("abort-me", async () => { abortedQueuedStarted = true; return "unexpected"; }, { signal: queuedAbort.signal }).catch((error) => error.message);
|
|
694
|
+
queuedAbort.abort(new Error("foreground tool call cancelled"));
|
|
695
|
+
check("queued foreground dispatch responds to caller cancellation", await abortedQueued === "foreground tool call cancelled" && !abortedQueuedStarted && abortingDispatcher.snapshot().queuedRunIds.length === 0);
|
|
696
|
+
const retainingDispatcher = new RunDispatcher();
|
|
697
|
+
let settleRetainingOwner;
|
|
698
|
+
let retainedWaiterStarted = false;
|
|
699
|
+
const retainingOwner = retainingDispatcher.submit("retaining-owner", async () => { await new Promise((resolve) => { settleRetainingOwner = resolve; }); retainingDispatcher.retain("retaining-owner"); return "blocked"; });
|
|
700
|
+
const retainedWaiter = retainingDispatcher.submit("foreground-waiter", async () => { retainedWaiterStarted = true; return "unexpected"; }, { onRetained: (runId) => `blocked by ${runId}` });
|
|
701
|
+
settleRetainingOwner();
|
|
702
|
+
check("retaining an active run rejects already-queued foreground waiters", await retainingOwner === "blocked" && await retainedWaiter === "blocked by retaining-owner" && !retainedWaiterStarted && retainingDispatcher.snapshot().queuedRunIds.length === 0);
|
|
680
703
|
const retainedDispatcher = new RunDispatcher();
|
|
681
704
|
const retainedStarts = [];
|
|
682
705
|
const retainedFirst = retainedDispatcher.submit("blocked-run", async () => { retainedStarts.push("blocked-run"); retainedDispatcher.retain("blocked-run"); return "blocked"; });
|
|
@@ -735,8 +758,8 @@ try {
|
|
|
735
758
|
|
|
736
759
|
const blockedHerdr = fakeHerdr("blocked");
|
|
737
760
|
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 });
|
|
738
|
-
check("needs-input returns foreground control
|
|
739
|
-
const blockedStatus = await executeHerdrSubagents({ action: "status", runId: blocked.details.
|
|
761
|
+
check("needs-input returns foreground control with its full run ID and allowed actions", !blocked.isError && blocked.details.status === "blocked" && blocked.details.question === "Which branch should I inspect?" && blocked.details.tasks[0].question === blocked.details.question && blocked.details.allowedActions?.join(",") === "respond,cancel" && blocked.content[0].text.includes(blocked.details.runId) && !blockedHerdr.calls.some(([name]) => name === "closeTab"));
|
|
762
|
+
const blockedStatus = await executeHerdrSubagents({ action: "status", runId: basename(blocked.details.recordDirectory) }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents") });
|
|
740
763
|
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?"));
|
|
741
764
|
const activeCleanup = await executeHerdrSubagents({ action: "cleanup", runId: blocked.details.runId }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
|
|
742
765
|
check("cleanup refuses to delete an active blocked run", activeCleanup.isError && activeCleanup.content[0].text.includes("still active") && existsSync(blocked.details.recordDirectory));
|
|
@@ -751,7 +774,7 @@ try {
|
|
|
751
774
|
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
775
|
rmSync(join(staleLockBlocked.details.recordDirectory, ".respond.lock"));
|
|
753
776
|
const resumeUpdates = [];
|
|
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 });
|
|
777
|
+
const resumed = await executeHerdrSubagents({ action: "respond", runId: blocked.details.runId.slice(0, 8), answer: "Inspect main." }, undefined, (update) => resumeUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: blockedHerdr });
|
|
755
778
|
const blockedStarts = blockedHerdr.calls.filter(([name]) => name === "startAgent").map(([, request]) => request);
|
|
756
779
|
const blockedPrompts = blockedHerdr.calls.filter(([name]) => name === "promptAgent").map(([, request]) => request);
|
|
757
780
|
check("answer resumes original live Agent and queued sibling gets a new pane", !resumed.isError && resumed.details.status === "completed" && blockedPrompts.length === 3 && blockedPrompts[1].target === blockedPrompts[0].target && blockedStarts.length === 2 && new Set(blockedStarts.map((request) => request.paneId)).size === 2);
|
|
@@ -765,7 +788,7 @@ try {
|
|
|
765
788
|
const twiceBlockedHerdr = fakeHerdr("blocked-twice");
|
|
766
789
|
const twiceBlocked = await executeHerdrSubagents({ concurrency: 1, tasks: [{ name: "first-question", prompt: "first", agent: "explorer" }, { name: "second-question", prompt: "second", agent: "reviewer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: twiceBlockedHerdr });
|
|
767
790
|
const secondQuestion = await executeHerdrSubagents({ action: "respond", runId: twiceBlocked.details.runId, answer: "main" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: twiceBlockedHerdr });
|
|
768
|
-
check("queued sibling blocking returns its current question and Fleet pane", secondQuestion.details.status === "blocked" && secondQuestion.details.question === "Which environment should I inspect?" && secondQuestion.content[0].text.includes(
|
|
791
|
+
check("queued sibling blocking returns its current question, actions, and Fleet pane", secondQuestion.details.status === "blocked" && secondQuestion.details.question === "Which environment should I inspect?" && secondQuestion.details.allowedActions?.join(",") === "respond,cancel" && secondQuestion.content[0].text.includes(secondQuestion.details.runId) && secondQuestion.details.activity[1].status === "blocked" && Boolean(secondQuestion.details.activity[1].paneId));
|
|
769
792
|
const failedAfterBlockHerdr = fakeHerdr("blocked");
|
|
770
793
|
const failedAfterBlock = await executeHerdrSubagents({ concurrency: 1, tasks: [{ name: "question-first", prompt: "question", agent: "explorer" }, { name: "fails-later", prompt: "FAIL sibling", agent: "reviewer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: failedAfterBlockHerdr });
|
|
771
794
|
const failedAnswer = await executeHerdrSubagents({ action: "respond", runId: failedAfterBlock.details.runId, answer: "main" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: failedAfterBlockHerdr });
|
|
@@ -865,7 +888,7 @@ try {
|
|
|
865
888
|
check("history lists compact project-local runs and tasks", Boolean(stableHistory?.tasks.length) && historyText(history).includes("stable") && !historyText(history).includes("FULL stable"));
|
|
866
889
|
const historyHerdr = fakeHerdr();
|
|
867
890
|
const historicalUpdates = [];
|
|
868
|
-
const historicalResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "follow up" }, undefined, (update) => historicalUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: historyHerdr });
|
|
891
|
+
const historicalResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId.slice(0, 8), task: 1, prompt: "follow up" }, undefined, (update) => historicalUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: historyHerdr });
|
|
869
892
|
const historyStartRequest = historyHerdr.calls.find(([name]) => name === "startAgent")[1];
|
|
870
893
|
check("historical resume preserves the saved Pi session and persona prompt", !historicalResume.isError && historyHerdr.calls[0][0] === "createTab" && historyStartRequest.args.includes(stableHistory.tasks[0].sessionFile) && historyStartRequest.args.includes(stableHistory.tasks[0].model) && historyStartRequest.args.some((value) => String(value).includes("subagent_report")) && existsSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json")) && readFileSync(join(stableHistory.tasks[0].recordDirectory, "followup-system-prompt.md"), "utf8").includes("Investigate the assigned question without modifying files"));
|
|
871
894
|
check("historical resume streams authoritative Fleet activity", historicalUpdates.some((update) => update.status === "running" && update.activity?.[0]?.agent === "explorer" && update.activity[0].sessionFile === stableHistory.tasks[0].sessionFile));
|
|
@@ -917,13 +940,11 @@ try {
|
|
|
917
940
|
check("parent shutdown marks a historical follow-up run cancelled despite completed siblings", historicalCancelledRun.status === "cancelled" && historicalCancelledRun.error.includes("Parent Pi session closed"));
|
|
918
941
|
const followupBlockedHerdr = fakeHerdr("blocked");
|
|
919
942
|
const blockedFollowup = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "ask a follow-up" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: followupBlockedHerdr });
|
|
920
|
-
const duplicateResumeHerdr = fakeHerdr();
|
|
921
|
-
const duplicateResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "duplicate live follow-up" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: duplicateResumeHerdr });
|
|
922
|
-
check("historical resume rejects an active run without starting a second Agent", duplicateResume.isError && duplicateResume.content[0].text.includes("still active") && duplicateResumeHerdr.calls.length === 0);
|
|
923
|
-
const answeredFollowup = await executeHerdrSubagents({ action: "respond", runId: backgroundId, answer: "follow-up answer" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: followupBlockedHerdr });
|
|
924
943
|
const historicalTurns = join(stableHistory.tasks[0].recordDirectory, "turns");
|
|
925
|
-
const
|
|
926
|
-
|
|
944
|
+
const failedInputTurn = JSON.parse(readFileSync(join(historicalTurns, "03.json"), "utf8"));
|
|
945
|
+
const failedInputTask = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "task.json"), "utf8"));
|
|
946
|
+
const failedInputAttempt = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "reports", `${failedInputTask.currentAttemptId}.json`), "utf8"));
|
|
947
|
+
check("historical input requests fail instead of creating unowned blocked work", blockedFollowup.isError && blockedFollowup.details.status === "failed" && failedInputTurn.status === "failed" && failedInputAttempt.failureKind === "missing_input" && followupBlockedHerdr.calls.at(-1)[0] === "closeTab");
|
|
927
948
|
const prePromptFailureTaskFile = join(stableHistory.tasks[0].recordDirectory, "task.json");
|
|
928
949
|
const prePromptStaleTask = JSON.parse(readFileSync(prePromptFailureTaskFile, "utf8"));
|
|
929
950
|
Object.assign(prePromptStaleTask, { question: "stale question", error: "stale error", completedAt: "2000-01-01T00:00:00.000Z" });
|
|
@@ -952,7 +973,7 @@ try {
|
|
|
952
973
|
const ignoreText = readFileSync(ignoreFile, "utf8");
|
|
953
974
|
check("runtime archive is ignored without hiding personas or overwriting unrelated rules", ignoreText.includes("herdr-subagents/runs/") && !ignoreText.split(/\r?\n/).includes("herdr-subagents/") && ignoreText.includes("unrelated-state/") && ignoreText.match(/herdr-subagents\/runs\//g)?.length === 1);
|
|
954
975
|
const remainingRunId = history.find((run) => run.id !== backgroundId)?.id;
|
|
955
|
-
const cleaned = await executeHerdrSubagents({ action: "cleanup", runId:
|
|
976
|
+
const cleaned = await executeHerdrSubagents({ action: "cleanup", runId: basename(stableHistory.recordDirectory) }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: fakeHerdr() });
|
|
956
977
|
const afterCleanup = await listSubagentHistory(root);
|
|
957
978
|
check("explicit cleanup removes only the selected run", !cleaned.isError && !afterCleanup.some((run) => run.id === backgroundId) && (!remainingRunId || afterCleanup.some((run) => run.id === remainingRunId)));
|
|
958
979
|
|
|
@@ -1079,6 +1100,33 @@ try {
|
|
|
1079
1100
|
writeFileSync(join(ownerLossTask, "reports", `${ownerLossAttempt}.json`), JSON.stringify({ attemptId: ownerLossAttempt, status: "completed", summary: "Completed before owner loss.", documents: [] }));
|
|
1080
1101
|
await reconcileSubagentRuns(ownerLossRoot, fakeHerdr(), { sessionId: "new-owner", processId: process.pid });
|
|
1081
1102
|
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);
|
|
1103
|
+
const blockedOwnerLossRoot = join(root, "blocked-owner-loss-recovery");
|
|
1104
|
+
const blockedOwnerLossRun = join(blockedOwnerLossRoot, ".pi", "herdr-subagents", "runs", "run");
|
|
1105
|
+
const blockedOwnerLossTask = join(blockedOwnerLossRun, "tasks", "01-task");
|
|
1106
|
+
const blockedOwnerLossAttempt = "66666666-6666-4666-8666-666666666666";
|
|
1107
|
+
mkdirSync(join(blockedOwnerLossTask, "reports"), { recursive: true });
|
|
1108
|
+
writeFileSync(join(blockedOwnerLossRun, "run.json"), JSON.stringify({ id: "blocked-owner-loss", label: "blocked-owner-loss", status: "running", effectiveConcurrency: 1, ownerSessionId: "dead-owner", ownerProcessId: 99999999, tabId: "w1:t-blocked-owner-loss", startedAt: new Date().toISOString() }));
|
|
1109
|
+
writeFileSync(join(blockedOwnerLossTask, "task.json"), JSON.stringify({ id: "blocked-owner-loss-task", runId: "blocked-owner-loss", order: 1, status: "running", paneId: "w1:p-blocked-owner-loss", currentAttemptId: blockedOwnerLossAttempt, sessionFile: join(blockedOwnerLossTask, "session.jsonl") }));
|
|
1110
|
+
writeFileSync(join(blockedOwnerLossTask, "reports", `${blockedOwnerLossAttempt}.json`), JSON.stringify({ attemptId: blockedOwnerLossAttempt, status: "needs-input", summary: "Need input before owner loss.", question: "Which branch?", documents: [] }));
|
|
1111
|
+
const blockedOwnerLossHerdr = fakeHerdr();
|
|
1112
|
+
await reconcileSubagentRuns(blockedOwnerLossRoot, blockedOwnerLossHerdr, { sessionId: "new-owner", processId: process.pid });
|
|
1113
|
+
const cancelledBlockedOwnerTask = JSON.parse(readFileSync(join(blockedOwnerLossTask, "task.json"), "utf8"));
|
|
1114
|
+
const cancelledBlockedOwnerReport = JSON.parse(readFileSync(join(blockedOwnerLossTask, "report.json"), "utf8"));
|
|
1115
|
+
const cancelledBlockedOwnerAttempt = JSON.parse(readFileSync(join(blockedOwnerLossTask, "reports", `${blockedOwnerLossAttempt}.json`), "utf8"));
|
|
1116
|
+
check("owner-loss reconciliation never leaves blocked work without dispatcher ownership", cancelledBlockedOwnerTask.status === "cancelled" && cancelledBlockedOwnerReport.status === "failed" && !cancelledBlockedOwnerReport.question && cancelledBlockedOwnerAttempt.status === "failed" && !cancelledBlockedOwnerAttempt.question && JSON.parse(readFileSync(join(blockedOwnerLossRun, "run.json"), "utf8")).status === "cancelled" && blockedOwnerLossHerdr.calls.some(([name]) => name === "closeTab"));
|
|
1117
|
+
const parallelInputRecoveryRoot = join(root, "parallel-input-recovery");
|
|
1118
|
+
const parallelInputRecoveryRun = join(parallelInputRecoveryRoot, ".pi", "herdr-subagents", "runs", "run");
|
|
1119
|
+
const parallelInputRecoveryTask = join(parallelInputRecoveryRun, "tasks", "01-task");
|
|
1120
|
+
const parallelInputRecoveryAttempt = "55555555-5555-4555-8555-555555555555";
|
|
1121
|
+
mkdirSync(join(parallelInputRecoveryTask, "reports"), { recursive: true });
|
|
1122
|
+
writeFileSync(join(parallelInputRecoveryRun, "run.json"), JSON.stringify({ id: "parallel-input-recovery", label: "parallel-input-recovery", status: "running", effectiveConcurrency: 2, ownerSessionId: "dead-owner", ownerProcessId: 99999999, tabId: "w1:t-parallel-input", startedAt: new Date().toISOString() }));
|
|
1123
|
+
writeFileSync(join(parallelInputRecoveryTask, "task.json"), JSON.stringify({ id: "parallel-input-task", runId: "parallel-input-recovery", order: 1, status: "running", paneId: "w1:p-parallel-input", currentAttemptId: parallelInputRecoveryAttempt, sessionFile: join(parallelInputRecoveryTask, "session.jsonl") }));
|
|
1124
|
+
writeFileSync(join(parallelInputRecoveryTask, "reports", `${parallelInputRecoveryAttempt}.json`), JSON.stringify({ attemptId: parallelInputRecoveryAttempt, status: "needs-input", summary: "Need a branch.", question: "Which branch?", documents: [] }));
|
|
1125
|
+
await reconcileSubagentRuns(parallelInputRecoveryRoot, fakeHerdr(), { sessionId: "new-owner", processId: process.pid });
|
|
1126
|
+
const recoveredParallelInputTask = JSON.parse(readFileSync(join(parallelInputRecoveryTask, "task.json"), "utf8"));
|
|
1127
|
+
const recoveredParallelInputReport = JSON.parse(readFileSync(join(parallelInputRecoveryTask, "report.json"), "utf8"));
|
|
1128
|
+
const recoveredParallelInputAttempt = JSON.parse(readFileSync(join(parallelInputRecoveryTask, "reports", `${parallelInputRecoveryAttempt}.json`), "utf8"));
|
|
1129
|
+
check("reconciliation keeps concurrent input requests out of blocked state", recoveredParallelInputTask.status === "failed" && recoveredParallelInputTask.failureKind === "missing_input" && recoveredParallelInputReport.status === "failed" && recoveredParallelInputReport.requiredInput === "Which branch?" && recoveredParallelInputAttempt.status === "failed" && recoveredParallelInputAttempt.failureKind === "missing_input" && JSON.parse(readFileSync(join(parallelInputRecoveryRun, "run.json"), "utf8")).status === "failed");
|
|
1082
1130
|
const malformedRoot = join(root, "malformed-recovery");
|
|
1083
1131
|
const malformedRun = join(malformedRoot, ".pi", "herdr-subagents", "runs", "run");
|
|
1084
1132
|
const malformedTask = join(malformedRun, "tasks", "01-task");
|