@maxiaochao/pi-toolkit 0.9.6 → 0.10.0
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 +6 -0
- package/README.md +1 -1
- package/package.json +5 -5
- package/packages/herdr-subagents/CHANGELOG.md +41 -0
- package/packages/herdr-subagents/README.md +155 -0
- package/packages/herdr-subagents/TESTING.md +24 -0
- package/packages/herdr-subagents/agents/explorer.md +8 -0
- package/packages/herdr-subagents/agents/reviewer.md +8 -0
- package/packages/{tiny-subagent → herdr-subagents}/agents/worker.md +1 -0
- package/packages/herdr-subagents/docs/demo.gif +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +258 -0
- package/packages/herdr-subagents/extensions/subagent-report.ts +65 -0
- package/packages/{tiny-subagent → herdr-subagents}/package.json +7 -6
- package/packages/herdr-subagents/src/batch-runner.ts +219 -0
- package/packages/herdr-subagents/src/blocking.ts +175 -0
- package/packages/herdr-subagents/src/config.ts +125 -0
- package/packages/herdr-subagents/src/continue-run.ts +87 -0
- package/packages/herdr-subagents/src/dispatcher.ts +73 -0
- package/packages/herdr-subagents/src/errors.ts +3 -0
- package/packages/herdr-subagents/src/fleet-controller.ts +82 -0
- package/packages/herdr-subagents/src/fleet.ts +56 -0
- package/packages/herdr-subagents/src/herdr.ts +224 -0
- package/packages/herdr-subagents/src/history.ts +210 -0
- package/packages/herdr-subagents/src/live-agent.ts +87 -0
- package/packages/herdr-subagents/src/monitor.ts +18 -0
- package/packages/herdr-subagents/src/ownership.ts +17 -0
- package/packages/herdr-subagents/src/parameters.ts +21 -0
- package/packages/{tiny-subagent → herdr-subagents}/src/personas.ts +13 -2
- package/packages/herdr-subagents/src/protocol.ts +23 -0
- package/packages/herdr-subagents/src/queued-runs.ts +70 -0
- package/packages/herdr-subagents/src/reconcile.ts +167 -0
- package/packages/herdr-subagents/src/records.ts +213 -0
- package/packages/herdr-subagents/src/result-path.ts +7 -0
- package/packages/herdr-subagents/src/run-locator.ts +19 -0
- package/packages/herdr-subagents/src/run-pane-allocator.ts +78 -0
- package/packages/herdr-subagents/src/run-status.ts +23 -0
- package/packages/herdr-subagents/src/runner.ts +250 -0
- package/packages/herdr-subagents/src/state.ts +11 -0
- package/packages/herdr-subagents/src/tab-cleanup.ts +68 -0
- package/packages/herdr-subagents/src/tool.ts +217 -0
- package/packages/herdr-subagents/src/validation.ts +38 -0
- package/packages/herdr-subagents/tests/extension-contract.ts +225 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +889 -0
- package/packages/todo/extensions/todo.ts +1 -1
- package/packages/tiny-subagent/CHANGELOG.md +0 -12
- package/packages/tiny-subagent/README.md +0 -128
- package/packages/tiny-subagent/TESTING.md +0 -20
- package/packages/tiny-subagent/docs/demo.gif +0 -0
- package/packages/tiny-subagent/docs/screenshot.png +0 -0
- package/packages/tiny-subagent/extensions/tiny-subagent.ts +0 -228
- package/packages/tiny-subagent/src/runner.ts +0 -319
- package/packages/tiny-subagent/tests/run-tests.mjs +0 -111
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { HerdrAutomation } from "./herdr.ts";
|
|
4
|
+
import { isProcessAlive, type OwnerIdentity } from "./ownership.ts";
|
|
5
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
6
|
+
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
7
|
+
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, type RunRecord, type TaskStatus } from "./records.ts";
|
|
8
|
+
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export interface CancellationResult { cancelledRuns: number; cleanupErrors: string[]; }
|
|
12
|
+
export interface ReconciliationResult { liveTasks: number; settledTasks: number; interruptedTasks: number; cancelledTasks: number; cleanupErrors: string[]; }
|
|
13
|
+
|
|
14
|
+
async function closeRunTab(run: RunRecord, runFile: string, herdr: HerdrAutomation, cleanupErrors: string[]): Promise<void> {
|
|
15
|
+
if (!run.tabId) return;
|
|
16
|
+
const forceClose = new AbortController();
|
|
17
|
+
forceClose.abort(new Error("Owning parent Pi session is no longer active."));
|
|
18
|
+
const errorsBeforeCleanup = cleanupErrors.length;
|
|
19
|
+
try {
|
|
20
|
+
await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: forceClose.signal, onError: (message) => cleanupErrors.push(message), errorPrefix: `Could not close Subagent tab ${run.tabId}` });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (cleanupErrors.length === errorsBeforeCleanup) cleanupErrors.push(`Could not close Subagent tab ${run.tabId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CancelActiveRunsOptions { reason?: string; owner?: OwnerIdentity; }
|
|
27
|
+
|
|
28
|
+
export async function cancelActiveSubagentRuns(cwd: string, herdr: HerdrAutomation, options: CancelActiveRunsOptions = {}): Promise<CancellationResult> {
|
|
29
|
+
const reason = options.reason ?? "Parent Pi session closed.";
|
|
30
|
+
const result: CancellationResult = { cancelledRuns: 0, cleanupErrors: [] };
|
|
31
|
+
const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
|
|
32
|
+
if (!existsSync(runsDirectory)) return result;
|
|
33
|
+
for (const runEntry of readdirSync(runsDirectory)) {
|
|
34
|
+
const runDirectory = join(runsDirectory, runEntry);
|
|
35
|
+
const runFile = join(runDirectory, "run.json");
|
|
36
|
+
let run: RunRecord;
|
|
37
|
+
try { run = await readRunRecord(runFile); }
|
|
38
|
+
catch (error) {
|
|
39
|
+
result.cleanupErrors.push(`Could not inspect Subagent run ${runFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!isActiveStatus(run.status)) continue;
|
|
43
|
+
if (options.owner?.sessionId && run.ownerSessionId !== options.owner.sessionId) continue;
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
const tasksDirectory = join(runDirectory, "tasks");
|
|
46
|
+
if (existsSync(tasksDirectory)) for (const taskEntry of readdirSync(tasksDirectory)) {
|
|
47
|
+
const taskFile = join(tasksDirectory, taskEntry, "task.json");
|
|
48
|
+
let task;
|
|
49
|
+
try { task = await readTaskRecord(taskFile); }
|
|
50
|
+
catch (error) {
|
|
51
|
+
result.cleanupErrors.push(`Could not cancel Subagent task ${taskFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!isActiveStatus(task.status)) continue;
|
|
55
|
+
applyTaskStatus(task, "cancelled", { error: reason, at: now });
|
|
56
|
+
await writeJsonAtomic(taskFile, task);
|
|
57
|
+
}
|
|
58
|
+
applyRunStatus(run, "cancelled", { error: reason, at: now });
|
|
59
|
+
await writeJsonAtomic(runFile, run);
|
|
60
|
+
await closeRunTab(run, runFile, herdr, result.cleanupErrors);
|
|
61
|
+
result.cancelledRuns += 1;
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function reconcileSubagentRuns(cwd: string, herdr: HerdrAutomation, owner?: OwnerIdentity): Promise<ReconciliationResult> {
|
|
67
|
+
const result: ReconciliationResult = { liveTasks: 0, settledTasks: 0, interruptedTasks: 0, cancelledTasks: 0, cleanupErrors: [] };
|
|
68
|
+
const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
|
|
69
|
+
if (!existsSync(runsDirectory)) return result;
|
|
70
|
+
for (const runEntry of readdirSync(runsDirectory)) {
|
|
71
|
+
const runDirectory = join(runsDirectory, runEntry);
|
|
72
|
+
const runFile = join(runDirectory, "run.json");
|
|
73
|
+
let run: RunRecord;
|
|
74
|
+
try { run = await readRunRecord(runFile); }
|
|
75
|
+
catch (error) {
|
|
76
|
+
result.cleanupErrors.push(`Could not inspect Subagent run ${runFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const differentOwner = owner?.sessionId !== undefined && run.ownerSessionId !== owner.sessionId;
|
|
80
|
+
if (differentOwner && run.ownerProcessId !== (owner?.processId ?? process.pid) && isProcessAlive(run.ownerProcessId)) continue;
|
|
81
|
+
for (const pendingTabId of [...(run.cleanupPendingTabIds ?? [])]) {
|
|
82
|
+
const errorsBeforeCleanup = result.cleanupErrors.length;
|
|
83
|
+
try {
|
|
84
|
+
await cleanupRunTab({ herdr, tabId: pendingTabId, runFile, runRecord: run, onError: (message) => result.cleanupErrors.push(message), errorPrefix: `Could not finish deferred cleanup for Herdr tab ${pendingTabId}` });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (result.cleanupErrors.length === errorsBeforeCleanup) result.cleanupErrors.push(`Could not finish deferred cleanup for Herdr tab ${pendingTabId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!isActiveStatus(run.status)) continue;
|
|
90
|
+
const tasksDirectory = join(runDirectory, "tasks");
|
|
91
|
+
if (!existsSync(tasksDirectory)) {
|
|
92
|
+
const message = `Active Subagent run ${run.id} has no tasks directory.`;
|
|
93
|
+
result.cleanupErrors.push(message);
|
|
94
|
+
applyRunStatus(run, "failed", { error: message });
|
|
95
|
+
await writeJsonAtomic(runFile, run);
|
|
96
|
+
await closeRunTab(run, runFile, herdr, result.cleanupErrors);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const statuses: TaskStatus[] = [];
|
|
100
|
+
let unreadableTasks = 0;
|
|
101
|
+
for (const taskEntry of readdirSync(tasksDirectory)) {
|
|
102
|
+
const taskFile = join(tasksDirectory, taskEntry, "task.json");
|
|
103
|
+
let task;
|
|
104
|
+
try { task = await readTaskRecord(taskFile); }
|
|
105
|
+
catch (error) {
|
|
106
|
+
result.cleanupErrors.push(`Could not inspect Subagent task ${taskFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
unreadableTasks += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (isActiveStatus(task.status)) {
|
|
111
|
+
const reportFile = join(tasksDirectory, taskEntry, "report.json");
|
|
112
|
+
if (existsSync(reportFile) && (task.status === "running" || task.status === "starting")) {
|
|
113
|
+
try {
|
|
114
|
+
const report = await readReport(reportFile);
|
|
115
|
+
applyReport(task, report, report.reportedAt ?? new Date().toISOString());
|
|
116
|
+
result.settledTasks += 1;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
119
|
+
result.cleanupErrors.push(`Invalid Subagent report ${reportFile}: ${detail}`);
|
|
120
|
+
await persistFailureReport(reportFile, { status: "failed", summary: "Recovered task had an invalid structured report.", documents: [], error: detail, reportedAt: new Date().toISOString() });
|
|
121
|
+
if (!differentOwner) {
|
|
122
|
+
applyTaskStatus(task, "failed", { error: `Invalid structured report: ${detail}` });
|
|
123
|
+
result.settledTasks += 1;
|
|
124
|
+
result.interruptedTasks += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (differentOwner && isActiveStatus(task.status)) {
|
|
129
|
+
applyTaskStatus(task, "cancelled", { error: "Owning parent Pi session is no longer active." });
|
|
130
|
+
result.cancelledTasks += 1;
|
|
131
|
+
result.interruptedTasks += 1;
|
|
132
|
+
} else if (!differentOwner && (task.status === "running" || task.status === "starting")) {
|
|
133
|
+
let childIsLive = false;
|
|
134
|
+
if (task.paneId) {
|
|
135
|
+
try { childIsLive = await herdr.isTaskRunning(task.paneId); }
|
|
136
|
+
catch (error) {
|
|
137
|
+
result.cleanupErrors.push(`Could not inspect Subagent pane ${task.paneId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
138
|
+
childIsLive = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (childIsLive) result.liveTasks += 1;
|
|
142
|
+
else {
|
|
143
|
+
applyTaskStatus(task, "cancelled", { error: "Child pane disappeared before a structured report was recorded." });
|
|
144
|
+
result.cancelledTasks += 1;
|
|
145
|
+
result.interruptedTasks += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
await writeJsonAtomic(taskFile, task);
|
|
149
|
+
}
|
|
150
|
+
statuses.push(task.status);
|
|
151
|
+
}
|
|
152
|
+
if (unreadableTasks > 0) {
|
|
153
|
+
const message = `Could not reconcile ${unreadableTasks} unreadable task record${unreadableTasks === 1 ? "" : "s"}.`;
|
|
154
|
+
applyRunStatus(run, "failed", { error: message });
|
|
155
|
+
await writeJsonAtomic(runFile, run);
|
|
156
|
+
await closeRunTab(run, runFile, herdr, result.cleanupErrors);
|
|
157
|
+
result.interruptedTasks += unreadableTasks;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (differentOwner || !statuses.some((status) => status === "running" || status === "starting")) {
|
|
161
|
+
applyRunStatus(run, differentOwner ? "cancelled" : aggregateTaskStatus(statuses), { error: differentOwner ? "Owning parent Pi session is no longer active." : undefined });
|
|
162
|
+
await writeJsonAtomic(runFile, run);
|
|
163
|
+
if (differentOwner || run.status === "cancelled") await closeRunTab(run, runFile, herdr, result.cleanupErrors);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, rename } from "node:fs/promises";
|
|
3
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
4
|
+
|
|
5
|
+
export type TaskStatus = "starting" | "queued" | "running" | "blocked" | "completed" | "failed" | "cancelled";
|
|
6
|
+
export type RunStatus = TaskStatus | "partial";
|
|
7
|
+
export const ACTIVE_STATUSES = new Set<RunStatus>(["starting", "queued", "running", "blocked"]);
|
|
8
|
+
const SETTLED_RUN_STATUSES = new Set<RunStatus>(["completed", "partial", "failed", "cancelled"]);
|
|
9
|
+
|
|
10
|
+
export function isActiveStatus(status: RunStatus): boolean { return ACTIVE_STATUSES.has(status); }
|
|
11
|
+
export function isSettledRunStatus(status: unknown): status is Exclude<RunStatus, "starting" | "queued" | "running" | "blocked"> { return SETTLED_RUN_STATUSES.has(status as RunStatus); }
|
|
12
|
+
|
|
13
|
+
export interface PersistedDocument {
|
|
14
|
+
path: string;
|
|
15
|
+
description: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PersistedReport {
|
|
19
|
+
status: "completed" | "needs-input" | "failed";
|
|
20
|
+
summary: string;
|
|
21
|
+
documents: PersistedDocument[];
|
|
22
|
+
error?: string;
|
|
23
|
+
question?: string;
|
|
24
|
+
reportedAt?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RunRecord {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
status: RunStatus;
|
|
31
|
+
cwd?: string;
|
|
32
|
+
ownerSessionId?: string;
|
|
33
|
+
ownerProcessId?: number;
|
|
34
|
+
requestedConcurrency?: number;
|
|
35
|
+
effectiveConcurrency?: number;
|
|
36
|
+
stalledWarningSeconds?: number;
|
|
37
|
+
taskIds?: string[];
|
|
38
|
+
paneIds?: string[];
|
|
39
|
+
startedAt?: string;
|
|
40
|
+
updatedAt?: string;
|
|
41
|
+
completedAt?: string;
|
|
42
|
+
tabId?: string;
|
|
43
|
+
paneId?: string;
|
|
44
|
+
staleTabIds?: string[];
|
|
45
|
+
question?: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
cleanupError?: string;
|
|
48
|
+
cleanupPendingTabIds?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TaskRecord {
|
|
52
|
+
id: string;
|
|
53
|
+
runId: string;
|
|
54
|
+
order: number;
|
|
55
|
+
name?: string;
|
|
56
|
+
status: TaskStatus;
|
|
57
|
+
prompt?: string;
|
|
58
|
+
agent?: string;
|
|
59
|
+
access?: "read" | "write";
|
|
60
|
+
model?: string;
|
|
61
|
+
thinking?: string;
|
|
62
|
+
tools?: string[];
|
|
63
|
+
skills?: string[];
|
|
64
|
+
systemPrompt?: string;
|
|
65
|
+
sessionFile?: string;
|
|
66
|
+
startedAt?: string;
|
|
67
|
+
updatedAt?: string;
|
|
68
|
+
completedAt?: string;
|
|
69
|
+
tabId?: string;
|
|
70
|
+
paneId?: string;
|
|
71
|
+
paneLabel?: string;
|
|
72
|
+
agentName?: string;
|
|
73
|
+
attempt?: number;
|
|
74
|
+
currentTurnFile?: string;
|
|
75
|
+
question?: string;
|
|
76
|
+
error?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface TurnRecord {
|
|
80
|
+
attempt: number;
|
|
81
|
+
prompt?: string;
|
|
82
|
+
status: TaskStatus;
|
|
83
|
+
summary?: string;
|
|
84
|
+
documents?: PersistedDocument[];
|
|
85
|
+
sessionFile?: string;
|
|
86
|
+
startedAt?: string;
|
|
87
|
+
completedAt?: string;
|
|
88
|
+
tabId?: string;
|
|
89
|
+
paneId?: string;
|
|
90
|
+
agentName?: string;
|
|
91
|
+
question?: string;
|
|
92
|
+
error?: string;
|
|
93
|
+
events?: Array<Record<string, unknown>>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readObject(path: string): Promise<Record<string, unknown>> {
|
|
97
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
98
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Expected a JSON object in ${path}.`);
|
|
99
|
+
return parsed as Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const TASK_STATUSES = new Set<TaskStatus>(["starting", "queued", "running", "blocked", "completed", "failed", "cancelled"]);
|
|
103
|
+
const RUN_STATUSES = new Set<RunStatus>([...TASK_STATUSES, "partial"]);
|
|
104
|
+
function requireString(record: Record<string, unknown>, field: string, path: string): void {
|
|
105
|
+
if (typeof record[field] !== "string" || !(record[field] as string).trim()) throw new Error(`Missing ${field} in ${path}.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function readRunRecord(path: string): Promise<RunRecord> {
|
|
109
|
+
const record = await readObject(path);
|
|
110
|
+
requireString(record, "id", path);
|
|
111
|
+
requireString(record, "label", path);
|
|
112
|
+
if (!RUN_STATUSES.has(record.status as RunStatus)) throw new Error(`Invalid run status in ${path}.`);
|
|
113
|
+
return record as unknown as RunRecord;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function readTaskRecord(path: string): Promise<TaskRecord> {
|
|
117
|
+
const record = await readObject(path);
|
|
118
|
+
requireString(record, "id", path);
|
|
119
|
+
requireString(record, "runId", path);
|
|
120
|
+
if (!Number.isInteger(record.order) || Number(record.order) < 1) throw new Error(`Invalid task order in ${path}.`);
|
|
121
|
+
if (!TASK_STATUSES.has(record.status as TaskStatus)) throw new Error(`Invalid task status in ${path}.`);
|
|
122
|
+
return record as unknown as TaskRecord;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function readTurnRecord(path: string): Promise<TurnRecord> {
|
|
126
|
+
const record = await readObject(path);
|
|
127
|
+
if (!Number.isInteger(record.attempt) || Number(record.attempt) < 1) throw new Error(`Invalid turn attempt in ${path}.`);
|
|
128
|
+
if (!TASK_STATUSES.has(record.status as TaskStatus)) throw new Error(`Invalid turn status in ${path}.`);
|
|
129
|
+
return record as unknown as TurnRecord;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function parseReport(value: unknown, source = "structured report"): PersistedReport {
|
|
133
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Expected a JSON object in ${source}.`);
|
|
134
|
+
const report = value as Record<string, unknown>;
|
|
135
|
+
if (report.status !== "completed" && report.status !== "needs-input" && report.status !== "failed") throw new Error(`Invalid report status in ${source}.`);
|
|
136
|
+
if (typeof report.summary !== "string" || !report.summary.trim()) throw new Error(`Missing report summary in ${source}.`);
|
|
137
|
+
if (!Array.isArray(report.documents) || !report.documents.every((document) => {
|
|
138
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) return false;
|
|
139
|
+
const entry = document as Record<string, unknown>;
|
|
140
|
+
return typeof entry.path === "string" && typeof entry.description === "string";
|
|
141
|
+
})) throw new Error(`Invalid report documents in ${source}.`);
|
|
142
|
+
if (report.status === "needs-input" && (typeof report.question !== "string" || !report.question.trim())) throw new Error(`Missing report question in ${source}.`);
|
|
143
|
+
if (report.error !== undefined && typeof report.error !== "string") throw new Error(`Invalid report error in ${source}.`);
|
|
144
|
+
if (report.question !== undefined && typeof report.question !== "string") throw new Error(`Invalid report question in ${source}.`);
|
|
145
|
+
return report as unknown as PersistedReport;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function readReport(path: string): Promise<PersistedReport> {
|
|
149
|
+
return parseReport(await readObject(path), path);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function persistFailureReport(path: string, report: PersistedReport, replaceExisting = false): Promise<string | undefined> {
|
|
153
|
+
if (replaceExisting) {
|
|
154
|
+
await writeJsonAtomic(path, parseReport(report, "generated failure report"));
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
let archivedPath: string | undefined;
|
|
158
|
+
if (existsSync(path)) {
|
|
159
|
+
try { await readReport(path); return undefined; }
|
|
160
|
+
catch {
|
|
161
|
+
archivedPath = `${path}.invalid-${Date.now()}`;
|
|
162
|
+
await rename(path, archivedPath);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
await writeJsonAtomic(path, parseReport(report, "generated failure report"));
|
|
166
|
+
return archivedPath;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function applyLifecycleStatus<T extends { status: string; question?: string; error?: string; updatedAt?: string; completedAt?: string }>(
|
|
170
|
+
record: T,
|
|
171
|
+
status: RunStatus,
|
|
172
|
+
options: { question?: string; error?: string; at?: string } = {},
|
|
173
|
+
): T {
|
|
174
|
+
const at = options.at ?? new Date().toISOString();
|
|
175
|
+
delete record.question;
|
|
176
|
+
delete record.error;
|
|
177
|
+
delete record.updatedAt;
|
|
178
|
+
delete record.completedAt;
|
|
179
|
+
Object.assign(record, { status });
|
|
180
|
+
if (status === "blocked") Object.assign(record, { updatedAt: at, ...(options.question ? { question: options.question } : {}) });
|
|
181
|
+
else if (isActiveStatus(status)) Object.assign(record, { updatedAt: at });
|
|
182
|
+
else Object.assign(record, { completedAt: at, ...(options.error ? { error: options.error } : {}) });
|
|
183
|
+
return record;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function applyTaskStatus<T extends TaskRecord | TurnRecord>(record: T, status: TaskStatus, options: { question?: string; error?: string; at?: string } = {}): T {
|
|
187
|
+
return applyLifecycleStatus(record, status, options);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
|
|
191
|
+
record: T,
|
|
192
|
+
reportPath: string,
|
|
193
|
+
options: { cancelled: boolean; error: string; name: string; summary?: string; at?: string; replaceReport?: boolean },
|
|
194
|
+
): Promise<{ status: "cancelled" | "failed"; report: PersistedReport }> {
|
|
195
|
+
const status = options.cancelled ? "cancelled" : "failed";
|
|
196
|
+
const at = options.at ?? new Date().toISOString();
|
|
197
|
+
applyTaskStatus(record, status, { error: options.error, at });
|
|
198
|
+
const report: PersistedReport = {
|
|
199
|
+
status: "failed",
|
|
200
|
+
summary: options.summary ?? `${options.name} ${options.cancelled ? "was cancelled" : "failed"}.`,
|
|
201
|
+
documents: [],
|
|
202
|
+
error: options.error,
|
|
203
|
+
reportedAt: at,
|
|
204
|
+
};
|
|
205
|
+
await persistFailureReport(reportPath, report, options.replaceReport);
|
|
206
|
+
return { status, report };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function applyReport(record: TaskRecord | TurnRecord, report: PersistedReport, completedAt = new Date().toISOString()): "blocked" | "completed" | "failed" {
|
|
210
|
+
const status = report.status === "needs-input" ? "blocked" : report.status;
|
|
211
|
+
applyTaskStatus(record, status, { at: completedAt, question: report.question, error: report.error });
|
|
212
|
+
return status;
|
|
213
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function resultPathForAttempt(taskDirectory: string, attempt: number): string {
|
|
4
|
+
return attempt > 1
|
|
5
|
+
? resolve(taskDirectory, "turns", `${String(attempt).padStart(2, "0")}-result.md`)
|
|
6
|
+
: resolve(taskDirectory, "result.md");
|
|
7
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readRunRecord } from "./records.ts";
|
|
4
|
+
|
|
5
|
+
export async function findRunDirectory(runsDirectory: string, runId: string): Promise<string | undefined> {
|
|
6
|
+
if (!existsSync(runsDirectory)) return undefined;
|
|
7
|
+
const errors: string[] = [];
|
|
8
|
+
for (const entry of readdirSync(runsDirectory)) {
|
|
9
|
+
const directory = join(runsDirectory, entry);
|
|
10
|
+
const runFile = join(directory, "run.json");
|
|
11
|
+
try {
|
|
12
|
+
if ((await readRunRecord(runFile)).id === runId) return directory;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
errors.push(`${runFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (errors.length) throw new Error(`Could not inspect Subagent runs while looking for '${runId}': ${errors.join("; ")}`);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { HerdrAutomation } from "./herdr.ts";
|
|
2
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
3
|
+
import type { RunRecord } from "./records.ts";
|
|
4
|
+
import { applyRunStatus } from "./run-status.ts";
|
|
5
|
+
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
6
|
+
|
|
7
|
+
interface RunPaneAllocatorOptions {
|
|
8
|
+
herdr: HerdrAutomation;
|
|
9
|
+
run: RunRecord;
|
|
10
|
+
runFile: string;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
validateExistingPane?: boolean;
|
|
13
|
+
onCleanupError?: (message: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class RunPaneAllocator {
|
|
17
|
+
private allocation = Promise.resolve();
|
|
18
|
+
private readonly paneIds: string[];
|
|
19
|
+
private lastPaneId?: string;
|
|
20
|
+
private readonly options: RunPaneAllocatorOptions;
|
|
21
|
+
|
|
22
|
+
constructor(options: RunPaneAllocatorOptions) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.paneIds = Array.isArray(options.run.paneIds) ? [...options.run.paneIds] : [];
|
|
25
|
+
this.lastPaneId = this.paneIds.at(-1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get tabId(): string | undefined { return this.options.run.tabId; }
|
|
29
|
+
|
|
30
|
+
async allocate(taskDirectory: string): Promise<string> {
|
|
31
|
+
let paneId = "";
|
|
32
|
+
this.allocation = this.allocation.then(async () => {
|
|
33
|
+
const { herdr, run, runFile, signal } = this.options;
|
|
34
|
+
const env = { PI_HERDR_SUBAGENTS_CHILD: "1", PI_HERDR_SUBAGENTS_TASK_DIR: taskDirectory };
|
|
35
|
+
let anchorExists = Boolean(run.tabId && this.lastPaneId);
|
|
36
|
+
if (anchorExists && this.options.validateExistingPane) {
|
|
37
|
+
const survivingPaneIds: string[] = [];
|
|
38
|
+
for (const existingPaneId of this.paneIds) {
|
|
39
|
+
if (await herdr.paneExists(existingPaneId, signal)) survivingPaneIds.push(existingPaneId);
|
|
40
|
+
}
|
|
41
|
+
this.paneIds.splice(0, this.paneIds.length, ...survivingPaneIds);
|
|
42
|
+
this.lastPaneId = this.paneIds.at(-1);
|
|
43
|
+
anchorExists = Boolean(this.lastPaneId);
|
|
44
|
+
}
|
|
45
|
+
if (!anchorExists) {
|
|
46
|
+
const staleTabId = run.tabId;
|
|
47
|
+
const tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd: run.cwd, label: `SA · ${run.label}`, env, focus: false, signal });
|
|
48
|
+
run.tabId = tab.tabId;
|
|
49
|
+
paneId = tab.paneId;
|
|
50
|
+
this.paneIds.splice(0);
|
|
51
|
+
if (staleTabId && staleTabId !== tab.tabId) {
|
|
52
|
+
run.staleTabIds = [...new Set([...(run.staleTabIds ?? []), staleTabId])];
|
|
53
|
+
await writeJsonAtomic(runFile, run);
|
|
54
|
+
await cleanupRunTab({
|
|
55
|
+
herdr, tabId: staleTabId, runFile, runRecord: run, signal, onError: this.options.onCleanupError,
|
|
56
|
+
errorPrefix: `Could not close stale Herdr tab ${staleTabId}`,
|
|
57
|
+
onClosed: (latest) => {
|
|
58
|
+
latest.staleTabIds = latest.staleTabIds?.filter((id) => id !== staleTabId);
|
|
59
|
+
if (!latest.staleTabIds?.length) delete latest.staleTabIds;
|
|
60
|
+
},
|
|
61
|
+
}).catch((error) => {
|
|
62
|
+
if (!this.options.onCleanupError) throw error;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
const split = await herdr.splitPane({ paneId: this.lastPaneId as string, cwd: run.cwd, direction: this.paneIds.length % 2 ? "right" : "down", focus: false, env, signal });
|
|
67
|
+
paneId = split.paneId;
|
|
68
|
+
}
|
|
69
|
+
this.lastPaneId = paneId;
|
|
70
|
+
this.paneIds.push(paneId);
|
|
71
|
+
applyRunStatus(run, "running");
|
|
72
|
+
run.paneIds = [...this.paneIds];
|
|
73
|
+
await writeJsonAtomic(runFile, run);
|
|
74
|
+
});
|
|
75
|
+
await this.allocation;
|
|
76
|
+
return paneId;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { applyLifecycleStatus, type RunStatus, type TaskStatus } from "./records.ts";
|
|
2
|
+
|
|
3
|
+
export type AggregateRunStatus = Exclude<RunStatus, "starting" | "queued">;
|
|
4
|
+
export type PersistedRunStatus = RunStatus;
|
|
5
|
+
|
|
6
|
+
export function aggregateTaskStatus(statuses: readonly TaskStatus[]): AggregateRunStatus {
|
|
7
|
+
if (statuses.length === 0) return "failed";
|
|
8
|
+
if (statuses.some((status) => status === "running" || status === "starting")) return "running";
|
|
9
|
+
if (statuses.some((status) => status === "blocked" || status === "queued")) return "blocked";
|
|
10
|
+
const completed = statuses.some((status) => status === "completed");
|
|
11
|
+
if (statuses.some((status) => status === "cancelled")) return completed ? "partial" : "cancelled";
|
|
12
|
+
if (statuses.some((status) => status === "failed")) return completed ? "partial" : "failed";
|
|
13
|
+
if (statuses.every((status) => status === "completed")) return "completed";
|
|
14
|
+
throw new Error(`Cannot aggregate unknown task status: ${statuses.join(", ")}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function applyRunStatus<T extends { status: string; question?: string; error?: string; updatedAt?: string; completedAt?: string }>(
|
|
18
|
+
run: T,
|
|
19
|
+
status: PersistedRunStatus,
|
|
20
|
+
options: { question?: string; error?: string; at?: string } = {},
|
|
21
|
+
): T {
|
|
22
|
+
return applyLifecycleStatus(run, status, options);
|
|
23
|
+
}
|