infinity-harness 2.6.6 → 2.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/CHANGELOG.md +66 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +391 -18
- package/package.json +1 -1
- package/src/core/config.ts +1 -1
- package/src/core/settings.ts +10 -3
- package/src/core/types.ts +16 -0
- package/src/exec/piWorker.ts +707 -0
- package/src/intake.ts +4 -1
- package/src/loop.ts +35 -34
- package/src/remote.ts +26 -6
- package/src/scheduler.ts +10 -3
- package/src/supervisor.ts +955 -0
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/widget.ts +134 -0
- package/src/ui/wizard.ts +43 -7
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the supervisor.
|
|
3
|
+
*
|
|
4
|
+
* This is the piece that makes the main session a control panel instead of a
|
|
5
|
+
* worker. It runs entirely in the extension's own process — plain JavaScript,
|
|
6
|
+
* no LLM — and its only job is to keep exactly one background pi session
|
|
7
|
+
* doing the right work with the right model.
|
|
8
|
+
*
|
|
9
|
+
* Why it exists
|
|
10
|
+
* -------------
|
|
11
|
+
* Before this, the loop pushed the brief back into the human's own session
|
|
12
|
+
* with `pi.sendUserMessage`. Everything followed from that one line: the
|
|
13
|
+
* human's model did every task regardless of difficulty, the human's context
|
|
14
|
+
* window carried the whole run, the human's token budget paid for it, and a
|
|
15
|
+
* "session handoff" meant replacing the terminal the human was typing into.
|
|
16
|
+
* The difficulty tiers the wizard collects had nowhere to be applied, because
|
|
17
|
+
* a session only ever has one model and it was the human's.
|
|
18
|
+
*
|
|
19
|
+
* The shape now
|
|
20
|
+
* -------------
|
|
21
|
+
* main session the human, the widget, the log. No harness turns at all.
|
|
22
|
+
* supervisor this file. Decides, spawns, watches, records.
|
|
23
|
+
* worker a separate `pi --mode rpc` process with its own model,
|
|
24
|
+
* its own context window and its own session file.
|
|
25
|
+
*
|
|
26
|
+
* The unit is the session is the model
|
|
27
|
+
* ------------------------------------
|
|
28
|
+
* `session.handoff` names one level of the plan — goal, phase, sprint,
|
|
29
|
+
* feature, task, subtask. That level is the *unit*: one worker owns one unit
|
|
30
|
+
* from start to finish, in one session, on one model. When the run crosses a
|
|
31
|
+
* unit boundary the worker is closed and a new one starts, and *that* is the
|
|
32
|
+
* handoff. Because the model is chosen when the worker starts, the model
|
|
33
|
+
* boundary and the session boundary are the same boundary by construction —
|
|
34
|
+
* which is why the difficulty of a unit, not of a task, decides the model
|
|
35
|
+
* (see `effectiveDifficultyForTask`).
|
|
36
|
+
*
|
|
37
|
+
* Nothing in here holds run state in memory. The plan, the phase, the budgets
|
|
38
|
+
* and the ladder are all files, so a supervisor that dies mid-run is restarted
|
|
39
|
+
* by the next session with nothing lost.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { mkdirSync } from "node:fs";
|
|
43
|
+
import { join, resolve } from "node:path";
|
|
44
|
+
|
|
45
|
+
import type { HandoffGranularity, Phase } from "./core/types.ts";
|
|
46
|
+
import { loadConfig } from "./core/config.ts";
|
|
47
|
+
import { loadFeatureList, nextActionableTask, findFeature, type FlatTask } from "./core/featureList.ts";
|
|
48
|
+
import { harnessDir } from "./core/paths.ts";
|
|
49
|
+
import { readJsonSafe, writeJsonAtomic, fileExists } from "./core/fsx.ts";
|
|
50
|
+
import { decideNext, stopFilePath, type LoopDecision } from "./loop.ts";
|
|
51
|
+
import { loadRunState, countSession, disarmRun } from "./runState.ts";
|
|
52
|
+
import { effectiveDifficultyForTask } from "./scheduler.ts";
|
|
53
|
+
import { resolveModel, resolveThinking, loadRouterConfig } from "./modelRouter.ts";
|
|
54
|
+
import {
|
|
55
|
+
WorkerSession,
|
|
56
|
+
WORKER_DIRECTIVE,
|
|
57
|
+
workerSessionDir,
|
|
58
|
+
type WorkerEvent,
|
|
59
|
+
} from "./exec/piWorker.ts";
|
|
60
|
+
|
|
61
|
+
// ── on-disk state ───────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export const SUPERVISOR_FILE = "supervisor.json";
|
|
64
|
+
export const ACTIVITY_FILE = "activity.json";
|
|
65
|
+
/** Activity lines kept. Enough to scroll back through a phase, not a run. */
|
|
66
|
+
export const ACTIVITY_LIMIT = 400;
|
|
67
|
+
|
|
68
|
+
export function supervisorStatePath(targetDir: string): string {
|
|
69
|
+
return resolve(harnessDir(targetDir), SUPERVISOR_FILE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function activityPath(targetDir: string): string {
|
|
73
|
+
return resolve(harnessDir(targetDir), ACTIVITY_FILE);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ActivityLevel = "info" | "work" | "warn" | "error" | "good";
|
|
77
|
+
|
|
78
|
+
export type ActivityLine = {
|
|
79
|
+
at: string;
|
|
80
|
+
level: ActivityLevel;
|
|
81
|
+
/** Which worker said it, e.g. "W3". Null for the supervisor itself. */
|
|
82
|
+
worker: string | null;
|
|
83
|
+
text: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type WorkerView = {
|
|
87
|
+
/** Short stable label: W1, W2, … */
|
|
88
|
+
name: string;
|
|
89
|
+
unitKey: string;
|
|
90
|
+
unitLabel: string;
|
|
91
|
+
level: UnitLevel;
|
|
92
|
+
difficulty: string | null;
|
|
93
|
+
/** What we asked for. */
|
|
94
|
+
model: string;
|
|
95
|
+
/** What actually answered, once a turn has run. */
|
|
96
|
+
servedModel: string | null;
|
|
97
|
+
thinking: string;
|
|
98
|
+
state: "starting" | "working" | "idle" | "closed" | "failed";
|
|
99
|
+
/** The last thing it did, for the log line the human reads. */
|
|
100
|
+
doing: string | null;
|
|
101
|
+
startedAt: string;
|
|
102
|
+
turns: number;
|
|
103
|
+
tokens: { inputTokens: number; outputTokens: number };
|
|
104
|
+
contextRatio: number | null;
|
|
105
|
+
sessionId: string | null;
|
|
106
|
+
attemptDir: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Who is driving, and when they last said so.
|
|
111
|
+
*
|
|
112
|
+
* Two pi windows open on the same project would otherwise both start a
|
|
113
|
+
* supervisor, and the run would get two workers editing the same tree — the
|
|
114
|
+
* exact corruption every lock in this codebase exists to prevent. The second
|
|
115
|
+
* one reads this, sees a fresh heartbeat, and stays a viewer.
|
|
116
|
+
*/
|
|
117
|
+
export type SupervisorOwner = {
|
|
118
|
+
pid: number;
|
|
119
|
+
sessionId: string;
|
|
120
|
+
at: string;
|
|
121
|
+
/** The pi child this owner has running, so a crash can be cleaned up after. */
|
|
122
|
+
workerPid: number | null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** A heartbeat older than this means the owner is gone, not busy. */
|
|
126
|
+
export const OWNER_STALE_MS = 90_000;
|
|
127
|
+
/** How often the owner refreshes its claim. Comfortably inside the stale window. */
|
|
128
|
+
export const HEARTBEAT_MS = 20_000;
|
|
129
|
+
|
|
130
|
+
export type SupervisorState = {
|
|
131
|
+
version: 1;
|
|
132
|
+
runId: string;
|
|
133
|
+
owner?: SupervisorOwner | null;
|
|
134
|
+
status: "idle" | "running" | "stopped";
|
|
135
|
+
startedAt: string | null;
|
|
136
|
+
updatedAt: string;
|
|
137
|
+
/** The model the main session is on — what an empty router slot means. */
|
|
138
|
+
baseModel: string | null;
|
|
139
|
+
handoff: HandoffGranularity;
|
|
140
|
+
unit: WorkUnit | null;
|
|
141
|
+
worker: WorkerView | null;
|
|
142
|
+
/** Finished workers, newest last, capped. */
|
|
143
|
+
history: WorkerView[];
|
|
144
|
+
sessions: number;
|
|
145
|
+
lastDecision: string | null;
|
|
146
|
+
stopReason: string | null;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const HISTORY_LIMIT = 20;
|
|
150
|
+
|
|
151
|
+
export function emptySupervisorState(runId: string): SupervisorState {
|
|
152
|
+
return {
|
|
153
|
+
version: 1,
|
|
154
|
+
runId,
|
|
155
|
+
owner: null,
|
|
156
|
+
status: "idle",
|
|
157
|
+
startedAt: null,
|
|
158
|
+
updatedAt: new Date().toISOString(),
|
|
159
|
+
baseModel: null,
|
|
160
|
+
handoff: "task",
|
|
161
|
+
unit: null,
|
|
162
|
+
worker: null,
|
|
163
|
+
history: [],
|
|
164
|
+
sessions: 0,
|
|
165
|
+
lastDecision: null,
|
|
166
|
+
stopReason: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function loadSupervisorState(targetDir: string): SupervisorState | null {
|
|
171
|
+
const raw = readJsonSafe<Partial<SupervisorState> | null>(supervisorStatePath(targetDir), null);
|
|
172
|
+
if (!raw || typeof raw.runId !== "string") return null;
|
|
173
|
+
return { ...emptySupervisorState(raw.runId), ...raw } as SupervisorState;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Is someone else already driving this project? Returns their claim, or null. */
|
|
177
|
+
export function activeOwner(targetDir: string, selfPid = process.pid, now = Date.now()): SupervisorOwner | null {
|
|
178
|
+
const st = loadSupervisorState(targetDir);
|
|
179
|
+
const owner = st?.owner ?? null;
|
|
180
|
+
if (!owner || typeof owner.pid !== "number") return null;
|
|
181
|
+
if (owner.pid === selfPid) return null;
|
|
182
|
+
const at = Date.parse(owner.at ?? "");
|
|
183
|
+
if (!Number.isFinite(at) || now - at > OWNER_STALE_MS) return null;
|
|
184
|
+
// A heartbeat can be fresh and the process still gone — a hard kill leaves
|
|
185
|
+
// the last one behind. Ask the OS rather than trusting the timestamp alone.
|
|
186
|
+
if (!processAlive(owner.pid)) return null;
|
|
187
|
+
return owner;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Does this pid exist? `kill(pid, 0)` throws ESRCH when it does not. */
|
|
191
|
+
export function processAlive(pid: number | null | undefined): boolean {
|
|
192
|
+
if (typeof pid !== "number" || pid <= 0) return false;
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, 0);
|
|
195
|
+
return true;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return (e as { code?: string })?.code === "EPERM";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Kill a pi child left behind by a supervisor that died.
|
|
203
|
+
*
|
|
204
|
+
* A `SIGKILL`ed pi cannot close its worker, and that worker keeps editing the
|
|
205
|
+
* project — with nothing watching it and nothing able to stop it. The next
|
|
206
|
+
* session picks up the pid from the state file and ends it.
|
|
207
|
+
*/
|
|
208
|
+
export function reapOrphanWorker(targetDir: string): number | null {
|
|
209
|
+
const st = loadSupervisorState(targetDir);
|
|
210
|
+
const pid = st?.owner?.workerPid ?? null;
|
|
211
|
+
if (!pid || !processAlive(pid)) return null;
|
|
212
|
+
if (processAlive(st?.owner?.pid)) return null; // its supervisor is alive; not an orphan
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid, "SIGKILL");
|
|
215
|
+
return pid;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function saveSupervisorState(targetDir: string, state: SupervisorState): void {
|
|
222
|
+
state.updatedAt = new Date().toISOString();
|
|
223
|
+
writeJsonAtomic(supervisorStatePath(targetDir), state);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function loadActivity(targetDir: string): ActivityLine[] {
|
|
227
|
+
const raw = readJsonSafe<{ lines?: ActivityLine[] } | null>(activityPath(targetDir), null);
|
|
228
|
+
return Array.isArray(raw?.lines) ? (raw!.lines as ActivityLine[]) : [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Append one line to the background log.
|
|
233
|
+
*
|
|
234
|
+
* This is the only channel the human has into what the workers are doing, so
|
|
235
|
+
* it is a file rather than a notification: a run that spans days outlives
|
|
236
|
+
* every terminal that watched part of it.
|
|
237
|
+
*/
|
|
238
|
+
export function appendActivity(targetDir: string, line: Omit<ActivityLine, "at">): ActivityLine[] {
|
|
239
|
+
const lines = loadActivity(targetDir);
|
|
240
|
+
const next = [...lines, { at: new Date().toISOString(), ...line }].slice(-ACTIVITY_LIMIT);
|
|
241
|
+
try {
|
|
242
|
+
writeJsonAtomic(activityPath(targetDir), { lines: next });
|
|
243
|
+
} catch {
|
|
244
|
+
/* a log that cannot be written must not stop the run */
|
|
245
|
+
}
|
|
246
|
+
return next;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── the unit ────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
export type UnitLevel = "run" | "goal" | "phase" | "sprint" | "feature" | "task" | "subtask";
|
|
252
|
+
|
|
253
|
+
export type WorkUnit = {
|
|
254
|
+
level: UnitLevel;
|
|
255
|
+
/** Identity. A change here is a session boundary, and therefore a model boundary. */
|
|
256
|
+
key: string;
|
|
257
|
+
/** One line for the widget: "feature-002 · Checkout". */
|
|
258
|
+
label: string;
|
|
259
|
+
phase: Phase | null;
|
|
260
|
+
difficulty: string | null;
|
|
261
|
+
/** Resolved model reference, or "" meaning "whatever the base model is". */
|
|
262
|
+
model: string;
|
|
263
|
+
thinking: string;
|
|
264
|
+
taskKey: string | null;
|
|
265
|
+
featureId: string | null;
|
|
266
|
+
/** What the worker is told its remit is, in one sentence. */
|
|
267
|
+
scope: string;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/** The handoff knob, as a unit level. `off` and `goal` both mean one session. */
|
|
271
|
+
export function unitLevelFor(handoff: HandoffGranularity): UnitLevel {
|
|
272
|
+
switch (handoff) {
|
|
273
|
+
case "off":
|
|
274
|
+
return "run";
|
|
275
|
+
case "goal":
|
|
276
|
+
return "goal";
|
|
277
|
+
case "phase":
|
|
278
|
+
return "phase";
|
|
279
|
+
case "sprint":
|
|
280
|
+
return "sprint";
|
|
281
|
+
case "feature":
|
|
282
|
+
return "feature";
|
|
283
|
+
case "subtask":
|
|
284
|
+
return "subtask";
|
|
285
|
+
default:
|
|
286
|
+
return "task";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const SCOPE_TEXT: Record<UnitLevel, string> = {
|
|
291
|
+
run: "You own this entire run. Keep going through every phase until the harness stops you.",
|
|
292
|
+
goal: "You own this goal. Keep going until every feature under it is done.",
|
|
293
|
+
phase: "You own this whole phase. Work through every task in it before you stop.",
|
|
294
|
+
sprint: "You own this sprint. Work through every feature and task in it before you stop.",
|
|
295
|
+
feature: "You own this feature. Work through all of its tasks before you stop.",
|
|
296
|
+
task: "You own this one task. Finish it, then stop — the harness starts the next one.",
|
|
297
|
+
subtask: "You own this one subtask. Finish it, then stop — the harness starts the next one.",
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* What is being worked on right now, at the configured granularity.
|
|
302
|
+
*
|
|
303
|
+
* Returns null when there is nothing actionable: an empty plan in an early
|
|
304
|
+
* phase is normal, and the phase's own seeded work is what the brief will
|
|
305
|
+
* name, so the unit falls back to the phase.
|
|
306
|
+
*/
|
|
307
|
+
export function currentUnit(targetDir: string, baseModel?: string | null): WorkUnit | null {
|
|
308
|
+
const { config } = loadConfig(targetDir);
|
|
309
|
+
const { list } = loadFeatureList(targetDir);
|
|
310
|
+
const handoff = (config.session?.handoff ?? "task") as HandoffGranularity;
|
|
311
|
+
const level = unitLevelFor(handoff);
|
|
312
|
+
const phase = (config.currentPhase ?? null) as Phase | null;
|
|
313
|
+
|
|
314
|
+
const phaseTask = phase ? nextActionableTask(list, phase) : null;
|
|
315
|
+
const task = (phaseTask ?? nextActionableTask(list)) as FlatTask | null;
|
|
316
|
+
const feature = task ? findFeature(list, task.featureId) ?? null : null;
|
|
317
|
+
const sprintId = (feature as { sprintId?: string } | null)?.sprintId ?? null;
|
|
318
|
+
const sprint = sprintId ? (list.sprints ?? []).find((s) => s.id === sprintId) ?? null : null;
|
|
319
|
+
const goalId =
|
|
320
|
+
(feature as { goalId?: string } | null)?.goalId ??
|
|
321
|
+
(sprint as { goalId?: string } | null)?.goalId ??
|
|
322
|
+
(list.goals ?? [])[0]?.id ??
|
|
323
|
+
null;
|
|
324
|
+
|
|
325
|
+
const difficulty = task
|
|
326
|
+
? (effectiveDifficultyForTask(task, handoff, list) ?? (task as { difficulty?: string }).difficulty ?? null)
|
|
327
|
+
: null;
|
|
328
|
+
|
|
329
|
+
// The active subtask, when the plan goes that deep.
|
|
330
|
+
let subtaskKey: string | null = null;
|
|
331
|
+
let subtaskTitle: string | null = null;
|
|
332
|
+
if (task && Array.isArray((task as { subtasks?: Array<{ id?: string; title: string; status: string }> }).subtasks)) {
|
|
333
|
+
const subs = (task as { subtasks?: Array<{ id?: string; title: string; status: string }> }).subtasks ?? [];
|
|
334
|
+
const open = subs.find((s) => s.status !== "complete") ?? null;
|
|
335
|
+
if (open) {
|
|
336
|
+
subtaskKey = `${task.compositeKey}#${open.id ?? open.title}`;
|
|
337
|
+
subtaskTitle = open.title;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const identity = ((): { key: string; label: string } | null => {
|
|
342
|
+
switch (level) {
|
|
343
|
+
case "run":
|
|
344
|
+
return { key: "run", label: "whole run" };
|
|
345
|
+
case "goal":
|
|
346
|
+
return goalId ? { key: `goal:${goalId}` , label: goalGuess(list, goalId) } : { key: "goal:*", label: "the goal" };
|
|
347
|
+
case "phase":
|
|
348
|
+
return phase ? { key: `phase:${phase}`, label: phase.toUpperCase() } : null;
|
|
349
|
+
case "sprint":
|
|
350
|
+
return sprintId
|
|
351
|
+
? { key: `sprint:${sprintId}`, label: `${sprintId}${sprint?.name ? ` · ${sprint.name}` : ""}` }
|
|
352
|
+
: phase
|
|
353
|
+
? { key: `phase:${phase}`, label: phase.toUpperCase() }
|
|
354
|
+
: null;
|
|
355
|
+
case "feature":
|
|
356
|
+
return feature
|
|
357
|
+
? { key: `feature:${feature.id}`, label: `${feature.id} · ${feature.name}` }
|
|
358
|
+
: phase
|
|
359
|
+
? { key: `phase:${phase}`, label: phase.toUpperCase() }
|
|
360
|
+
: null;
|
|
361
|
+
case "subtask":
|
|
362
|
+
if (subtaskKey) return { key: `subtask:${subtaskKey}`, label: `${task!.compositeKey} › ${subtaskTitle}` };
|
|
363
|
+
return task ? { key: `task:${task.compositeKey}`, label: task.compositeKey } : phase ? { key: `phase:${phase}`, label: phase.toUpperCase() } : null;
|
|
364
|
+
default:
|
|
365
|
+
return task
|
|
366
|
+
? { key: `task:${task.compositeKey}`, label: `${task.compositeKey} · ${short(task.description)}` }
|
|
367
|
+
: phase
|
|
368
|
+
? { key: `phase:${phase}`, label: phase.toUpperCase() }
|
|
369
|
+
: null;
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
if (!identity) return null;
|
|
373
|
+
|
|
374
|
+
const routed = resolveModel({
|
|
375
|
+
projectDir: targetDir,
|
|
376
|
+
task: task
|
|
377
|
+
? ({
|
|
378
|
+
difficulty: difficulty ?? undefined,
|
|
379
|
+
modelHint: (task as { modelHint?: string }).modelHint,
|
|
380
|
+
id: task.id,
|
|
381
|
+
key: task.compositeKey,
|
|
382
|
+
} as never)
|
|
383
|
+
: undefined,
|
|
384
|
+
feature: (feature ?? undefined) as never,
|
|
385
|
+
sprint: (sprint ?? undefined) as never,
|
|
386
|
+
phase: phase ?? undefined,
|
|
387
|
+
role: (config.currentRole ?? undefined) as string | undefined,
|
|
388
|
+
});
|
|
389
|
+
const thinking = resolveThinking({
|
|
390
|
+
projectDir: targetDir,
|
|
391
|
+
task: difficulty ? ({ difficulty } as never) : undefined,
|
|
392
|
+
feature: (feature ?? undefined) as never,
|
|
393
|
+
sprint: (sprint ?? undefined) as never,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
level,
|
|
398
|
+
key: identity.key,
|
|
399
|
+
label: identity.label,
|
|
400
|
+
phase,
|
|
401
|
+
difficulty,
|
|
402
|
+
model: (routed && routed.trim()) || (baseModel ?? "") || "",
|
|
403
|
+
thinking: thinking || "",
|
|
404
|
+
taskKey: task?.compositeKey ?? null,
|
|
405
|
+
featureId: feature?.id ?? null,
|
|
406
|
+
scope: SCOPE_TEXT[level],
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function goalGuess(list: { goals?: Array<{ id: string; title: string }> }, goalId: string): string {
|
|
411
|
+
const g = (list.goals ?? []).find((x) => x.id === goalId);
|
|
412
|
+
return g ? `${g.id} · ${short(g.title)}` : goalId;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function short(s: string | undefined, n = 44): string {
|
|
416
|
+
const v = (s ?? "").replace(/\s+/g, " ").trim();
|
|
417
|
+
return v.length > n ? v.slice(0, n - 1) + "…" : v;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** One line for the widget: what this unit is and which model owns it. */
|
|
421
|
+
export function describeUnit(unit: WorkUnit, baseModel?: string | null): string {
|
|
422
|
+
const model = unit.model || baseModel || "pi default";
|
|
423
|
+
const diff = unit.difficulty ? ` (${unit.difficulty})` : "";
|
|
424
|
+
return `${unit.level} ${unit.label}${diff} → ${model}${unit.thinking ? ` · ${unit.thinking}` : ""}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── the loop ────────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
export type SupervisorHooks = {
|
|
430
|
+
/** Called whenever the state file changes, so the host can redraw. */
|
|
431
|
+
onState?: (state: SupervisorState) => void;
|
|
432
|
+
/** Called for every activity line, so the host can notify sparingly. */
|
|
433
|
+
onActivity?: (line: ActivityLine) => void;
|
|
434
|
+
/** A phase's gate passed and a human has to sign it. */
|
|
435
|
+
onApproval?: (phase: Phase, message: string) => void;
|
|
436
|
+
/** The run ended, for whatever reason. */
|
|
437
|
+
onStop?: (reason: string, detail: string) => void;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
export type SupervisorOptions = {
|
|
441
|
+
targetDir: string;
|
|
442
|
+
runId: string;
|
|
443
|
+
/** What the main session is on. An empty router slot inherits this. */
|
|
444
|
+
baseModel?: string | null;
|
|
445
|
+
hooks?: SupervisorHooks;
|
|
446
|
+
/** Ceiling on units, so a test can bound the loop. */
|
|
447
|
+
maxCycles?: number;
|
|
448
|
+
/** Cap on one prompt→settled cycle inside a worker. */
|
|
449
|
+
turnTimeoutMs?: number;
|
|
450
|
+
/** Extra argv for every worker. The e2e rig points workers at its mock model. */
|
|
451
|
+
workerArgs?: string[];
|
|
452
|
+
workerEnv?: NodeJS.ProcessEnv;
|
|
453
|
+
/**
|
|
454
|
+
* Path to the harness extension, used only if a worker turns out not to
|
|
455
|
+
* have discovered it. See `WorkerSpec.harnessExtension`.
|
|
456
|
+
*/
|
|
457
|
+
harnessExtension?: string | null;
|
|
458
|
+
/** Injected for tests. */
|
|
459
|
+
createWorker?: (spec: ConstructorParameters<typeof WorkerSession>[0]) => WorkerSession;
|
|
460
|
+
decide?: typeof decideNext;
|
|
461
|
+
/** Pause between cycles, so a fast failure loop cannot spin the CPU. */
|
|
462
|
+
idleMs?: number;
|
|
463
|
+
/** Identifies this driver in the ownership claim. Defaults to the pid. */
|
|
464
|
+
sessionId?: string;
|
|
465
|
+
/** Drive even when another session holds the claim. Only a human asks for this. */
|
|
466
|
+
takeOver?: boolean;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/** Returned instead of a supervisor when another session is already driving. */
|
|
470
|
+
export type SupervisorRefusal = { started: false; reason: string; owner: SupervisorOwner };
|
|
471
|
+
|
|
472
|
+
export function isRefusal(r: RunningSupervisor | SupervisorRefusal): r is SupervisorRefusal {
|
|
473
|
+
return (r as SupervisorRefusal).started === false;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* A running supervisor.
|
|
478
|
+
*
|
|
479
|
+
* `stop()` is safe to call from anywhere, including a pi shutdown handler; it
|
|
480
|
+
* closes the worker and resolves once the loop has actually left.
|
|
481
|
+
*/
|
|
482
|
+
export type RunningSupervisor = {
|
|
483
|
+
readonly runId: string;
|
|
484
|
+
stop(reason?: string): Promise<void>;
|
|
485
|
+
readonly done: Promise<void>;
|
|
486
|
+
isRunning(): boolean;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const DEFAULT_IDLE_MS = 750;
|
|
490
|
+
|
|
491
|
+
/** The shape `WorkerSession.prompt` resolves to; kept local so tests can fake it. */
|
|
492
|
+
type TurnLike = {
|
|
493
|
+
summary: string;
|
|
494
|
+
tools: string[];
|
|
495
|
+
usage: { inputTokens: number; outputTokens: number };
|
|
496
|
+
contextRatio: number | null;
|
|
497
|
+
aborted: boolean;
|
|
498
|
+
error: string | null;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
export function startSupervisor(options: SupervisorOptions): RunningSupervisor | SupervisorRefusal {
|
|
502
|
+
const dir = options.targetDir;
|
|
503
|
+
if (!options.takeOver) {
|
|
504
|
+
const owner = activeOwner(dir);
|
|
505
|
+
if (owner) {
|
|
506
|
+
return {
|
|
507
|
+
started: false,
|
|
508
|
+
reason:
|
|
509
|
+
`another pi session (pid ${owner.pid}) is already driving this run. ` +
|
|
510
|
+
`Two supervisors would put two workers in the same tree.`,
|
|
511
|
+
owner,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// A supervisor that was killed cannot close its worker, and that worker
|
|
516
|
+
// keeps editing the project with nothing watching it.
|
|
517
|
+
const orphan = reapOrphanWorker(dir);
|
|
518
|
+
const decide = options.decide ?? decideNext;
|
|
519
|
+
const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
|
|
520
|
+
let stopping = false;
|
|
521
|
+
let stopReason: string | null = null;
|
|
522
|
+
/**
|
|
523
|
+
* Resolved by `stop()`. The loop races every await that can block for a
|
|
524
|
+
* long time against it, so halting is immediate even when the thing being
|
|
525
|
+
* awaited is a wedged worker rather than a healthy one.
|
|
526
|
+
*/
|
|
527
|
+
let signalStop: () => void = () => {};
|
|
528
|
+
const stopSignal = new Promise<void>((r) => {
|
|
529
|
+
signalStop = r;
|
|
530
|
+
});
|
|
531
|
+
let worker: WorkerSession | null = null;
|
|
532
|
+
let workerView: WorkerView | null = null;
|
|
533
|
+
let workerSeq = 0;
|
|
534
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
535
|
+
/** True once the loop has left: the ownership claim must never come back. */
|
|
536
|
+
let released = false;
|
|
537
|
+
|
|
538
|
+
const state = loadSupervisorState(dir) ?? emptySupervisorState(options.runId);
|
|
539
|
+
state.runId = options.runId;
|
|
540
|
+
state.owner = { pid: process.pid, sessionId: options.sessionId ?? String(process.pid), at: new Date().toISOString(), workerPid: null };
|
|
541
|
+
state.status = "running";
|
|
542
|
+
state.startedAt = new Date().toISOString();
|
|
543
|
+
state.baseModel = options.baseModel ?? null;
|
|
544
|
+
state.stopReason = null;
|
|
545
|
+
state.worker = null;
|
|
546
|
+
saveSupervisorState(dir, state);
|
|
547
|
+
|
|
548
|
+
const publish = (): void => {
|
|
549
|
+
state.worker = workerView;
|
|
550
|
+
// The claim is refreshed on every publish, which happens on every worker
|
|
551
|
+
// event — far more often than the stale window, so a live run never looks
|
|
552
|
+
// abandoned and an abandoned one always does. Once released it stays
|
|
553
|
+
// released: re-stamping it on the way out would leave a dead claim behind
|
|
554
|
+
// for the next session to wait out.
|
|
555
|
+
state.owner = released
|
|
556
|
+
? null
|
|
557
|
+
: {
|
|
558
|
+
pid: process.pid,
|
|
559
|
+
sessionId: options.sessionId ?? String(process.pid),
|
|
560
|
+
at: new Date().toISOString(),
|
|
561
|
+
workerPid: worker?.pid ?? null,
|
|
562
|
+
};
|
|
563
|
+
saveSupervisorState(dir, state);
|
|
564
|
+
try {
|
|
565
|
+
options.hooks?.onState?.(state);
|
|
566
|
+
} catch {
|
|
567
|
+
/* a broken host must not stop the run */
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const say = (level: ActivityLevel, text: string, who: string | null = null): void => {
|
|
572
|
+
const line: ActivityLine = { at: new Date().toISOString(), level, worker: who, text };
|
|
573
|
+
appendActivity(dir, { level, worker: who, text });
|
|
574
|
+
try {
|
|
575
|
+
options.hooks?.onActivity?.(line);
|
|
576
|
+
} catch {
|
|
577
|
+
/* ditto */
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const closeWorker = async (why: string): Promise<void> => {
|
|
582
|
+
if (!worker) return;
|
|
583
|
+
const name = workerView?.name ?? "worker";
|
|
584
|
+
await worker.close();
|
|
585
|
+
if (workerView) {
|
|
586
|
+
workerView.state = "closed";
|
|
587
|
+
workerView.doing = why;
|
|
588
|
+
state.history = [...state.history, workerView].slice(-HISTORY_LIMIT);
|
|
589
|
+
}
|
|
590
|
+
worker = null;
|
|
591
|
+
workerView = null;
|
|
592
|
+
say("info", `${name} finished — ${why}`, name);
|
|
593
|
+
publish();
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const openWorker = async (unit: WorkUnit): Promise<WorkerSession | null> => {
|
|
597
|
+
const name = `W${++workerSeq}`;
|
|
598
|
+
const attemptDir = join(
|
|
599
|
+
resolve(dir, "tmp", "infinity-harness"),
|
|
600
|
+
sanitize(options.runId),
|
|
601
|
+
sanitize(unit.key),
|
|
602
|
+
`session-${workerSeq}`,
|
|
603
|
+
);
|
|
604
|
+
mkdirSync(attemptDir, { recursive: true });
|
|
605
|
+
workerView = {
|
|
606
|
+
name,
|
|
607
|
+
unitKey: unit.key,
|
|
608
|
+
unitLabel: unit.label,
|
|
609
|
+
level: unit.level,
|
|
610
|
+
difficulty: unit.difficulty,
|
|
611
|
+
model: unit.model || options.baseModel || "",
|
|
612
|
+
servedModel: null,
|
|
613
|
+
thinking: unit.thinking,
|
|
614
|
+
state: "starting",
|
|
615
|
+
doing: "starting a fresh pi session",
|
|
616
|
+
startedAt: new Date().toISOString(),
|
|
617
|
+
turns: 0,
|
|
618
|
+
tokens: { inputTokens: 0, outputTokens: 0 },
|
|
619
|
+
contextRatio: null,
|
|
620
|
+
sessionId: null,
|
|
621
|
+
attemptDir,
|
|
622
|
+
};
|
|
623
|
+
publish();
|
|
624
|
+
say(
|
|
625
|
+
"info",
|
|
626
|
+
`${name} starting — ${unit.level} ${unit.label} on ${workerView.model || "pi's default model"}` +
|
|
627
|
+
(unit.difficulty ? ` (${unit.difficulty})` : ""),
|
|
628
|
+
name,
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
const spec = {
|
|
632
|
+
projectDir: dir,
|
|
633
|
+
attemptDir,
|
|
634
|
+
model: unit.model || options.baseModel || null,
|
|
635
|
+
thinking: unit.thinking || null,
|
|
636
|
+
sessionDir: workerSessionDir(dir),
|
|
637
|
+
sessionName: `infinity ${unit.key}`,
|
|
638
|
+
unitKey: unit.key,
|
|
639
|
+
runId: options.runId,
|
|
640
|
+
turnTimeoutMs: options.turnTimeoutMs,
|
|
641
|
+
extraArgs: options.workerArgs,
|
|
642
|
+
env: options.workerEnv,
|
|
643
|
+
};
|
|
644
|
+
let session = options.createWorker ? options.createWorker(spec) : new WorkerSession(spec);
|
|
645
|
+
session.on((e) => onWorkerEvent(name, e));
|
|
646
|
+
session.start();
|
|
647
|
+
let ok = await session.ready();
|
|
648
|
+
// A worker that did not discover the harness cannot record what it did,
|
|
649
|
+
// and a run whose gate never sees the work loops forever on a finished
|
|
650
|
+
// task. Restart it once with the extension named explicitly.
|
|
651
|
+
if (ok && options.harnessExtension && !(await session.hasHarnessTools())) {
|
|
652
|
+
say("warn", `${name} started without the harness tools — restarting it with the extension loaded`, name);
|
|
653
|
+
await session.close();
|
|
654
|
+
const withExt = { ...spec, harnessExtension: options.harnessExtension };
|
|
655
|
+
session = options.createWorker ? options.createWorker(withExt) : new WorkerSession(withExt);
|
|
656
|
+
session.on((e) => onWorkerEvent(name, e));
|
|
657
|
+
session.start();
|
|
658
|
+
ok = await session.ready();
|
|
659
|
+
}
|
|
660
|
+
if (!ok) {
|
|
661
|
+
if (workerView) workerView.state = "failed";
|
|
662
|
+
say("error", `${name} could not start pi — ${session.startError ?? "no response"}`, name);
|
|
663
|
+
publish();
|
|
664
|
+
await session.close();
|
|
665
|
+
worker = null;
|
|
666
|
+
workerView = null;
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
if (workerView) {
|
|
670
|
+
workerView.state = "working";
|
|
671
|
+
workerView.sessionId = session.sessionId;
|
|
672
|
+
workerView.doing = "reading the brief";
|
|
673
|
+
}
|
|
674
|
+
worker = session;
|
|
675
|
+
state.sessions += 1;
|
|
676
|
+
countSession(dir);
|
|
677
|
+
publish();
|
|
678
|
+
return session;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const onWorkerEvent = (name: string, e: WorkerEvent): void => {
|
|
682
|
+
if (!workerView || workerView.name !== name) return;
|
|
683
|
+
switch (e.kind) {
|
|
684
|
+
case "tool":
|
|
685
|
+
workerView.doing = e.summary;
|
|
686
|
+
say("work", e.summary, name);
|
|
687
|
+
break;
|
|
688
|
+
case "model":
|
|
689
|
+
workerView.servedModel = e.provider ? `${e.provider}/${e.model}` : e.model;
|
|
690
|
+
break;
|
|
691
|
+
case "usage":
|
|
692
|
+
workerView.tokens = { inputTokens: e.inputTokens, outputTokens: e.outputTokens };
|
|
693
|
+
break;
|
|
694
|
+
case "compaction":
|
|
695
|
+
say("warn", `${name} compacted its context`, name);
|
|
696
|
+
break;
|
|
697
|
+
case "error":
|
|
698
|
+
say("error", e.message, name);
|
|
699
|
+
break;
|
|
700
|
+
default:
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
publish();
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const humanStopped = (): string | null => {
|
|
707
|
+
if (fileExists(stopFilePath(dir))) return "a STOP file is present";
|
|
708
|
+
const run = loadRunState(dir);
|
|
709
|
+
if (!run || !run.armed) return run?.stopReason ?? "the run is not armed";
|
|
710
|
+
const { config } = loadConfig(dir);
|
|
711
|
+
if (config.paused) return "the run is paused";
|
|
712
|
+
return null;
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const loop = async (): Promise<void> => {
|
|
716
|
+
let cycles = 0;
|
|
717
|
+
try {
|
|
718
|
+
if (orphan) say("warn", `killed an orphaned worker (pid ${orphan}) left by a previous session`);
|
|
719
|
+
say("info", `supervisor started — handoff at ${loadConfig(dir).config.session?.handoff ?? "task"} level`);
|
|
720
|
+
// A long quiet turn must not let the claim go stale under a second
|
|
721
|
+
// session that would then start a worker of its own.
|
|
722
|
+
heartbeat = setInterval(() => {
|
|
723
|
+
if (stopping) return;
|
|
724
|
+
try {
|
|
725
|
+
publish();
|
|
726
|
+
} catch {
|
|
727
|
+
/* a claim that cannot be written is not worth crashing a run for */
|
|
728
|
+
}
|
|
729
|
+
}, HEARTBEAT_MS);
|
|
730
|
+
heartbeat.unref?.();
|
|
731
|
+
while (!stopping) {
|
|
732
|
+
if (options.maxCycles !== undefined && cycles >= options.maxCycles) {
|
|
733
|
+
stopReason = stopReason ?? "cycle ceiling reached";
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
cycles += 1;
|
|
737
|
+
|
|
738
|
+
const brake = humanStopped();
|
|
739
|
+
if (brake) {
|
|
740
|
+
stopReason = brake;
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const { decision } = await decide({ targetDir: dir, runId: options.runId });
|
|
745
|
+
state.lastDecision = decision.action;
|
|
746
|
+
state.handoff = (loadConfig(dir).config.session?.handoff ?? "task") as HandoffGranularity;
|
|
747
|
+
|
|
748
|
+
if (decision.action === "stop" || decision.action === "wait") {
|
|
749
|
+
stopReason = decision.detail;
|
|
750
|
+
await closeWorker(decision.action === "stop" ? "run finished" : "run parked");
|
|
751
|
+
disarmRun(dir, decision.detail);
|
|
752
|
+
say(decision.reason === "complete" ? "good" : "warn", `run ${decision.action}: ${decision.detail}`);
|
|
753
|
+
try {
|
|
754
|
+
options.hooks?.onStop?.(decision.reason, decision.detail);
|
|
755
|
+
} catch {
|
|
756
|
+
/* host */
|
|
757
|
+
}
|
|
758
|
+
publish();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (decision.action === "approve") {
|
|
763
|
+
// A signature is a human boundary, so the worker is closed rather
|
|
764
|
+
// than left holding a context window open for however long the
|
|
765
|
+
// human takes to come back.
|
|
766
|
+
await closeWorker(`waiting for your signature on ${decision.phase.toUpperCase()}`);
|
|
767
|
+
state.unit = null;
|
|
768
|
+
publish();
|
|
769
|
+
say("warn", `${decision.phase.toUpperCase()} passed its gate and needs your signature — /infinity:approve`);
|
|
770
|
+
try {
|
|
771
|
+
options.hooks?.onApproval?.(decision.phase, decision.message);
|
|
772
|
+
} catch {
|
|
773
|
+
/* host */
|
|
774
|
+
}
|
|
775
|
+
await Promise.race([waitWhile(() => !stopping && stillAwaiting(dir), idleMs, 0), stopSignal]);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// -- continue / advanced: there is work, and someone has to do it ----
|
|
780
|
+
const unit = currentUnit(dir, options.baseModel);
|
|
781
|
+
if (!unit) {
|
|
782
|
+
stopReason = "nothing actionable and no phase to work on";
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
state.unit = unit;
|
|
786
|
+
|
|
787
|
+
if (decision.action === "advanced") {
|
|
788
|
+
say("good", `gate passed → ${decision.toPhase.toUpperCase()}`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// The unit boundary IS the handoff: a different unit means a different
|
|
792
|
+
// session and, because the model is chosen at spawn, a different model.
|
|
793
|
+
if (worker && workerView && workerView.unitKey !== unit.key) {
|
|
794
|
+
await closeWorker(`handoff — ${workerView.unitLabel} → ${unit.label}`);
|
|
795
|
+
}
|
|
796
|
+
// An escalation that names a stronger model is also a model boundary,
|
|
797
|
+
// and therefore also a new session: `set_model` mid-session would put
|
|
798
|
+
// the stronger model in front of the weaker one's failed reasoning,
|
|
799
|
+
// which is the context we are trying to get away from.
|
|
800
|
+
const escalated = decision.action === "continue" ? (decision.escalation ?? null) : null;
|
|
801
|
+
if (escalated?.model && worker && workerView && workerView.model !== escalated.model) {
|
|
802
|
+
await closeWorker(`escalating to ${escalated.model} (${escalated.strategy})`);
|
|
803
|
+
say("warn", `escalation: ${escalated.strategy} → ${escalated.model}`);
|
|
804
|
+
unit.model = escalated.model;
|
|
805
|
+
} else if (escalated?.model) {
|
|
806
|
+
unit.model = escalated.model;
|
|
807
|
+
}
|
|
808
|
+
// Context pressure inside a worker is the other reason to replace it,
|
|
809
|
+
// and it is the one that matters on a small model: a session that
|
|
810
|
+
// compacts has already lost the detail the next turn needed.
|
|
811
|
+
if (worker && workerView) {
|
|
812
|
+
const threshold = loadConfig(dir).config.session?.contextThreshold ?? 0;
|
|
813
|
+
const ratio = workerView.contextRatio;
|
|
814
|
+
if (threshold > 0 && typeof ratio === "number" && ratio >= threshold) {
|
|
815
|
+
await closeWorker(`context ${Math.round(ratio * 100)}% full — fresh session`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (!worker) {
|
|
820
|
+
const started = await openWorker(unit);
|
|
821
|
+
if (!started) {
|
|
822
|
+
stopReason = "could not start a background pi session";
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
if (!worker || !workerView) break;
|
|
827
|
+
|
|
828
|
+
// A fresh session needs the whole brief. A session that has been on
|
|
829
|
+
// this unit since the last cycle already has it, and re-sending it
|
|
830
|
+
// every cycle is the largest avoidable cost in a long run.
|
|
831
|
+
const returning = workerView.turns > 0;
|
|
832
|
+
const body = returning ? (decision.headline ?? decision.message) : decision.message;
|
|
833
|
+
const prompt = returning
|
|
834
|
+
? `${body}\n\n${WORKER_DIRECTIVE}`
|
|
835
|
+
: `${body}\n\n---\n\nYOUR REMIT: ${unit.scope}\n\n${WORKER_DIRECTIVE}`;
|
|
836
|
+
workerView.turns += 1;
|
|
837
|
+
workerView.state = "working";
|
|
838
|
+
publish();
|
|
839
|
+
const turn = await Promise.race([
|
|
840
|
+
worker.prompt(prompt, options.turnTimeoutMs),
|
|
841
|
+
stopSignal.then(
|
|
842
|
+
(): TurnLike => ({ summary: "", tools: [], usage: { inputTokens: 0, outputTokens: 0 }, contextRatio: null, aborted: true, error: null }),
|
|
843
|
+
),
|
|
844
|
+
]);
|
|
845
|
+
if (stopping) break;
|
|
846
|
+
if (workerView) {
|
|
847
|
+
workerView.contextRatio = turn.contextRatio;
|
|
848
|
+
workerView.state = "idle";
|
|
849
|
+
workerView.doing = short(turn.summary, 96) || "waiting for the gate";
|
|
850
|
+
publish();
|
|
851
|
+
}
|
|
852
|
+
if (turn.error) {
|
|
853
|
+
say("warn", `${workerView?.name ?? "worker"} — ${turn.error}`, workerView?.name ?? null);
|
|
854
|
+
// A worker that cannot take a prompt is not a worker. Replace it
|
|
855
|
+
// rather than asking a corpse the same question forever.
|
|
856
|
+
await closeWorker(turn.error);
|
|
857
|
+
} else if (turn.summary) {
|
|
858
|
+
say("info", short(turn.summary, 160), workerView?.name ?? null);
|
|
859
|
+
}
|
|
860
|
+
if (idleMs > 0) await Promise.race([sleep(idleMs), stopSignal]);
|
|
861
|
+
}
|
|
862
|
+
} catch (e) {
|
|
863
|
+
stopReason = `supervisor error: ${e instanceof Error ? e.message : String(e)}`;
|
|
864
|
+
say("error", stopReason);
|
|
865
|
+
disarmRun(dir, stopReason);
|
|
866
|
+
} finally {
|
|
867
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
868
|
+
heartbeat = null;
|
|
869
|
+
released = true;
|
|
870
|
+
await closeWorker(stopReason ?? "stopped");
|
|
871
|
+
state.status = "stopped";
|
|
872
|
+
state.stopReason = stopReason;
|
|
873
|
+
state.unit = null;
|
|
874
|
+
publish();
|
|
875
|
+
if (stopReason) say("info", `supervisor stopped — ${stopReason}`);
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
const done = loop();
|
|
880
|
+
|
|
881
|
+
return {
|
|
882
|
+
runId: options.runId,
|
|
883
|
+
isRunning: () => !stopping && state.status === "running",
|
|
884
|
+
async stop(reason = "stopped by the human"): Promise<void> {
|
|
885
|
+
if (stopping) {
|
|
886
|
+
await done;
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
stopping = true;
|
|
890
|
+
stopReason = stopReason ?? reason;
|
|
891
|
+
signalStop();
|
|
892
|
+
// Kill the child first: the loop may be parked on its turn, and that
|
|
893
|
+
// turn is the only thing keeping the loop from noticing the stop.
|
|
894
|
+
if (worker) {
|
|
895
|
+
try {
|
|
896
|
+
await worker.close();
|
|
897
|
+
} catch {
|
|
898
|
+
/* already gone */
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
await done;
|
|
902
|
+
},
|
|
903
|
+
done,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function stillAwaiting(targetDir: string): boolean {
|
|
908
|
+
try {
|
|
909
|
+
return Boolean(loadConfig(targetDir).config.awaitingApproval);
|
|
910
|
+
} catch {
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function sleep(ms: number): Promise<void> {
|
|
916
|
+
return new Promise((r) => {
|
|
917
|
+
const t = setTimeout(r, ms);
|
|
918
|
+
t.unref?.();
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Poll a predicate. `limit` of 0 means forever (until the supervisor stops). */
|
|
923
|
+
async function waitWhile(pred: () => boolean, everyMs: number, limit: number): Promise<void> {
|
|
924
|
+
let waited = 0;
|
|
925
|
+
while (pred()) {
|
|
926
|
+
await sleep(Math.max(200, everyMs));
|
|
927
|
+
waited += Math.max(200, everyMs);
|
|
928
|
+
if (limit > 0 && waited >= limit) return;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function sanitize(s: string): string {
|
|
933
|
+
return s.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 80) || "unit";
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Read-only view for the widget, the dashboard and `/infinity:workers`. */
|
|
937
|
+
export function supervisorView(targetDir: string): {
|
|
938
|
+
state: SupervisorState | null;
|
|
939
|
+
activity: ActivityLine[];
|
|
940
|
+
routerEnabled: boolean;
|
|
941
|
+
} {
|
|
942
|
+
return {
|
|
943
|
+
state: loadSupervisorState(targetDir),
|
|
944
|
+
activity: loadActivity(targetDir),
|
|
945
|
+
routerEnabled: (() => {
|
|
946
|
+
try {
|
|
947
|
+
return loadRouterConfig(targetDir).enabled;
|
|
948
|
+
} catch {
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
})(),
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
export type { LoopDecision };
|