@maxiaochao/pi-toolkit 0.10.20 → 0.10.22
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 +12 -0
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +26 -0
- package/packages/herdr-subagents/README.md +21 -7
- package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
- package/packages/herdr-subagents/TESTING.md +15 -2
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +15 -21
- package/packages/herdr-subagents/extensions/subagent-report.ts +2 -2
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +58 -11
- package/packages/herdr-subagents/src/blocking.ts +115 -72
- package/packages/herdr-subagents/src/continue-run.ts +21 -4
- package/packages/herdr-subagents/src/dispatcher.ts +44 -4
- package/packages/herdr-subagents/src/fleet.ts +2 -1
- package/packages/herdr-subagents/src/herdr.ts +10 -1
- package/packages/herdr-subagents/src/historical-resume.ts +184 -0
- package/packages/herdr-subagents/src/history.ts +11 -128
- package/packages/herdr-subagents/src/live-agent.ts +99 -35
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/protocol.ts +3 -1
- package/packages/herdr-subagents/src/reconcile.ts +156 -123
- package/packages/herdr-subagents/src/records.ts +50 -6
- package/packages/herdr-subagents/src/resume-state.ts +46 -0
- package/packages/herdr-subagents/src/run-locator.ts +17 -2
- package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
- package/packages/herdr-subagents/src/run-state.ts +120 -0
- package/packages/herdr-subagents/src/status.ts +8 -3
- package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
- package/packages/herdr-subagents/src/tool.ts +14 -62
- package/packages/herdr-subagents/src/validation.ts +1 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +78 -3
- package/packages/herdr-subagents/tests/run-tests.mjs +608 -64
- package/packages/herdr-subagents/src/runner.ts +0 -234
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { isProcessAlive } from "./ownership.ts";
|
|
5
|
+
import { readRunRecord, type RunRecord } from "./records.ts";
|
|
6
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
7
|
+
|
|
8
|
+
interface LockOwner {
|
|
9
|
+
token: string;
|
|
10
|
+
processId: number;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DirectoryLock {
|
|
15
|
+
directory: string;
|
|
16
|
+
token: string;
|
|
17
|
+
release(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const INITIALIZATION_STALE_MS = 30_000;
|
|
21
|
+
const delay = (milliseconds: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => {
|
|
22
|
+
const onAbort = () => { clearTimeout(timer); reject(signal?.reason ?? new Error("Lock wait aborted.")); };
|
|
23
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, milliseconds);
|
|
24
|
+
if (signal?.aborted) onAbort();
|
|
25
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function readOwner(directory: string): Promise<LockOwner | undefined> {
|
|
29
|
+
try {
|
|
30
|
+
const value = JSON.parse(await readFile(join(directory, "owner.json"), "utf8")) as Partial<LockOwner>;
|
|
31
|
+
if (typeof value.token === "string" && Number.isInteger(value.processId)) return { ...value, createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date(0).toISOString() } as LockOwner;
|
|
32
|
+
} catch { /* An invalid/legacy lock is handled only after its initialization lease expires. */ }
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function reclaimIfStale(directory: string): Promise<boolean> {
|
|
37
|
+
const owner = await readOwner(directory);
|
|
38
|
+
if (owner && isProcessAlive(owner.processId)) return false;
|
|
39
|
+
let identity;
|
|
40
|
+
try { identity = await lstat(directory); }
|
|
41
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; }
|
|
42
|
+
// A missing owner is an explicit initialization state. Atomic publication below does
|
|
43
|
+
// not create it, but retain a lease for old/crashed lock directories.
|
|
44
|
+
if (!owner && Date.now() - identity.mtimeMs < INITIALIZATION_STALE_MS) return false;
|
|
45
|
+
const stale = `${directory}.stale-${randomUUID()}`;
|
|
46
|
+
// Revalidate both ownership and directory identity immediately before reclamation.
|
|
47
|
+
const latestOwner = await readOwner(directory);
|
|
48
|
+
if (latestOwner && isProcessAlive(latestOwner.processId)) return false;
|
|
49
|
+
let latestIdentity;
|
|
50
|
+
try { latestIdentity = await lstat(directory); }
|
|
51
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; }
|
|
52
|
+
if (latestIdentity.dev !== identity.dev || latestIdentity.ino !== identity.ino) return true;
|
|
53
|
+
try { await rename(directory, stale); }
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (["ENOENT", "ENOTEMPTY", "EEXIST"].includes(String((error as NodeJS.ErrnoException).code))) return true;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
await rm(stale, { recursive: true, force: true });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function acquireDirectoryLock(directory: string, options: { wait: boolean; signal?: AbortSignal; contentionMessage?: string; beforePublish?: () => void | Promise<void> }): Promise<DirectoryLock> {
|
|
63
|
+
const token = randomUUID();
|
|
64
|
+
const owner: LockOwner = { token, processId: process.pid, createdAt: new Date().toISOString() };
|
|
65
|
+
for (;;) {
|
|
66
|
+
const prepared = `${directory}.initializing-${token}`;
|
|
67
|
+
await mkdir(prepared, { mode: 0o700 });
|
|
68
|
+
try {
|
|
69
|
+
await writeJsonAtomic(join(prepared, "owner.json"), owner);
|
|
70
|
+
await options.beforePublish?.();
|
|
71
|
+
try { await rename(prepared, directory); }
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(String((error as NodeJS.ErrnoException).code))) throw error;
|
|
74
|
+
await rm(prepared, { recursive: true, force: true });
|
|
75
|
+
if (await reclaimIfStale(directory)) continue;
|
|
76
|
+
if (!options.wait) throw new Error(options.contentionMessage ?? `Lock is already held: ${directory}`);
|
|
77
|
+
await delay(10, options.signal);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
directory,
|
|
82
|
+
token,
|
|
83
|
+
async release() {
|
|
84
|
+
if ((await readOwner(directory))?.token !== token) return;
|
|
85
|
+
const released = `${directory}.released-${token}`;
|
|
86
|
+
try { await rename(directory, released); }
|
|
87
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
|
88
|
+
// Delete only the detached inode; a contender may safely publish the next
|
|
89
|
+
// generation at the canonical path immediately after the rename.
|
|
90
|
+
await rm(released, { recursive: true, force: true });
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
await rm(prepared, { recursive: true, force: true });
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Only the holder of this lock may start a run's queued tasks. */
|
|
101
|
+
export function queueLockPath(runDirectory: string): string { return join(runDirectory, ".queue.lock"); }
|
|
102
|
+
|
|
103
|
+
/** Replaces a long-lived in-memory copy with the record observed under the lock, including removals. */
|
|
104
|
+
export function adoptRecord<T extends object>(target: T, latest: T): T {
|
|
105
|
+
const mutable = target as unknown as Record<string, unknown>;
|
|
106
|
+
for (const key of Object.keys(mutable)) if (!(key in latest)) delete mutable[key];
|
|
107
|
+
return Object.assign(target, latest);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function withRunStateLock<T>(runFile: string, signal: AbortSignal | undefined, mutate: (run: RunRecord) => T | Promise<T>): Promise<T> {
|
|
111
|
+
const lock = await acquireDirectoryLock(join(dirname(runFile), ".state.lock"), { wait: true, signal });
|
|
112
|
+
try {
|
|
113
|
+
const run = await readRunRecord(runFile);
|
|
114
|
+
const result = await mutate(run);
|
|
115
|
+
await writeJsonAtomic(runFile, run);
|
|
116
|
+
return result;
|
|
117
|
+
} finally {
|
|
118
|
+
await lock.release();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -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;
|
|
@@ -70,9 +70,14 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export async function getSubagentRunStatus(cwd: string, runId: string): Promise<RunStatusSnapshot | undefined> {
|
|
73
|
+
const queuedRuns = await loadQueuedRuns(cwd);
|
|
73
74
|
const durable = await loadDurableStatus(cwd, runId);
|
|
74
|
-
if (durable)
|
|
75
|
-
|
|
75
|
+
if (durable) {
|
|
76
|
+
resolveRunIdReference(runId, [durable.runId, ...queuedRuns.map((candidate) => candidate.runId)]);
|
|
77
|
+
return durable;
|
|
78
|
+
}
|
|
79
|
+
const queuedId = resolveRunIdReference(runId, queuedRuns.map((candidate) => candidate.runId));
|
|
80
|
+
const queued = queuedRuns.find((candidate) => candidate.runId === queuedId);
|
|
76
81
|
if (!queued) return undefined;
|
|
77
82
|
return {
|
|
78
83
|
runId: queued.runId,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HerdrAutomation } from "./herdr.ts";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { type RunRecord } from "./records.ts";
|
|
3
|
+
import { adoptRecord, withRunStateLock } from "./run-state.ts";
|
|
4
4
|
|
|
5
5
|
function isMissingTabError(error: unknown): boolean {
|
|
6
6
|
const detail = error instanceof Error ? `${error.message}\n${(error as { stderr?: unknown }).stderr ?? ""}` : String(error);
|
|
@@ -18,17 +18,16 @@ export async function cleanupRunTab(options: {
|
|
|
18
18
|
errorPrefix?: string;
|
|
19
19
|
}): Promise<{ deferred: boolean }> {
|
|
20
20
|
const mergeLatest = async (update: (run: RunRecord) => void | Promise<void>, requirePending = false, clearPending = true) => {
|
|
21
|
-
const latest = await
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Object.assign(options.runRecord, latest);
|
|
21
|
+
const latest = await withRunStateLock(options.runFile, options.signal, async (run) => {
|
|
22
|
+
if (requirePending && !run.cleanupPendingTabIds?.includes(options.tabId)) return run;
|
|
23
|
+
if (clearPending) {
|
|
24
|
+
run.cleanupPendingTabIds = run.cleanupPendingTabIds?.filter((tabId) => tabId !== options.tabId);
|
|
25
|
+
if (!run.cleanupPendingTabIds?.length) delete run.cleanupPendingTabIds;
|
|
26
|
+
}
|
|
27
|
+
await update(run);
|
|
28
|
+
return run;
|
|
29
|
+
});
|
|
30
|
+
adoptRecord(options.runRecord, latest);
|
|
32
31
|
};
|
|
33
32
|
const reportFailure = async (error: unknown, requirePending = false) => {
|
|
34
33
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -51,9 +50,10 @@ export async function cleanupRunTab(options: {
|
|
|
51
50
|
return { deferred: false };
|
|
52
51
|
}
|
|
53
52
|
if (await options.herdr.isTabFocused(options.tabId, options.signal)) {
|
|
54
|
-
const latest = await
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
const latest = await withRunStateLock(options.runFile, options.signal, (run) => {
|
|
54
|
+
run.cleanupPendingTabIds = [...new Set([...(run.cleanupPendingTabIds ?? []), options.tabId])];
|
|
55
|
+
return run;
|
|
56
|
+
});
|
|
57
57
|
Object.assign(options.runRecord, latest);
|
|
58
58
|
void options.herdr.waitForTabUnfocused(options.tabId, options.signal)
|
|
59
59
|
.then(() => options.herdr.closeTab(options.tabId, options.signal))
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
|
|
2
2
|
import { loadSubagentConfiguration, type ConfigurationPaths, type SubagentConfiguration } from "./config.ts";
|
|
3
|
-
import { runHerdrSubagents, type HerdrSubagentsResult } from "./runner.ts";
|
|
4
3
|
import { runHerdrSubagentsBatch, type BatchResult, type BatchTask } from "./batch-runner.ts";
|
|
5
4
|
import { respondToBlockedTask } from "./blocking.ts";
|
|
6
5
|
import { cleanSubagentRun, historyText, listSubagentHistory, resumeHistoricalTask } from "./history.ts";
|
|
@@ -31,9 +30,7 @@ export interface HerdrSubagentsControlToolParams {
|
|
|
31
30
|
export interface HerdrSubagentsToolParams {
|
|
32
31
|
action?: "list" | "status" | "respond" | "history" | "resume" | "cancel" | "cleanup";
|
|
33
32
|
prompt?: string;
|
|
34
|
-
agent?: string;
|
|
35
33
|
label?: string;
|
|
36
|
-
model?: string;
|
|
37
34
|
tasks?: HerdrSubagentsTaskParams[];
|
|
38
35
|
concurrency?: number;
|
|
39
36
|
background?: boolean;
|
|
@@ -57,13 +54,6 @@ export interface HerdrSubagentsToolDependencies {
|
|
|
57
54
|
cancelRun?: (runId: string) => Promise<{ cancelled: boolean; queued?: boolean; cleanupErrors?: string[] }>;
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
export type HerdrSubagentsDetails = HerdrSubagentsResult & {
|
|
61
|
-
agent: string;
|
|
62
|
-
description: string;
|
|
63
|
-
prompt: string;
|
|
64
|
-
personaPath: string;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
57
|
export type HerdrSubagentsBatchDetails = BatchResult & { prompts: string[] };
|
|
68
58
|
|
|
69
59
|
type ToolResponse = {
|
|
@@ -91,14 +81,6 @@ export function listText(discovery: SubagentConfiguration): string {
|
|
|
91
81
|
return lines.join("\n");
|
|
92
82
|
}
|
|
93
83
|
|
|
94
|
-
function summaryText(result: HerdrSubagentsResult): string {
|
|
95
|
-
if (result.ok) return result.output;
|
|
96
|
-
const lines = [result.summary];
|
|
97
|
-
if (result.errorMessage) lines.push(`Error: ${result.errorMessage}`);
|
|
98
|
-
if (result.recordDirectory) lines.push(`Task record: ${result.recordDirectory}`);
|
|
99
|
-
return lines.join("\n");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
84
|
interface ToolExecutionContext {
|
|
103
85
|
params: HerdrSubagentsToolParams;
|
|
104
86
|
signal?: AbortSignal;
|
|
@@ -131,17 +113,20 @@ const actionHandlers = {
|
|
|
131
113
|
},
|
|
132
114
|
async cleanup({ params, ctx }: ToolExecutionContext) {
|
|
133
115
|
try {
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
116
|
+
const requestedRunId = params.runId as string;
|
|
117
|
+
const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
|
|
118
|
+
const cleaned = await cleanSubagentRun(ctx.cwd, runId);
|
|
119
|
+
const text = cleaned ? `Removed Subagent run ${runId}.` : `Unknown Subagent run '${requestedRunId}'.`;
|
|
120
|
+
return { content: [{ type: "text", text }], details: { action: "cleanup", runId, cleaned }, ...(cleaned ? {} : { isError: true }) };
|
|
137
121
|
} catch (error) {
|
|
138
122
|
const text = error instanceof Error ? error.message : String(error);
|
|
139
123
|
return errorResponse(text, { action: "cleanup", runId: params.runId, cleaned: false });
|
|
140
124
|
}
|
|
141
125
|
},
|
|
142
126
|
async cancel({ params, ctx, dependencies }: ToolExecutionContext) {
|
|
143
|
-
const
|
|
127
|
+
const requestedRunId = (params.runId as string).trim();
|
|
144
128
|
try {
|
|
129
|
+
const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
|
|
145
130
|
let result: { cancelled: boolean; queued?: boolean; cleanupErrors?: string[] };
|
|
146
131
|
if (dependencies.cancelRun) result = await dependencies.cancelRun(runId);
|
|
147
132
|
else {
|
|
@@ -159,7 +144,7 @@ const actionHandlers = {
|
|
|
159
144
|
const text = result.cancelled ? `Cancelled Subagent run ${runId}.${suffix}` : `Subagent run '${runId}' is not active or queued.`;
|
|
160
145
|
return { content: [{ type: "text", text }], details: { action: "cancel", runId, ...result }, ...(result.cancelled ? {} : { isError: true }) };
|
|
161
146
|
} catch (error) {
|
|
162
|
-
return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId, cancelled: false });
|
|
147
|
+
return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId: requestedRunId, cancelled: false });
|
|
163
148
|
}
|
|
164
149
|
},
|
|
165
150
|
async resume({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext) {
|
|
@@ -176,14 +161,15 @@ const actionHandlers = {
|
|
|
176
161
|
async respond({ params, signal, onUpdate, ctx, dependencies }: ToolExecutionContext) {
|
|
177
162
|
try {
|
|
178
163
|
const result = await respondToBlockedTask(ctx.cwd, (params.runId as string).trim(), (params.answer as string).trim(), {
|
|
179
|
-
herdr: dependencies.herdr, taskNumber: params.task, signal, owner: dependencies.owner,
|
|
164
|
+
herdr: dependencies.herdr, taskNumber: params.task as number, signal, owner: dependencies.owner,
|
|
180
165
|
onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
|
|
181
166
|
onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, stalled: true } }),
|
|
182
167
|
onWarning: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, warning: text } }),
|
|
183
168
|
onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, cleanupError: text } }),
|
|
184
169
|
});
|
|
185
|
-
const
|
|
186
|
-
|
|
170
|
+
const blockedControl = result.status === "blocked" ? { allowedActions: ["respond", "cancel"] as const } : {};
|
|
171
|
+
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");
|
|
172
|
+
return { content: [{ type: "text", text }], details: { ...result, ...blockedControl }, ...(result.status === "failed" || result.status === "partial" || result.status === "cancelled" ? { isError: true } : {}) };
|
|
187
173
|
} catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); }
|
|
188
174
|
},
|
|
189
175
|
async list({ discovery }: ToolExecutionContext) {
|
|
@@ -215,9 +201,10 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
|
|
|
215
201
|
onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: { ...partial, prompts: tasks.map((task) => task.prompt) } }),
|
|
216
202
|
});
|
|
217
203
|
const lines = [result.summary, ...result.tasks.map((task) => `- ${task.name}: ${task.status} — ${task.summary}`)];
|
|
204
|
+
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
205
|
if (result.documents.length) lines.push("", "Documents:", ...result.documents.map((document) => `- ${document.description}: ${document.path}`));
|
|
219
206
|
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 } : {}) };
|
|
207
|
+
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
208
|
} catch (error) {
|
|
222
209
|
const text = error instanceof Error ? error.message : String(error);
|
|
223
210
|
const diagnostic = error as { recordDirectory?: string; runId?: string };
|
|
@@ -225,37 +212,6 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
|
|
|
225
212
|
}
|
|
226
213
|
}
|
|
227
214
|
|
|
228
|
-
async function executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext): Promise<ToolResponse> {
|
|
229
|
-
const prompt = params.prompt as string;
|
|
230
|
-
const selectedName = params.agent?.trim() || "worker";
|
|
231
|
-
const persona = discovery.personas.find((agent) => agent.name === selectedName);
|
|
232
|
-
if (!persona) return errorResponse(`Unknown persona '${selectedName}'.\n\n${listText(discovery)}`);
|
|
233
|
-
const explicitModel = params.model?.trim();
|
|
234
|
-
const selectedModel = explicitModel || persona.model || modelId(ctx);
|
|
235
|
-
const selectedThinking = persona.thinking ?? (!explicitModel && persona.modelSource === "parent session" ? ctx.thinkingLevel : undefined);
|
|
236
|
-
const details = (result: HerdrSubagentsResult): HerdrSubagentsDetails => ({ ...result, agent: persona.name, description: persona.description, prompt, personaPath: persona.filePath });
|
|
237
|
-
const result = await runHerdrSubagents(prompt, {
|
|
238
|
-
agent: persona.name,
|
|
239
|
-
label: params.label,
|
|
240
|
-
...(selectedModel ? { model: selectedModel } : {}),
|
|
241
|
-
...(selectedThinking ? { thinking: selectedThinking } : {}),
|
|
242
|
-
...(persona.tools ? { tools: persona.tools } : {}),
|
|
243
|
-
skills: persona.skills,
|
|
244
|
-
access: persona.access,
|
|
245
|
-
owner: dependencies.owner,
|
|
246
|
-
systemPrompt: persona.systemPrompt,
|
|
247
|
-
cwd: ctx.cwd,
|
|
248
|
-
signal,
|
|
249
|
-
herdr: dependencies.herdr,
|
|
250
|
-
stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
|
|
251
|
-
onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "running", stalled: true } }),
|
|
252
|
-
onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "warning", cleanupError: text } }),
|
|
253
|
-
onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: details(partial) }),
|
|
254
|
-
});
|
|
255
|
-
const isError = result.status === "failed" || result.status === "cancelled";
|
|
256
|
-
return { content: [{ type: "text", text: summaryText(result) }], details: details(result), ...(isError ? { isError: true } : {}) };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
215
|
export async function executeHerdrSubagents(
|
|
260
216
|
params: HerdrSubagentsRunParams | HerdrSubagentsToolParams,
|
|
261
217
|
signal: AbortSignal | undefined,
|
|
@@ -264,10 +220,6 @@ export async function executeHerdrSubagents(
|
|
|
264
220
|
dependencies: HerdrSubagentsToolDependencies,
|
|
265
221
|
): Promise<ToolResponse> {
|
|
266
222
|
if (params.action) return executeHerdrSubagentsControl(params as HerdrSubagentsControlToolParams, signal, onUpdate, ctx, dependencies);
|
|
267
|
-
if (params.prompt && !params.tasks) {
|
|
268
|
-
const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
|
|
269
|
-
return executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery });
|
|
270
|
-
}
|
|
271
223
|
const runParams = params as HerdrSubagentsRunParams;
|
|
272
224
|
const validationError = validateRunParams(runParams);
|
|
273
225
|
if (validationError) return { content: [{ type: "text", text: validationError }], details: { errorMessage: validationError }, isError: true };
|
|
@@ -21,7 +21,7 @@ export function validateControlParams(params: HerdrSubagentsControlToolParams):
|
|
|
21
21
|
const extra = Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsControlToolParams));
|
|
22
22
|
if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
|
|
23
23
|
if ((params.action === "status" || params.action === "cancel" || params.action === "cleanup") && !params.runId?.trim()) return `runId is required for action '${params.action}'.`;
|
|
24
|
-
if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and
|
|
24
|
+
if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim() || !params.task)) return "runId, answer, and task are required for action 'respond'.";
|
|
25
25
|
if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
|
|
26
26
|
return undefined;
|
|
27
27
|
}
|
|
@@ -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), task: 1, 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 } };
|
|
@@ -272,10 +347,10 @@ async function verifyPublicUiWiring() {
|
|
|
272
347
|
const responding = harness(async () => ({ content: [{ type: "text", text: "needs input" }], details: blockedDetails }));
|
|
273
348
|
await startHarness(responding, cwd);
|
|
274
349
|
if (!responding.tool) throw new Error("herdr_subagents was not registered for UI wiring test");
|
|
275
|
-
const renderedBlocked = responding.tool.renderResult({ content: [{ type: "text", text: "needs input" }], details: {
|
|
276
|
-
if (!renderedBlocked.includes("blocked") || renderedBlocked.includes("failed")) throw new Error("
|
|
350
|
+
const renderedBlocked = responding.tool.renderResult({ content: [{ type: "text", text: "needs input" }], details: { label: "reviews", status: "blocked", effectiveConcurrency: 1, tasks: [{ index: 1, name: "second", status: "blocked", summary: "Need input." }] } }, { isPartial: false }, { fg: (_color: string, text: string) => text, bold: (text: string) => text }).render(100).join("\n");
|
|
351
|
+
if (!renderedBlocked.includes("blocked") || renderedBlocked.includes("failed")) throw new Error("blocked result did not render its authoritative status");
|
|
277
352
|
if (!responding.controlTool) throw new Error("herdr_subagents_control was not registered for UI wiring test");
|
|
278
|
-
await responding.controlTool.execute("call", { action: "respond", runId: "blocked-run", answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
|
|
353
|
+
await responding.controlTool.execute("call", { action: "respond", runId: "blocked-run", task: 2, answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
|
|
279
354
|
if (!responding.renderedLines.some((line) => line.includes("second") && line.includes("blocked"))) throw new Error("blocked response did not refresh Fleet rows");
|
|
280
355
|
if (!responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\r")?.consume) throw new Error("Fleet terminal input was not consumed while selecting");
|
|
281
356
|
await new Promise((resolve) => setTimeout(resolve, 0));
|