@maxiaochao/pi-toolkit 0.10.21 → 0.10.23
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 +10 -0
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +22 -0
- package/packages/herdr-subagents/README.md +16 -8
- package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
- package/packages/herdr-subagents/TESTING.md +13 -3
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +4 -17
- 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 +56 -15
- package/packages/herdr-subagents/src/blocking.ts +115 -72
- package/packages/herdr-subagents/src/continue-run.ts +22 -7
- 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 +5 -131
- 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 -135
- package/packages/herdr-subagents/src/records.ts +50 -32
- package/packages/herdr-subagents/src/resume-state.ts +46 -0
- 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 +0 -6
- package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
- package/packages/herdr-subagents/src/tool.ts +1 -54
- package/packages/herdr-subagents/src/validation.ts +1 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +4 -4
- package/packages/herdr-subagents/tests/run-tests.mjs +579 -60
- package/packages/herdr-subagents/src/runner.ts +0 -234
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HerdrAutomation } from "./herdr.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { withRunStateLock } from "./run-state.ts";
|
|
3
3
|
import type { RunRecord } from "./records.ts";
|
|
4
4
|
import { applyRunStatus } from "./run-status.ts";
|
|
5
5
|
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
@@ -33,45 +33,47 @@ export class RunPaneAllocator {
|
|
|
33
33
|
this.allocation = this.allocation.then(async () => {
|
|
34
34
|
const { herdr, run, runFile, signal } = this.options;
|
|
35
35
|
const env = childEnvironment(taskDirectory);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
const allocated = await withRunStateLock(runFile, signal, async (latest) => {
|
|
37
|
+
const currentPaneIds = [...(latest.paneIds ?? [])];
|
|
38
|
+
let lastPaneId = currentPaneIds.at(-1);
|
|
39
|
+
let anchorExists = Boolean(latest.tabId && lastPaneId);
|
|
40
|
+
if (anchorExists && this.options.validateExistingPane) {
|
|
41
|
+
const surviving: string[] = [];
|
|
42
|
+
for (const existingPaneId of currentPaneIds) if (await herdr.paneExists(existingPaneId, signal)) surviving.push(existingPaneId);
|
|
43
|
+
currentPaneIds.splice(0, currentPaneIds.length, ...surviving);
|
|
44
|
+
lastPaneId = currentPaneIds.at(-1);
|
|
45
|
+
anchorExists = Boolean(lastPaneId);
|
|
41
46
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (staleTabId && staleTabId !== tab.tabId) {
|
|
53
|
-
run.staleTabIds = [...new Set([...(run.staleTabIds ?? []), staleTabId])];
|
|
54
|
-
await writeJsonAtomic(runFile, run);
|
|
55
|
-
await cleanupRunTab({
|
|
56
|
-
herdr, tabId: staleTabId, runFile, runRecord: run, signal, onError: this.options.onCleanupError,
|
|
57
|
-
errorPrefix: `Could not close stale Herdr tab ${staleTabId}`,
|
|
58
|
-
onClosed: (latest) => {
|
|
59
|
-
latest.staleTabIds = latest.staleTabIds?.filter((id) => id !== staleTabId);
|
|
60
|
-
if (!latest.staleTabIds?.length) delete latest.staleTabIds;
|
|
61
|
-
},
|
|
62
|
-
}).catch((error) => {
|
|
63
|
-
if (!this.options.onCleanupError) throw error;
|
|
64
|
-
});
|
|
47
|
+
let staleTabId: string | undefined;
|
|
48
|
+
if (!anchorExists) {
|
|
49
|
+
staleTabId = latest.tabId;
|
|
50
|
+
const tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd: latest.cwd, label: `SA · ${latest.label}`, env, focus: false, signal });
|
|
51
|
+
latest.tabId = tab.tabId;
|
|
52
|
+
paneId = tab.paneId;
|
|
53
|
+
currentPaneIds.splice(0);
|
|
54
|
+
if (staleTabId && staleTabId !== tab.tabId) latest.staleTabIds = [...new Set([...(latest.staleTabIds ?? []), staleTabId])];
|
|
55
|
+
} else {
|
|
56
|
+
paneId = (await herdr.splitPane({ paneId: lastPaneId as string, cwd: latest.cwd, direction: currentPaneIds.length % 2 ? "right" : "down", focus: false, env, signal })).paneId;
|
|
65
57
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
currentPaneIds.push(paneId);
|
|
59
|
+
applyRunStatus(latest, "running");
|
|
60
|
+
latest.paneIds = currentPaneIds;
|
|
61
|
+
return { latest, staleTabId: staleTabId && staleTabId !== latest.tabId ? staleTabId : undefined };
|
|
62
|
+
});
|
|
63
|
+
this.paneIds.splice(0, this.paneIds.length, ...(allocated.latest.paneIds ?? []));
|
|
64
|
+
this.lastPaneId = this.paneIds.at(-1);
|
|
65
|
+
Object.assign(run, allocated.latest);
|
|
66
|
+
if (allocated.staleTabId) {
|
|
67
|
+
const staleTabId = allocated.staleTabId;
|
|
68
|
+
await cleanupRunTab({
|
|
69
|
+
herdr, tabId: staleTabId, runFile, runRecord: run, signal, onError: this.options.onCleanupError,
|
|
70
|
+
errorPrefix: `Could not close stale Herdr tab ${staleTabId}`,
|
|
71
|
+
onClosed: (latest) => {
|
|
72
|
+
latest.staleTabIds = latest.staleTabIds?.filter((id) => id !== staleTabId);
|
|
73
|
+
if (!latest.staleTabIds?.length) delete latest.staleTabIds;
|
|
74
|
+
},
|
|
75
|
+
}).catch((error) => { if (!this.options.onCleanupError) throw error; });
|
|
69
76
|
}
|
|
70
|
-
this.lastPaneId = paneId;
|
|
71
|
-
this.paneIds.push(paneId);
|
|
72
|
-
applyRunStatus(run, "running");
|
|
73
|
-
run.paneIds = [...this.paneIds];
|
|
74
|
-
await writeJsonAtomic(runFile, run);
|
|
75
77
|
});
|
|
76
78
|
await this.allocation;
|
|
77
79
|
return paneId;
|
|
@@ -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
|
+
}
|
|
@@ -12,8 +12,6 @@ export interface RunStatusTask {
|
|
|
12
12
|
summary?: string;
|
|
13
13
|
question?: string;
|
|
14
14
|
error?: string;
|
|
15
|
-
failureKind?: "missing_input";
|
|
16
|
-
requiredInput?: string;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
export interface RunStatusSnapshot {
|
|
@@ -46,8 +44,6 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
|
|
|
46
44
|
const report = reportIsCurrent && existsSync(reportFile) ? await readReport(reportFile) : undefined;
|
|
47
45
|
const question = report?.question ?? task.question;
|
|
48
46
|
const error = report?.error ?? task.error;
|
|
49
|
-
const failureKind = report?.failureKind ?? task.failureKind;
|
|
50
|
-
const requiredInput = report?.requiredInput ?? task.requiredInput;
|
|
51
47
|
tasks.push({
|
|
52
48
|
order: task.order,
|
|
53
49
|
name: task.name ?? task.agent ?? `Task ${task.order}`,
|
|
@@ -56,8 +52,6 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
|
|
|
56
52
|
...(report?.summary ? { summary: report.summary } : {}),
|
|
57
53
|
...(question ? { question } : {}),
|
|
58
54
|
...(error ? { error } : {}),
|
|
59
|
-
...(failureKind ? { failureKind } : {}),
|
|
60
|
-
...(requiredInput ? { requiredInput } : {}),
|
|
61
55
|
});
|
|
62
56
|
}
|
|
63
57
|
return {
|
|
@@ -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;
|
|
@@ -179,7 +161,7 @@ const actionHandlers = {
|
|
|
179
161
|
async respond({ params, signal, onUpdate, ctx, dependencies }: ToolExecutionContext) {
|
|
180
162
|
try {
|
|
181
163
|
const result = await respondToBlockedTask(ctx.cwd, (params.runId as string).trim(), (params.answer as string).trim(), {
|
|
182
|
-
herdr: dependencies.herdr, taskNumber: params.task, signal, owner: dependencies.owner,
|
|
164
|
+
herdr: dependencies.herdr, taskNumber: params.task as number, signal, owner: dependencies.owner,
|
|
183
165
|
onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
|
|
184
166
|
onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, stalled: true } }),
|
|
185
167
|
onWarning: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, warning: text } }),
|
|
@@ -230,37 +212,6 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
|
|
|
230
212
|
}
|
|
231
213
|
}
|
|
232
214
|
|
|
233
|
-
async function executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext): Promise<ToolResponse> {
|
|
234
|
-
const prompt = params.prompt as string;
|
|
235
|
-
const selectedName = params.agent?.trim() || "worker";
|
|
236
|
-
const persona = discovery.personas.find((agent) => agent.name === selectedName);
|
|
237
|
-
if (!persona) return errorResponse(`Unknown persona '${selectedName}'.\n\n${listText(discovery)}`);
|
|
238
|
-
const explicitModel = params.model?.trim();
|
|
239
|
-
const selectedModel = explicitModel || persona.model || modelId(ctx);
|
|
240
|
-
const selectedThinking = persona.thinking ?? (!explicitModel && persona.modelSource === "parent session" ? ctx.thinkingLevel : undefined);
|
|
241
|
-
const details = (result: HerdrSubagentsResult): HerdrSubagentsDetails => ({ ...result, agent: persona.name, description: persona.description, prompt, personaPath: persona.filePath });
|
|
242
|
-
const result = await runHerdrSubagents(prompt, {
|
|
243
|
-
agent: persona.name,
|
|
244
|
-
label: params.label,
|
|
245
|
-
...(selectedModel ? { model: selectedModel } : {}),
|
|
246
|
-
...(selectedThinking ? { thinking: selectedThinking } : {}),
|
|
247
|
-
...(persona.tools ? { tools: persona.tools } : {}),
|
|
248
|
-
skills: persona.skills,
|
|
249
|
-
access: persona.access,
|
|
250
|
-
owner: dependencies.owner,
|
|
251
|
-
systemPrompt: persona.systemPrompt,
|
|
252
|
-
cwd: ctx.cwd,
|
|
253
|
-
signal,
|
|
254
|
-
herdr: dependencies.herdr,
|
|
255
|
-
stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
|
|
256
|
-
onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "running", stalled: true } }),
|
|
257
|
-
onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "warning", cleanupError: text } }),
|
|
258
|
-
onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: details(partial) }),
|
|
259
|
-
});
|
|
260
|
-
const isError = result.status === "failed" || result.status === "cancelled";
|
|
261
|
-
return { content: [{ type: "text", text: summaryText(result) }], details: details(result), ...(isError ? { isError: true } : {}) };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
215
|
export async function executeHerdrSubagents(
|
|
265
216
|
params: HerdrSubagentsRunParams | HerdrSubagentsToolParams,
|
|
266
217
|
signal: AbortSignal | undefined,
|
|
@@ -269,10 +220,6 @@ export async function executeHerdrSubagents(
|
|
|
269
220
|
dependencies: HerdrSubagentsToolDependencies,
|
|
270
221
|
): Promise<ToolResponse> {
|
|
271
222
|
if (params.action) return executeHerdrSubagentsControl(params as HerdrSubagentsControlToolParams, signal, onUpdate, ctx, dependencies);
|
|
272
|
-
if (params.prompt && !params.tasks) {
|
|
273
|
-
const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
|
|
274
|
-
return executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery });
|
|
275
|
-
}
|
|
276
223
|
const runParams = params as HerdrSubagentsRunParams;
|
|
277
224
|
const validationError = validateRunParams(runParams);
|
|
278
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
|
}
|
|
@@ -311,7 +311,7 @@ async function verifyForegroundFailureHandoff() {
|
|
|
311
311
|
}
|
|
312
312
|
const queuedBehindBlocked = await blockedOwner.tool?.execute("blocked-background", { background: true, tasks: [{ name: "background", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
|
|
313
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 });
|
|
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
315
|
await waitUntil(() => blockedSuccessorStarted, "responding through a run ID prefix did not release the blocked dispatcher owner");
|
|
316
316
|
await blockedOwner.handlers.get("session_shutdown")?.({}, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
|
|
317
317
|
|
|
@@ -347,10 +347,10 @@ async function verifyPublicUiWiring() {
|
|
|
347
347
|
const responding = harness(async () => ({ content: [{ type: "text", text: "needs input" }], details: blockedDetails }));
|
|
348
348
|
await startHarness(responding, cwd);
|
|
349
349
|
if (!responding.tool) throw new Error("herdr_subagents was not registered for UI wiring test");
|
|
350
|
-
const renderedBlocked = responding.tool.renderResult({ content: [{ type: "text", text: "needs input" }], details: {
|
|
351
|
-
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");
|
|
352
352
|
if (!responding.controlTool) throw new Error("herdr_subagents_control was not registered for UI wiring test");
|
|
353
|
-
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 });
|
|
354
354
|
if (!responding.renderedLines.some((line) => line.includes("second") && line.includes("blocked"))) throw new Error("blocked response did not refresh Fleet rows");
|
|
355
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");
|
|
356
356
|
await new Promise((resolve) => setTimeout(resolve, 0));
|