pi-agent-fleet 0.5.0 → 0.7.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/package.json +1 -1
- package/src/canvas-client.tsx +18 -51
- package/src/canvas-layout.ts +83 -0
- package/src/canvas.ts +8 -4
- package/src/command.ts +7 -35
- package/src/contracts.ts +9 -5
- package/src/controller.ts +117 -17
- package/src/dag.ts +5 -1
- package/src/edits.ts +24 -5
- package/src/fleet-recovery.ts +12 -2
- package/src/prompts.ts +11 -0
- package/src/runner.ts +63 -2
- package/src/scheduler.ts +173 -14
- package/src/state.ts +19 -17
- package/src/tools.ts +13 -29
- package/src/types.ts +9 -0
package/src/edits.ts
CHANGED
|
@@ -8,14 +8,14 @@ import type { NodeStatus, ThinkingLevelName } from "./types.js";
|
|
|
8
8
|
import { THINKING_LEVELS } from "./types.js";
|
|
9
9
|
|
|
10
10
|
export type NodeEditKey = "model" | "effort" | "task";
|
|
11
|
-
export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "model" | "effort";
|
|
11
|
+
export type ConfigEditKey = "max_concurrent" | "warn_cost_usd" | "max_cost_usd" | "worker_extensions" | "model" | "effort";
|
|
12
12
|
|
|
13
13
|
export interface EditResult {
|
|
14
14
|
ok: boolean;
|
|
15
15
|
message: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed"]);
|
|
18
|
+
const EDITABLE_NODE_STATUSES: ReadonlySet<NodeStatus> = new Set(["pending", "ready", "failed", "contract_failed", "killed", "blocked"]);
|
|
19
19
|
|
|
20
20
|
export async function editNode(
|
|
21
21
|
fleet: ActiveFleet,
|
|
@@ -28,8 +28,8 @@ export async function editNode(
|
|
|
28
28
|
const node = fleet.state.nodes[nodeId];
|
|
29
29
|
if (!worker || !node) return { ok: false, message: `unknown node "${nodeId}"` };
|
|
30
30
|
if (!EDITABLE_NODE_STATUSES.has(node.status)) {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
return { ok: false, message: `node "${nodeId}" is ${node.status}; only pending, blocked, failed, contract_failed, or killed nodes can be edited` };
|
|
32
|
+
}
|
|
33
33
|
switch (key) {
|
|
34
34
|
case "model": {
|
|
35
35
|
const r = resolveModelReference(registry, value);
|
|
@@ -85,6 +85,25 @@ export async function editConfig(
|
|
|
85
85
|
fleet.spec.config.warn_cost_usd = n;
|
|
86
86
|
break;
|
|
87
87
|
}
|
|
88
|
+
case "max_cost_usd": {
|
|
89
|
+
const n = Number(value);
|
|
90
|
+
if (!Number.isFinite(n) || n < 0) return { ok: false, message: "max_cost_usd must be a number >= 0" };
|
|
91
|
+
fleet.spec.config.max_cost_usd = n;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "worker_extensions": {
|
|
95
|
+
// accept JSON array or comma-separated string
|
|
96
|
+
let list: string[];
|
|
97
|
+
try {
|
|
98
|
+
const parsed: unknown = JSON.parse(value);
|
|
99
|
+
list = Array.isArray(parsed) ? parsed.map(String) : [value];
|
|
100
|
+
} catch {
|
|
101
|
+
list = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
if (list.some((s) => s.length === 0)) return { ok: false, message: "worker_extensions must be non-empty strings" };
|
|
104
|
+
fleet.spec.config.worker_extensions = list;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
88
107
|
case "model": {
|
|
89
108
|
const r = resolveModelReference(registry, value);
|
|
90
109
|
if (!r.ok) return { ok: false, message: r.error };
|
|
@@ -99,7 +118,7 @@ export async function editConfig(
|
|
|
99
118
|
break;
|
|
100
119
|
}
|
|
101
120
|
default:
|
|
102
|
-
return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, model, effort)` };
|
|
121
|
+
return { ok: false, message: `unknown config key "${String(key)}" (keys: max_concurrent, warn_cost_usd, max_cost_usd, worker_extensions, model, effort)` };
|
|
103
122
|
}
|
|
104
123
|
await persistFleetJson(fleet);
|
|
105
124
|
return { ok: true, message: `config.${key} updated` };
|
package/src/fleet-recovery.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ActiveFleet } from "./controller.js";
|
|
4
|
-
import { readState } from "./state.js";
|
|
4
|
+
import { initFleetState, readState } from "./state.js";
|
|
5
5
|
import type { FleetSpec, FleetState } from "./types.js";
|
|
6
6
|
|
|
7
7
|
export interface FleetRootInfo {
|
|
@@ -13,7 +13,16 @@ export interface FleetRootInfo {
|
|
|
13
13
|
|
|
14
14
|
export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
|
|
15
15
|
const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
|
|
16
|
-
|
|
16
|
+
// Bare fleet.json-only root (e.g. hand-copied spec, never planned/launched):
|
|
17
|
+
// synthesize a fresh pending state so the canvas can still render the DAG.
|
|
18
|
+
let state: FleetState;
|
|
19
|
+
try {
|
|
20
|
+
state = await readState(fleetRoot);
|
|
21
|
+
} catch (e: unknown) {
|
|
22
|
+
const err = e as { code?: string };
|
|
23
|
+
if (err.code !== "ENOENT") throw e;
|
|
24
|
+
state = initFleetState(spec);
|
|
25
|
+
}
|
|
17
26
|
return {
|
|
18
27
|
spec,
|
|
19
28
|
fleetRoot,
|
|
@@ -23,6 +32,7 @@ export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
|
|
|
23
32
|
running: false,
|
|
24
33
|
sessions: new Map(),
|
|
25
34
|
killedNodes: new Set(),
|
|
35
|
+
relaunchRequests: new Set(),
|
|
26
36
|
};
|
|
27
37
|
}
|
|
28
38
|
|
package/src/prompts.ts
CHANGED
|
@@ -17,6 +17,17 @@ export function buildWorkerPrompt(opts: {
|
|
|
17
17
|
const dependents = getDependents(spec, workerId);
|
|
18
18
|
const out: string[] = [];
|
|
19
19
|
|
|
20
|
+
out.push(
|
|
21
|
+
"## Autonomy contract (read first)",
|
|
22
|
+
"",
|
|
23
|
+
"You are an unattended fleet worker — there is no human watching this session and nobody will answer questions.",
|
|
24
|
+
"- Everything in this prompt is PRE-APPROVED. Do not ask for approval; do the work.",
|
|
25
|
+
"- Do NOT invoke any skill or workflow with a human approval gate (e.g. brainstorming hard-gates). Skip gated steps and execute the task directly.",
|
|
26
|
+
"- Do not end your turn with a question, a plan awaiting approval, or an 'Approve?' prompt. End your turn only when every REQUIRED output below exists on disk.",
|
|
27
|
+
"- Ambiguity is yours to resolve: decide, record the decision in your output, and continue.",
|
|
28
|
+
"",
|
|
29
|
+
);
|
|
30
|
+
|
|
20
31
|
const fleetTs = basename(fleetRoot);
|
|
21
32
|
|
|
22
33
|
out.push(`# Fleet worker: ${workerId}`, "", `Type: ${worker.type}`, "", `## Task`, "", worker.task, "");
|
package/src/runner.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createAgentSession, SessionManager, type CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { createAgentSession, SessionManager, DefaultResourceLoader, type CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ThinkingLevelName, WorkerSpec } from "./types.js";
|
|
4
4
|
import { WORKER_TYPE_TOOLS } from "./types.js";
|
|
@@ -25,16 +25,52 @@ export interface SessionOpts {
|
|
|
25
25
|
tools: string[];
|
|
26
26
|
model?: string;
|
|
27
27
|
thinkingLevel?: ThinkingLevelName;
|
|
28
|
+
extensionAllowlist?: string[];
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export type SessionFactory = (opts: SessionOpts) => Promise<AgentSessionLike>;
|
|
31
32
|
|
|
33
|
+
// Fleet workers must not load user extensions/skills: reading global skill files
|
|
34
|
+
// burns tokens mid-task, and extensions like pi-web-access run background async work
|
|
35
|
+
// that can crash the pi process after the session is disposed.
|
|
36
|
+
// Exception: some model providers are registered BY extensions (e.g. opencode-pi
|
|
37
|
+
// registers the opencode-cli provider). worker_extensions allowlists exactly those.
|
|
38
|
+
export function filterExtensionsByAllowlist<T extends { path: string }>(
|
|
39
|
+
extensions: T[],
|
|
40
|
+
allowlist: string[] | undefined,
|
|
41
|
+
): T[] {
|
|
42
|
+
if (!allowlist || allowlist.length === 0) return extensions;
|
|
43
|
+
return extensions.filter((e) => allowlist.some((a) => e.path.includes(a)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function createLeanResourceLoader(cwd: string, extensionAllowlist?: string[]): Promise<DefaultResourceLoader> {
|
|
47
|
+
const allow = extensionAllowlist && extensionAllowlist.length > 0;
|
|
48
|
+
const loader = new DefaultResourceLoader({
|
|
49
|
+
cwd,
|
|
50
|
+
agentDir: cwd,
|
|
51
|
+
noExtensions: !allow,
|
|
52
|
+
extensionsOverride: allow
|
|
53
|
+
? (base) => ({ ...base, extensions: filterExtensionsByAllowlist(base.extensions, extensionAllowlist) })
|
|
54
|
+
: undefined,
|
|
55
|
+
noSkills: true,
|
|
56
|
+
noPromptTemplates: true,
|
|
57
|
+
noThemes: true,
|
|
58
|
+
noContextFiles: true,
|
|
59
|
+
systemPromptOverride: () => undefined,
|
|
60
|
+
appendSystemPromptOverride: () => [],
|
|
61
|
+
});
|
|
62
|
+
await loader.reload();
|
|
63
|
+
return loader;
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
export const defaultSessionFactory: SessionFactory = async (opts) => {
|
|
67
|
+
const resourceLoader = await createLeanResourceLoader(opts.cwd, opts.extensionAllowlist);
|
|
33
68
|
const { session } = await createAgentSession({
|
|
34
69
|
cwd: opts.cwd,
|
|
35
70
|
tools: opts.tools,
|
|
36
71
|
sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
|
|
37
72
|
thinkingLevel: opts.thinkingLevel as ThinkingLevelOption,
|
|
73
|
+
resourceLoader,
|
|
38
74
|
});
|
|
39
75
|
return session as unknown as AgentSessionLike;
|
|
40
76
|
};
|
|
@@ -49,6 +85,7 @@ export interface RunWorkerOpts {
|
|
|
49
85
|
onSession?: (session: AgentSessionLike) => void;
|
|
50
86
|
sessionFactory?: SessionFactory;
|
|
51
87
|
thinkingLevel?: ThinkingLevelName;
|
|
88
|
+
extensionAllowlist?: string[];
|
|
52
89
|
}
|
|
53
90
|
|
|
54
91
|
export interface RunWorkerResult {
|
|
@@ -69,6 +106,7 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
69
106
|
tools: WORKER_TYPE_TOOLS[opts.worker.type],
|
|
70
107
|
model: opts.worker.model,
|
|
71
108
|
thinkingLevel: opts.thinkingLevel,
|
|
109
|
+
extensionAllowlist: opts.extensionAllowlist,
|
|
72
110
|
});
|
|
73
111
|
} catch (err) {
|
|
74
112
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -79,13 +117,29 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
79
117
|
let turns = 0;
|
|
80
118
|
let tokens = 0;
|
|
81
119
|
let cost = 0;
|
|
120
|
+
// pi resolves session.prompt() normally even when the model run ends in an error
|
|
121
|
+
// (e.g. provider usage limit) — the failure is only visible on the final assistant
|
|
122
|
+
// message's stopReason/errorMessage. Track the last assistant message so a resolved
|
|
123
|
+
// prompt that actually errored is reported as ok:false instead of running the output
|
|
124
|
+
// contract against files that were never written.
|
|
125
|
+
let lastStopReason: string | undefined;
|
|
126
|
+
let lastErrorMessage: string | undefined;
|
|
82
127
|
const unsub = session.subscribe((e) => {
|
|
83
128
|
if (e.type === "turn_end") {
|
|
84
129
|
turns++;
|
|
85
130
|
opts.onEvent({ type: "turn", nodeId: opts.nodeId, turns });
|
|
86
131
|
}
|
|
87
132
|
if (e.type === "message_end") {
|
|
88
|
-
const msg = e.message as {
|
|
133
|
+
const msg = e.message as {
|
|
134
|
+
role?: string;
|
|
135
|
+
stopReason?: string;
|
|
136
|
+
errorMessage?: string;
|
|
137
|
+
usage?: { totalTokens?: number; cost?: { total?: number } };
|
|
138
|
+
} | undefined;
|
|
139
|
+
if (msg?.role === "assistant") {
|
|
140
|
+
lastStopReason = msg.stopReason;
|
|
141
|
+
lastErrorMessage = msg.errorMessage;
|
|
142
|
+
}
|
|
89
143
|
if (msg?.role === "assistant" && msg.usage?.totalTokens) {
|
|
90
144
|
tokens += msg.usage.totalTokens;
|
|
91
145
|
opts.onEvent({ type: "tokens", nodeId: opts.nodeId, tokens });
|
|
@@ -98,6 +152,11 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
98
152
|
});
|
|
99
153
|
try {
|
|
100
154
|
await session.prompt(opts.prompt);
|
|
155
|
+
if (lastStopReason === "error") {
|
|
156
|
+
const message = lastErrorMessage ?? "worker model run ended with an error";
|
|
157
|
+
opts.onEvent({ type: "error", nodeId: opts.nodeId, message });
|
|
158
|
+
return { ok: false, turns, tokens, cost, error: message };
|
|
159
|
+
}
|
|
101
160
|
opts.onEvent({ type: "done", nodeId: opts.nodeId });
|
|
102
161
|
return { ok: true, turns, tokens, cost };
|
|
103
162
|
} catch (err) {
|
|
@@ -112,12 +171,14 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
112
171
|
|
|
113
172
|
export function sessionFactoryForModel(model: Model<Api>): SessionFactory {
|
|
114
173
|
return async (opts) => {
|
|
174
|
+
const resourceLoader = await createLeanResourceLoader(opts.cwd, opts.extensionAllowlist);
|
|
115
175
|
const { session } = await createAgentSession({
|
|
116
176
|
cwd: opts.cwd,
|
|
117
177
|
tools: opts.tools,
|
|
118
178
|
sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
|
|
119
179
|
model,
|
|
120
180
|
thinkingLevel: opts.thinkingLevel as ThinkingLevelOption,
|
|
181
|
+
resourceLoader,
|
|
121
182
|
});
|
|
122
183
|
return session as unknown as AgentSessionLike;
|
|
123
184
|
};
|
package/src/scheduler.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { contractFailureNote, verifyOutputs } from "./contracts.js";
|
|
4
|
-
import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
|
|
4
|
+
import { archiveIteration, initFleetState, patchNode, relaunchResetIds, resetForIteration, snapshotIteration, writeState } from "./state.js";
|
|
5
5
|
import { TERMINAL_NODE_STATUSES } from "./types.js";
|
|
6
6
|
import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict, WorkerSpec } from "./types.js";
|
|
7
7
|
import { commitWorktree, createWorktree, prepareIntegratorWorktree } from "./worktree.js";
|
|
@@ -20,10 +20,20 @@ export interface RunFleetOpts {
|
|
|
20
20
|
killSwitch?: { killed: boolean };
|
|
21
21
|
pauseSwitch?: { paused: boolean };
|
|
22
22
|
nodeKills?: ReadonlySet<string>;
|
|
23
|
+
relaunchRequests?: Set<string>;
|
|
23
24
|
resumeFrom?: FleetState;
|
|
24
25
|
continuePass?: boolean;
|
|
25
26
|
onIterationEnd?: (snap: IterationSnapshot) => void;
|
|
26
27
|
prepareIteration?: (n: number, state: FleetState) => Promise<void>;
|
|
28
|
+
retryDelayMs?: (attempt: number) => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAX_RETRY_ATTEMPTS = 3;
|
|
32
|
+
const DEFAULT_RETRY_DELAY_MS = (attempt: number): number => 1000 * 2 ** attempt;
|
|
33
|
+
const RETRYABLE_ERROR_RE = /usage limit|rate.?limit|429|overloaded|502|503|timed? ?out|timeout|econnreset|etimedout|econnrefused|fetch failed/i;
|
|
34
|
+
|
|
35
|
+
export function isRetryableError(message: string): boolean {
|
|
36
|
+
return RETRYABLE_ERROR_RE.test(message);
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
const FAILED: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed", "blocked"]);
|
|
@@ -51,6 +61,19 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
51
61
|
let state: FleetState;
|
|
52
62
|
if (opts.resumeFrom) {
|
|
53
63
|
state = { ...opts.resumeFrom, paused: false, status: "running" };
|
|
64
|
+
// Crash recovery: nodes left "running" on disk by a dead process have no live
|
|
65
|
+
// session in this runFleet call — reset them to pending so they get dispatched.
|
|
66
|
+
// Without this, the scheduler can neither dispatch nor terminate them and spins
|
|
67
|
+
// forever (nothing runnable, not all nodes terminal).
|
|
68
|
+
for (const w of spec.workers) {
|
|
69
|
+
if (state.nodes[w.id]?.status === "running") {
|
|
70
|
+
state = patchNode(fleetRoot, state, w.id, {
|
|
71
|
+
status: "pending",
|
|
72
|
+
started_at: undefined,
|
|
73
|
+
status_note: "recovered stale running state from a previous crashed/killed process",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
54
77
|
await writeState(fleetRoot, state);
|
|
55
78
|
if (allNodesTerminal(state, spec) && !opts.continuePass) {
|
|
56
79
|
state = resetForIteration(state, spec);
|
|
@@ -70,6 +93,15 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
70
93
|
};
|
|
71
94
|
|
|
72
95
|
const running = new Set<Promise<void>>();
|
|
96
|
+
const circuitErrors = new Map<string, Set<string>>();
|
|
97
|
+
let circuitOpen = false;
|
|
98
|
+
function recordCircuitError(nodeId: string, error: string | undefined) {
|
|
99
|
+
if (!error) return;
|
|
100
|
+
const set = circuitErrors.get(error) ?? new Set<string>();
|
|
101
|
+
set.add(nodeId);
|
|
102
|
+
circuitErrors.set(error, set);
|
|
103
|
+
if (set.size >= 2) circuitOpen = true;
|
|
104
|
+
}
|
|
73
105
|
|
|
74
106
|
const repoCwdFor = (nodeId: string): string =>
|
|
75
107
|
typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
|
|
@@ -111,6 +143,31 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
111
143
|
|
|
112
144
|
const runPass = async (): Promise<void> => {
|
|
113
145
|
while (true) {
|
|
146
|
+
// apply queued relaunch requests (lost-wakeup fix, issue #1 bug 2)
|
|
147
|
+
if (opts.relaunchRequests && opts.relaunchRequests.size > 0) {
|
|
148
|
+
for (const id of [...opts.relaunchRequests]) {
|
|
149
|
+
opts.relaunchRequests.delete(id);
|
|
150
|
+
const n = state.nodes[id];
|
|
151
|
+
if (!n || !FAILED.has(n.status)) continue;
|
|
152
|
+
for (const rid of relaunchResetIds(spec, state, id)) {
|
|
153
|
+
const rn = state.nodes[rid];
|
|
154
|
+
if (!rn) continue;
|
|
155
|
+
if (rid === id && !FAILED.has(rn.status)) continue;
|
|
156
|
+
if (rid !== id && rn.status !== "blocked") continue;
|
|
157
|
+
await patch(rid, {
|
|
158
|
+
status: "pending",
|
|
159
|
+
started_at: undefined,
|
|
160
|
+
ended_at: undefined,
|
|
161
|
+
turns: 0,
|
|
162
|
+
tokens: 0,
|
|
163
|
+
cost_usd_estimate: 0,
|
|
164
|
+
produced_outputs: [],
|
|
165
|
+
contract_result: undefined,
|
|
166
|
+
status_note: undefined,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
114
171
|
// auto-initialize workers inserted into the spec after the run started
|
|
115
172
|
for (const w of spec.workers) {
|
|
116
173
|
if (!state.nodes[w.id]) {
|
|
@@ -144,6 +201,32 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
144
201
|
}
|
|
145
202
|
break;
|
|
146
203
|
}
|
|
204
|
+
// honor kill requests for not-yet-running nodes (incl. blocked, issue #1 bug 3)
|
|
205
|
+
for (const w of spec.workers) {
|
|
206
|
+
const n = state.nodes[w.id];
|
|
207
|
+
if (!n) continue;
|
|
208
|
+
if (!opts.nodeKills?.has(w.id)) continue;
|
|
209
|
+
if (n.status === "pending" || n.status === "ready" || n.status === "blocked") {
|
|
210
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// circuit breaker: two distinct nodes failed with the identical error
|
|
214
|
+
if (circuitOpen) {
|
|
215
|
+
const [tripError, tripSet] = [...circuitErrors.entries()].find(([_, ids]) => ids.size >= 2) ?? ["", new Set<string>()];
|
|
216
|
+
const tripCount = tripSet.size;
|
|
217
|
+
const note = `circuit breaker: ${tripCount} nodes failed with identical error: ${tripError.slice(0, 80)}`;
|
|
218
|
+
for (const w of spec.workers) {
|
|
219
|
+
const n = state.nodes[w.id];
|
|
220
|
+
if (!n || (n.status !== "pending" && n.status !== "ready")) continue;
|
|
221
|
+
await patch(w.id, { status: "blocked", ended_at: new Date().toISOString(), status_note: note });
|
|
222
|
+
}
|
|
223
|
+
if (running.size > 0) {
|
|
224
|
+
await Promise.race(running);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
147
230
|
// dispatch ready
|
|
148
231
|
const activeCount = running.size;
|
|
149
232
|
let slots = spec.config.max_concurrent - activeCount;
|
|
@@ -214,17 +297,57 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
214
297
|
}
|
|
215
298
|
}
|
|
216
299
|
|
|
300
|
+
const dispatchMs = Date.now();
|
|
217
301
|
await patch(w.id, { status: "running", started_at: new Date().toISOString() });
|
|
218
|
-
const p =
|
|
302
|
+
const p = (async () => {
|
|
303
|
+
let totals = { turns: 0, tokens: 0, cost: 0 };
|
|
304
|
+
let lastRes: Awaited<ReturnType<typeof opts.spawn>> | undefined;
|
|
305
|
+
let finalError: string | undefined;
|
|
306
|
+
|
|
307
|
+
for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
|
|
308
|
+
if (opts.killSwitch?.killed) return;
|
|
309
|
+
if (opts.nodeKills?.has(w.id)) {
|
|
310
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const res = await opts.spawn(w.id);
|
|
314
|
+
lastRes = res;
|
|
315
|
+
totals.turns += res.turns;
|
|
316
|
+
totals.tokens += res.tokens;
|
|
317
|
+
totals.cost += res.cost ?? 0;
|
|
318
|
+
if (opts.killSwitch?.killed) return;
|
|
319
|
+
if (opts.nodeKills?.has(w.id)) {
|
|
320
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (res.ok) {
|
|
324
|
+
finalError = undefined;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
finalError = res.error;
|
|
328
|
+
if (!res.error || !isRetryableError(res.error) || attempt === MAX_RETRY_ATTEMPTS - 1) {
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
await patch(w.id, { status_note: `retry ${attempt + 1}/${MAX_RETRY_ATTEMPTS - 1} after: ${res.error}` });
|
|
332
|
+
const delayMs = opts.retryDelayMs ? opts.retryDelayMs(attempt) : DEFAULT_RETRY_DELAY_MS(attempt);
|
|
333
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const res = lastRes;
|
|
337
|
+
if (!res) return;
|
|
338
|
+
|
|
219
339
|
if (opts.killSwitch?.killed) return;
|
|
220
340
|
if (opts.nodeKills?.has(w.id)) {
|
|
221
|
-
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns:
|
|
341
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
|
|
222
342
|
return;
|
|
223
343
|
}
|
|
344
|
+
|
|
224
345
|
if (!res.ok) {
|
|
225
|
-
await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns:
|
|
346
|
+
await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: totals.turns, tokens: totals.tokens, cost_usd_estimate: totals.cost });
|
|
347
|
+
recordCircuitError(w.id, finalError ?? res.error);
|
|
226
348
|
return;
|
|
227
349
|
}
|
|
350
|
+
|
|
228
351
|
if (w.worktree) {
|
|
229
352
|
try {
|
|
230
353
|
await commitWorktree({
|
|
@@ -238,9 +361,9 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
238
361
|
await patch(w.id, {
|
|
239
362
|
status: "failed",
|
|
240
363
|
ended_at: new Date().toISOString(),
|
|
241
|
-
turns:
|
|
242
|
-
tokens:
|
|
243
|
-
cost_usd_estimate:
|
|
364
|
+
turns: totals.turns,
|
|
365
|
+
tokens: totals.tokens,
|
|
366
|
+
cost_usd_estimate: totals.cost,
|
|
244
367
|
status_note: `commit failed: ${msg}`,
|
|
245
368
|
});
|
|
246
369
|
return;
|
|
@@ -250,22 +373,25 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
250
373
|
workerDir: `${fleetRoot}/workers/${w.id}`,
|
|
251
374
|
repoCwd: repoCwdFor(w.id),
|
|
252
375
|
outputs: w.outputs,
|
|
376
|
+
notBeforeMs: dispatchMs,
|
|
253
377
|
});
|
|
378
|
+
const costUnknown = res.tokens > 0 && res.cost === 0;
|
|
379
|
+
const costNote = costUnknown ? `cost unavailable: no pricing for model (${res.tokens} tokens used)` : undefined;
|
|
254
380
|
await patch(w.id, {
|
|
255
381
|
status: contract.ok ? "completed" : "contract_failed",
|
|
256
382
|
ended_at: new Date().toISOString(),
|
|
257
|
-
turns:
|
|
258
|
-
tokens:
|
|
259
|
-
cost_usd_estimate:
|
|
383
|
+
turns: totals.turns,
|
|
384
|
+
tokens: totals.tokens,
|
|
385
|
+
cost_usd_estimate: totals.cost,
|
|
260
386
|
contract_result: contract,
|
|
261
387
|
produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
|
|
262
|
-
status_note: contract.ok ?
|
|
388
|
+
status_note: contract.ok ? costNote : [contractFailureNote(contract.checks), costNote].filter(Boolean).join(" · "),
|
|
263
389
|
});
|
|
264
390
|
if (contract.ok) {
|
|
265
391
|
const note = await opts.onNodeCompleted?.(w.id);
|
|
266
392
|
if (note) await patch(w.id, { status_note: note });
|
|
267
393
|
}
|
|
268
|
-
}).finally(() => running.delete(p));
|
|
394
|
+
})().finally(() => running.delete(p));
|
|
269
395
|
running.add(p);
|
|
270
396
|
}
|
|
271
397
|
if (running.size > 0) {
|
|
@@ -275,13 +401,46 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
275
401
|
return !!n && TERMINAL_NODE_STATUSES.has(n.status);
|
|
276
402
|
})) {
|
|
277
403
|
break;
|
|
404
|
+
} else {
|
|
405
|
+
// Defensive: nothing in flight, not all terminal, and nothing dispatchable
|
|
406
|
+
// would mean a tight infinite loop (busy-spin, event-loop starvation).
|
|
407
|
+
// Fail the stuck nodes instead of hanging.
|
|
408
|
+
const dispatchable = spec.workers.some((w) => {
|
|
409
|
+
const n = state.nodes[w.id];
|
|
410
|
+
if (!n || (n.status !== "pending" && n.status !== "ready")) return false;
|
|
411
|
+
if (opts.nodeKills?.has(w.id)) return true; // will be killed next pass
|
|
412
|
+
return w.depends_on.every((d) => state.nodes[d]?.status === "completed");
|
|
413
|
+
});
|
|
414
|
+
const blockingDepsFailed = spec.workers.some((w) => {
|
|
415
|
+
const n = state.nodes[w.id];
|
|
416
|
+
if (!n || (n.status !== "pending" && n.status !== "ready")) return false;
|
|
417
|
+
return w.depends_on.some((d) => FAILED.has(state.nodes[d]?.status ?? ""));
|
|
418
|
+
});
|
|
419
|
+
if (!dispatchable && !blockingDepsFailed) {
|
|
420
|
+
for (const w of spec.workers) {
|
|
421
|
+
const n = state.nodes[w.id];
|
|
422
|
+
if (!n || TERMINAL_NODE_STATUSES.has(n.status)) continue;
|
|
423
|
+
await patch(w.id, {
|
|
424
|
+
status: "failed",
|
|
425
|
+
ended_at: new Date().toISOString(),
|
|
426
|
+
status_note: `stuck in non-terminal status "${n.status}" with no dispatchable path; failed to avoid scheduler hang`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
278
431
|
}
|
|
279
432
|
}
|
|
280
433
|
await Promise.allSettled([...running]);
|
|
281
434
|
};
|
|
282
435
|
|
|
436
|
+
const runPassUntilDrained = async () => {
|
|
437
|
+
do {
|
|
438
|
+
await runPass();
|
|
439
|
+
} while (opts.relaunchRequests && opts.relaunchRequests.size > 0);
|
|
440
|
+
};
|
|
441
|
+
|
|
283
442
|
if (!loop) {
|
|
284
|
-
await
|
|
443
|
+
await runPassUntilDrained();
|
|
285
444
|
const anyFailed = spec.workers.some((w) =>
|
|
286
445
|
["failed", "contract_failed"].includes(state.nodes[w.id]?.status ?? ""));
|
|
287
446
|
const finalStatus = opts.killSwitch?.killed ? "killed" : anyFailed ? "failed" : "completed";
|
|
@@ -311,7 +470,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
311
470
|
|
|
312
471
|
await opts.prepareIteration?.(n, state);
|
|
313
472
|
|
|
314
|
-
await
|
|
473
|
+
await runPassUntilDrained();
|
|
315
474
|
|
|
316
475
|
let verdict: Verdict | null = null;
|
|
317
476
|
let verdictBody: string | null = null;
|
package/src/state.ts
CHANGED
|
@@ -32,6 +32,8 @@ export async function readState(fleetRoot: string): Promise<FleetState> {
|
|
|
32
32
|
lgtm_streak: parsed.lgtm_streak ?? 0,
|
|
33
33
|
paused: parsed.paused ?? false,
|
|
34
34
|
iterations: parsed.iterations ?? [],
|
|
35
|
+
pid: parsed.pid,
|
|
36
|
+
heartbeat_at: parsed.heartbeat_at,
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -121,34 +123,34 @@ export function patchNode(
|
|
|
121
123
|
return { ...state, nodes, cost_usd_estimate: cost };
|
|
122
124
|
}
|
|
123
125
|
|
|
124
|
-
export function
|
|
126
|
+
export function relaunchResetIds(spec: FleetSpec, state: FleetState, nodeId: string): string[] {
|
|
125
127
|
if (!state.nodes[nodeId]) throw new Error(`unknown node "${nodeId}"`);
|
|
126
|
-
|
|
127
128
|
const dependents: Record<string, string[]> = {};
|
|
128
129
|
for (const w of spec.workers) {
|
|
129
130
|
for (const dep of w.depends_on) {
|
|
130
131
|
(dependents[dep] ??= []).push(w.id);
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
const out = [nodeId];
|
|
135
|
+
const seen = new Set(out);
|
|
136
|
+
const queue = [nodeId];
|
|
137
|
+
while (queue.length) {
|
|
138
|
+
for (const d of dependents[queue.shift()!] ?? []) {
|
|
139
|
+
if (seen.has(d)) continue;
|
|
140
|
+
seen.add(d);
|
|
141
|
+
if (state.nodes[d]?.status === "blocked") out.push(d);
|
|
142
|
+
queue.push(d);
|
|
141
143
|
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
144
147
|
|
|
148
|
+
export function resetForRelaunch(state: FleetState, spec: FleetSpec, nodeId: string): FleetState {
|
|
145
149
|
const fresh: NodeState = { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
|
|
146
150
|
const nodes = { ...state.nodes };
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
nodes[id] = fresh;
|
|
151
|
-
}
|
|
151
|
+
for (const id of relaunchResetIds(spec, state, nodeId)) {
|
|
152
|
+
if (id !== nodeId && state.nodes[id]?.status !== "blocked") continue;
|
|
153
|
+
nodes[id] = fresh;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
const cost = fleetCost({ ...state, nodes });
|