infinity-harness 2.7.0 → 2.8.1
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 +14 -0
- package/extensions/infinity-harness/index.ts +212 -11
- package/harness/docs/ARCHITECTURE.md +13 -7
- package/harness/docs/CONSTRAINTS.md +13 -5
- package/harness/docs/DECISIONS.md +44 -0
- package/harness/docs/DOMAIN.md +44 -8
- package/package.json +1 -1
- package/src/core/config.ts +88 -1
- package/src/core/featureList.ts +85 -17
- package/src/core/gates.ts +8 -6
- package/src/core/init.ts +33 -3
- package/src/core/modelRouter.ts +149 -0
- package/src/core/paths.ts +29 -0
- package/src/core/plan.ts +39 -0
- package/src/core/runState.ts +151 -0
- package/src/core/settings.ts +130 -3
- package/src/core/types.ts +33 -0
- package/src/daemon/budget.ts +94 -0
- package/src/daemon/guard.ts +113 -0
- package/src/daemon/index.ts +421 -0
- package/src/daemon/isolation.ts +95 -0
- package/src/daemon/preflight.ts +132 -0
- package/src/daemon/server.ts +153 -0
- package/src/daemon/supervisorState.ts +83 -0
- package/src/daemon/worker.ts +239 -0
- package/src/daemon/worktree.ts +95 -0
- package/src/exec/piWorker.ts +1 -2
- package/src/goalState.ts +2 -22
- package/src/intake.ts +1 -1
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +3 -2
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +107 -134
- package/src/taskList.ts +41 -3
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +55 -0
- package/src/unstuck.ts +0 -0
- package/src/worker.ts +12 -8
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — daemon/index.ts
|
|
3
|
+
*
|
|
4
|
+
* Detached entry: owns the run, heartbeat, bounded stop.
|
|
5
|
+
* Spawned via: spawn(process.execPath, [daemonEntry, targetDir], { detached:true, stdio:["ignore", logFd, logFd], windowsHide:true, env:{..., WORKER_ENV:"1"} }) + child.unref().
|
|
6
|
+
* The extension captures ctx.model -> run.json.baseModel at arm time before spawning.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle: arm run.json -> preflight tiers -> run sequential units (one worker at a time v3.0)
|
|
9
|
+
* -> decideNext via Core loop, stream to supervisor.json+activity.json, gate, advance/steer/stop.
|
|
10
|
+
* Budget (token+cost+X tripwire), worker recycling on compaction, CredentialSynchronizationError handling.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
15
|
+
import { daemonPath, planPath, runStatePath } from "../core/paths.ts";
|
|
16
|
+
import { readJsonSafe, writeJsonAtomic, ensureDir } from "../core/fsx.ts";
|
|
17
|
+
import { loadConfig, saveConfig } from "../core/config.ts";
|
|
18
|
+
import { loadFeatureList } from "../core/featureList.ts";
|
|
19
|
+
import { loadRunState, saveRunState, disarmRun, type RunState } from "../core/runState.ts";
|
|
20
|
+
import { decideNext, fingerprint, type LoopDecision } from "../loop.ts";
|
|
21
|
+
import { buildBrief, renderBrief } from "../core/brief.ts";
|
|
22
|
+
import { runChecks } from "../core/gates.ts";
|
|
23
|
+
import { guardSingleOwner, isDaemonAlive, loadDaemon, writeDaemon, newDaemonInfo, startHeartbeat, clearDaemon, HEARTBEAT_MS } from "./guard.ts";
|
|
24
|
+
import { startServer, stopServer } from "./server.ts";
|
|
25
|
+
import { saveSupervisor, appendActivity, type SupervisorWorker } from "./supervisorState.ts";
|
|
26
|
+
import { runPreflight } from "./preflight.ts";
|
|
27
|
+
import { addUsageForTier, isCapExceeded, hasXLeak, xLeakReason, type Tier } from "./budget.ts";
|
|
28
|
+
import { createWorker, promptWorker, type TurnResult } from "./worker.ts";
|
|
29
|
+
import { routeModel, effectiveDifficultyForTask as effectiveDifficulty } from "../core/modelRouter.ts";
|
|
30
|
+
import { dirname } from "node:path";
|
|
31
|
+
import type { Server } from "node:http";
|
|
32
|
+
|
|
33
|
+
export const WORKER_ENV = "INFINITY_HARNESS_WORKER";
|
|
34
|
+
|
|
35
|
+
let stopping = false;
|
|
36
|
+
let heartbeatStop: (() => void) | null = null;
|
|
37
|
+
let server: Server | null = null;
|
|
38
|
+
|
|
39
|
+
async function main(): Promise<void> {
|
|
40
|
+
const targetDir = process.argv[2] ?? process.cwd();
|
|
41
|
+
const logPath = resolve(targetDir, "harness", "daemon.log");
|
|
42
|
+
try { ensureDir(dirname(logPath)); } catch {}
|
|
43
|
+
|
|
44
|
+
// Guard: single owner
|
|
45
|
+
const existing = guardSingleOwner(targetDir);
|
|
46
|
+
if (existing && isDaemonAlive(existing)) {
|
|
47
|
+
console.error(`infinity-harness daemon already running pid ${existing.pid}`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const runState = loadRunState(targetDir);
|
|
52
|
+
if (!runState?.armed) {
|
|
53
|
+
console.error("no armed run — run.json is not armed");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
if (!runState.baseModel) {
|
|
57
|
+
console.error("no baseModel in run.json — extension must capture ctx.model at arm time");
|
|
58
|
+
// Refuse to arm: this is the path that silently used pi's default (the X leak).
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Preflight distinct tiers
|
|
63
|
+
const { loadConfig: _loadConfig } = await import("../core/config.ts");
|
|
64
|
+
const cfg = _loadConfig(targetDir).config;
|
|
65
|
+
const tiersRaw = (cfg as unknown as { tiers?: Record<string, { provider: string; id: string }> }).tiers ?? {};
|
|
66
|
+
const hasTier = Object.keys(tiersRaw).length > 0;
|
|
67
|
+
if (hasTier) {
|
|
68
|
+
const pre = await runPreflight({ targetDir, tiers: tiersRaw as never });
|
|
69
|
+
if (pre.blocked) {
|
|
70
|
+
console.error(`tier preflight failed: ${pre.blocked.tier} ${pre.blocked.reason}`);
|
|
71
|
+
// Record to run.json and stop
|
|
72
|
+
const rs = loadRunState(targetDir);
|
|
73
|
+
if (rs) {
|
|
74
|
+
rs.tiers = pre.tierResults;
|
|
75
|
+
saveRunState(targetDir, rs);
|
|
76
|
+
}
|
|
77
|
+
appendActivity(targetDir, { level: "error", worker: null, text: `preflight failed ${pre.blocked.tier}: ${pre.blocked.reason}` });
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
if (runState) {
|
|
81
|
+
runState.tiers = pre.tierResults;
|
|
82
|
+
saveRunState(targetDir, runState);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Start server (port 0, token from run)
|
|
87
|
+
const { server: srv, port, token } = await startServer({
|
|
88
|
+
targetDir,
|
|
89
|
+
onHalt: async () => { await boundedStop(targetDir, "halted by user"); return { ok: true }; },
|
|
90
|
+
onRun: async () => ({ ok: true, daemon: loadDaemon(targetDir) }),
|
|
91
|
+
onApprove: async (body) => {
|
|
92
|
+
try {
|
|
93
|
+
const note = typeof (body as { note?: unknown })?.note === "string" ? String((body as { note: string }).note) : "";
|
|
94
|
+
const { resolveApproval: _resolveApproval } = await import("../approval.ts");
|
|
95
|
+
await _resolveApproval(targetDir, note || true as unknown as string);
|
|
96
|
+
appendActivity(targetDir, { level: "good", worker: null, text: `approved: ${note || "(no note)"}` });
|
|
97
|
+
} catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; }
|
|
98
|
+
return { ok: true };
|
|
99
|
+
},
|
|
100
|
+
onReplan: async (body) => {
|
|
101
|
+
try {
|
|
102
|
+
const b = body as { reason?: string; addFeatures?: Array<{ id: string; name: string }>; addTasks?: Array<{ featureId: string; task: unknown }> };
|
|
103
|
+
const { amendPlan: _amendPlan } = await import("../replan.ts");
|
|
104
|
+
const r = await _amendPlan({ projectDir: targetDir, reason: typeof b.reason === "string" ? b.reason : undefined, addFeatures: b.addFeatures as never, addTasks: b.addTasks as never });
|
|
105
|
+
appendActivity(targetDir, { level: "info", worker: null, text: `replan +${r.added.features}f +${r.added.tasks}t rev ${r.baseRevision}` });
|
|
106
|
+
return { ok: true, ...r };
|
|
107
|
+
} catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; }
|
|
108
|
+
},
|
|
109
|
+
onRework: async (body) => {
|
|
110
|
+
try {
|
|
111
|
+
const b = body as { task?: string; key?: string; reason?: string };
|
|
112
|
+
const needle = String(b.task ?? b.key ?? "").trim();
|
|
113
|
+
if (!needle) return { ok: false, error: "task required" };
|
|
114
|
+
const { flattenTasks } = await import("../core/featureList.ts");
|
|
115
|
+
const list = loadFeatureList(targetDir).list;
|
|
116
|
+
const target = flattenTasks(list).find(t => t.compositeKey === needle || t.key === needle || t.id === needle);
|
|
117
|
+
if (!target) return { ok: false, error: `no task ${needle}` };
|
|
118
|
+
const { startRework: _startRework } = await import("../rework.ts");
|
|
119
|
+
const rs = loadRunState(targetDir);
|
|
120
|
+
const runId = rs?.runId ?? "daemon";
|
|
121
|
+
const res = await _startRework({ projectDir: targetDir, featureId: target.featureId, taskId: target.id, key: target.key, reason: typeof b.reason === "string" ? b.reason : "rework via daemon", runId });
|
|
122
|
+
appendActivity(targetDir, { level: "warn", worker: null, text: `rework ${target.compositeKey} → ${res.impacted.length} deps rev ${res.baseRevision}` });
|
|
123
|
+
return { ok: true, ...res };
|
|
124
|
+
} catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; }
|
|
125
|
+
},
|
|
126
|
+
onPilot: async (body) => {
|
|
127
|
+
try {
|
|
128
|
+
const b = body as { pilot?: string };
|
|
129
|
+
const p = String(b.pilot ?? "").trim().toLowerCase();
|
|
130
|
+
if (!["copilot","autopilot","full"].includes(p)) return { ok: false, error: `pilot must be copilot|autopilot|full, got ${JSON.stringify(b.pilot)}` };
|
|
131
|
+
const { loadConfig: _lc, saveConfig: _sc } = await import("../core/config.ts");
|
|
132
|
+
const { applyPilotPreset: _app } = await import("../core/config.ts");
|
|
133
|
+
const { withLock: _wl } = await import("../core/lock.ts");
|
|
134
|
+
const { configPath: _cp } = await import("../core/paths.ts");
|
|
135
|
+
await (_wl as unknown as (path: string, fn: ()=>unknown)=>Promise<unknown>)(_cp(targetDir), () => {
|
|
136
|
+
const l = _lc(targetDir);
|
|
137
|
+
if (!l.ok) throw new Error(l.error ?? "cannot load config");
|
|
138
|
+
(l.config as unknown as { pilot: string }).pilot = p;
|
|
139
|
+
_app(l.config as Parameters<typeof _app>[0], p as "copilot"|"autopilot"|"full");
|
|
140
|
+
const ok = _sc(targetDir, l.config).ok;
|
|
141
|
+
if (!ok) throw new Error("cannot save config");
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
appendActivity(targetDir, { level: "info", worker: null, text: `pilot → ${p}` });
|
|
145
|
+
return { ok: true, pilot: p };
|
|
146
|
+
} catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; }
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
server = srv;
|
|
150
|
+
|
|
151
|
+
const info = newDaemonInfo(runState.runId, port);
|
|
152
|
+
// Preserve token from server (which may have generated one if daemon.json absent)
|
|
153
|
+
(info as unknown as { token: string }).token = token || (info as unknown as { token: string }).token;
|
|
154
|
+
writeDaemon(targetDir, info);
|
|
155
|
+
heartbeatStop = startHeartbeat(targetDir);
|
|
156
|
+
|
|
157
|
+
const onSignal = async (sig: string): Promise<void> => {
|
|
158
|
+
if (stopping) return;
|
|
159
|
+
stopping = true;
|
|
160
|
+
console.log(`daemon signal ${sig}, stopping`);
|
|
161
|
+
await boundedStop(targetDir, `signal ${sig}`);
|
|
162
|
+
process.exit(0);
|
|
163
|
+
};
|
|
164
|
+
process.on("SIGTERM", () => void onSignal("SIGTERM"));
|
|
165
|
+
process.on("SIGINT", () => void onSignal("SIGINT"));
|
|
166
|
+
|
|
167
|
+
// Main loop: one unit at a time (v3.0 sequential). Continuous handoff in autopilot/full.
|
|
168
|
+
await runLoop(targetDir);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function boundedStop(targetDir: string, reason: string): Promise<void> {
|
|
172
|
+
stopping = true;
|
|
173
|
+
if (heartbeatStop) try { heartbeatStop(); } catch {}
|
|
174
|
+
if (server) try { await stopServer(server); } catch {}
|
|
175
|
+
server = null;
|
|
176
|
+
try { disarmRun(targetDir, reason); } catch {}
|
|
177
|
+
try { clearDaemon(targetDir); } catch {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function runLoop(targetDir: string): Promise<void> {
|
|
181
|
+
let iterations = 0;
|
|
182
|
+
const maxRecycles = (() => { try { const c = loadConfig(targetDir).config as unknown as { limits?: { maxRecycles?: number } }; return c.limits?.maxRecycles ?? 2; } catch { return 2; } })();
|
|
183
|
+
const recycleCount = new Map<string, number>();
|
|
184
|
+
|
|
185
|
+
// Outer run loop: decideNext -> prompt worker (or general A work) -> gate -> advance/steer/stop
|
|
186
|
+
while (!stopping) {
|
|
187
|
+
const runState = loadRunState(targetDir);
|
|
188
|
+
if (!runState?.armed) { await boundedStop(targetDir, runState?.stopReason ?? "disarmed"); break; }
|
|
189
|
+
|
|
190
|
+
// Budget caps (token/cost) and X tripwire
|
|
191
|
+
try {
|
|
192
|
+
const capCheck = isCapExceeded(runState.budget ?? { byTier: {}, cap: {} });
|
|
193
|
+
if (capCheck.exceeded) { appendActivity(targetDir, { level: "warn", worker: null, text: capCheck.reason ?? "cap exceeded" }); await boundedStop(targetDir, capCheck.reason ?? "cap exceeded"); break; }
|
|
194
|
+
// X leak is a defect signal, not a budget — check it regardless of cap
|
|
195
|
+
const hasConsultWorker = false; // v3.0 sequential: no consultation parallel workers yet
|
|
196
|
+
const { hasXLeak: _hasXLeak } = await import("./budget.ts");
|
|
197
|
+
if (_hasXLeak(runState.budget ?? { byTier: {}, cap: {} }, hasConsultWorker)) {
|
|
198
|
+
const reason = xLeakReason(runState.budget ?? { byTier: {}, cap: {} });
|
|
199
|
+
appendActivity(targetDir, { level: "error", worker: null, text: reason });
|
|
200
|
+
await boundedStop(targetDir, reason);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
} catch {}
|
|
204
|
+
|
|
205
|
+
const decision = await decideNext({ targetDir, runId: runState.runId, skipGate: false });
|
|
206
|
+
const action = decision.decision.action as string;
|
|
207
|
+
|
|
208
|
+
if (action === "stop") {
|
|
209
|
+
const d = decision.decision as Extract<LoopDecision, { action: "stop" }>;
|
|
210
|
+
appendActivity(targetDir, { level: "info", worker: null, text: d.detail ?? d.reason });
|
|
211
|
+
await boundedStop(targetDir, d.detail ?? d.reason);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
if (action === "wait") {
|
|
215
|
+
const d = decision.decision as Extract<LoopDecision, { action: "wait" }>;
|
|
216
|
+
appendActivity(targetDir, { level: "info", worker: null, text: d.detail ?? d.reason });
|
|
217
|
+
// Paused / awaiting approval — keep daemon alive but idle; poll.
|
|
218
|
+
await new Promise(r => setTimeout(r, 5_000));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (action === "approve") {
|
|
222
|
+
const d = decision.decision as Extract<LoopDecision, { action: "approve" }>;
|
|
223
|
+
appendActivity(targetDir, { level: "warn", worker: null, text: `awaiting approval: ${d.phase} — /infinity:approve to continue` });
|
|
224
|
+
saveSupervisor(targetDir, { runId: runState.runId, updatedAt: new Date().toISOString(), worker: null });
|
|
225
|
+
await new Promise(r => setTimeout(r, 5_000));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// continue / advanced both have a brief to work. We need to determine unit + routing.
|
|
229
|
+
const brief = await buildBrief(targetDir);
|
|
230
|
+
const list = loadFeatureList(targetDir).list;
|
|
231
|
+
const phase = brief.phase ?? loadConfig(targetDir).config.currentPhase;
|
|
232
|
+
// Derive unit: prefer current task, else nextActionableTask
|
|
233
|
+
let unitKey: string | null = brief.task?.key ?? brief.task?.id ?? null;
|
|
234
|
+
let difficulty: string | undefined = undefined;
|
|
235
|
+
let tierSpec: { provider: string; id: string; thinkingLevel?: string } | null = null;
|
|
236
|
+
try {
|
|
237
|
+
if (unitKey) {
|
|
238
|
+
const diff = effectiveDifficulty(list, unitKey, (loadConfig(targetDir).config.session as { handoff: string }).handoff) as string | undefined;
|
|
239
|
+
difficulty = diff;
|
|
240
|
+
}
|
|
241
|
+
} catch {}
|
|
242
|
+
// Fallback: use phase+feature hints when no task
|
|
243
|
+
const cfgForRouting = loadConfig(targetDir).config;
|
|
244
|
+
const tiers = (cfgForRouting as unknown as { tiers?: Record<string, { provider: string; id: string; thinkingLevel?: string }> }).tiers ?? {};
|
|
245
|
+
// General work (no unit) -> A
|
|
246
|
+
let askedTier: Tier = (difficulty ? (difficulty === "difficult" ? "D" : difficulty === "moderate" ? "C" : "B") : "A") as Tier;
|
|
247
|
+
let routed: { provider: string; id: string } | null = null;
|
|
248
|
+
if (askedTier && (tiers as Record<string, { provider: string; id: string }>)[askedTier]) {
|
|
249
|
+
tierSpec = (tiers as Record<string, { provider: string; id: string }>) [askedTier] as never;
|
|
250
|
+
routed = tierSpec as { provider: string; id: string };
|
|
251
|
+
}
|
|
252
|
+
// Fallback to baseModel when tier slot empty
|
|
253
|
+
if (!routed) {
|
|
254
|
+
const bm = runState.baseModel;
|
|
255
|
+
if (bm) routed = { provider: bm.provider, id: bm.id };
|
|
256
|
+
}
|
|
257
|
+
if (!routed) {
|
|
258
|
+
appendActivity(targetDir, { level: "error", worker: null, text: `no model for tier ${askedTier}: set config.tiers or baseModel` });
|
|
259
|
+
await new Promise(r => setTimeout(r, 2_000));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const askedModel = `${routed.provider}/${routed.id}`;
|
|
263
|
+
|
|
264
|
+
// Start one SDK worker for this unit (or general A work when unitKey null)
|
|
265
|
+
const workerLabel = unitKey ?? `_phase-${phase ?? "general"}`;
|
|
266
|
+
saveSupervisor(targetDir, {
|
|
267
|
+
runId: runState.runId,
|
|
268
|
+
updatedAt: new Date().toISOString(),
|
|
269
|
+
worker: {
|
|
270
|
+
name: "W1",
|
|
271
|
+
unitKey: workerLabel,
|
|
272
|
+
unitLabel: brief.task?.description ?? brief.feature?.name ?? String(phase ?? "general"),
|
|
273
|
+
level: brief.task ? "task" : "phase",
|
|
274
|
+
difficulty: difficulty ?? null,
|
|
275
|
+
model: askedModel,
|
|
276
|
+
askedModel,
|
|
277
|
+
servedModel: null,
|
|
278
|
+
thinking: (tierSpec as { thinkingLevel?: string } | null)?.thinkingLevel ?? "medium",
|
|
279
|
+
state: "starting",
|
|
280
|
+
doing: "starting",
|
|
281
|
+
startedAt: new Date().toISOString(),
|
|
282
|
+
turns: 0,
|
|
283
|
+
recycles: recycleCount.get(workerLabel) ?? 0,
|
|
284
|
+
tokens: { input: 0, output: 0 },
|
|
285
|
+
contextRatio: null,
|
|
286
|
+
sessionId: null,
|
|
287
|
+
} as SupervisorWorker,
|
|
288
|
+
});
|
|
289
|
+
appendActivity(targetDir, { level: "work", worker: "W1", text: `working ${workerLabel} on ${askedModel}` });
|
|
290
|
+
|
|
291
|
+
const briefMarkdown = renderBrief(brief, cfgForRouting);
|
|
292
|
+
let recycles = recycleCount.get(workerLabel) ?? 0;
|
|
293
|
+
|
|
294
|
+
// Worker session lifecycle with recycle on compaction
|
|
295
|
+
let turn: TurnResult | null = null;
|
|
296
|
+
let workerHandle: Awaited<ReturnType<typeof createWorker>> | null = null;
|
|
297
|
+
try {
|
|
298
|
+
const workerFactory = await import("./worker.ts");
|
|
299
|
+
workerHandle = await workerFactory.createWorker({
|
|
300
|
+
cwd: targetDir,
|
|
301
|
+
modelSpec: { provider: routed.provider, id: routed.id, thinkingLevel: (tierSpec as { thinkingLevel?: string } | null)?.thinkingLevel ?? "medium" },
|
|
302
|
+
askedModel,
|
|
303
|
+
sessionManagerDir: `harness/sessions/${workerLabel.replace(/[^a-z0-9._-]/gi, "-")}`,
|
|
304
|
+
customTools: (await import("./isolation.ts")).harnessToolsForWorker() as unknown[],
|
|
305
|
+
});
|
|
306
|
+
// Capture served model lazily from events after prompt
|
|
307
|
+
turn = await workerFactory.promptWorker(workerHandle as unknown as never, { text: briefMarkdown, timeoutMs: (cfgForRouting as unknown as { limits?: { unitWallClockMs?: number } }).limits?.unitWallClockMs ?? 30*60*1000 });
|
|
308
|
+
} catch (e: unknown) {
|
|
309
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
310
|
+
const isCredentialSync = msg.includes("CredentialSynchronizationError") || (e as { name?: string })?.name === "CredentialSynchronizationError";
|
|
311
|
+
if (isCredentialSync) {
|
|
312
|
+
appendActivity(targetDir, { level: "warn", worker: "W1", text: `credential sync error — retrying once: ${msg}` });
|
|
313
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
314
|
+
// Retry once
|
|
315
|
+
try {
|
|
316
|
+
const workerFactory = await import("./worker.ts");
|
|
317
|
+
if (!workerHandle) {
|
|
318
|
+
workerHandle = await workerFactory.createWorker({
|
|
319
|
+
cwd: targetDir,
|
|
320
|
+
modelSpec: { provider: routed.provider, id: routed.id, thinkingLevel: (tierSpec as { thinkingLevel?: string } | null)?.thinkingLevel ?? "medium" },
|
|
321
|
+
askedModel,
|
|
322
|
+
sessionManagerDir: `harness/sessions/${workerLabel.replace(/[^a-z0-9._-]/gi, "-")}`,
|
|
323
|
+
customTools: (await import("./isolation.ts")).harnessToolsForWorker() as unknown[],
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
turn = await workerFactory.promptWorker(workerHandle as unknown as never, { text: briefMarkdown, timeoutMs: (cfgForRouting as unknown as { limits?: { unitWallClockMs?: number } }).limits?.unitWallClockMs ?? 30*60*1000 });
|
|
327
|
+
} catch (e2) {
|
|
328
|
+
appendActivity(targetDir, { level: "error", worker: "W1", text: `credential sync retry failed: ${e2 instanceof Error ? e2.message : String(e2)}` });
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
appendActivity(targetDir, { level: "error", worker: "W1", text: `worker failed: ${msg}` });
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
if (workerHandle) { try { workerHandle.dispose(); } catch {} }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!turn) {
|
|
338
|
+
// Worker never produced a turn — treat as non-event, re-brief next loop.
|
|
339
|
+
await new Promise(r => setTimeout(r, 1_000));
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Record asked vs served, usage, tools
|
|
344
|
+
const asked = askedModel;
|
|
345
|
+
const served = turn.servedModel ?? turn.askedModel ?? asked;
|
|
346
|
+
if (served && served !== asked) {
|
|
347
|
+
appendActivity(targetDir, { level: "warn", worker: "W1", text: `asked ${asked} but ${served} answered` });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Budget accounting (per-tier, last reading IS total per session — we add it as one session's spend)
|
|
351
|
+
try {
|
|
352
|
+
const rs = loadRunState(targetDir);
|
|
353
|
+
if (rs) {
|
|
354
|
+
const tier: Tier = askedTier;
|
|
355
|
+
const inc = { input: turn.usage?.input ?? 0, output: turn.usage?.output ?? 0, cacheRead: turn.usage?.cacheRead ?? 0, cacheWrite: turn.usage?.cacheWrite ?? 0, cost: turn.usage?.cost ?? 0, calls: 1 };
|
|
356
|
+
addUsageForTier(rs.budget ?? { byTier: {}, cap: {} }, tier, inc);
|
|
357
|
+
rs.budget.byTier = (rs.budget as { byTier: Record<string, unknown> }).byTier as never;
|
|
358
|
+
saveRunState(targetDir, rs);
|
|
359
|
+
const capCheck = isCapExceeded(rs.budget as never);
|
|
360
|
+
if (capCheck.exceeded) { appendActivity(targetDir, { level: "warn", worker: "W1", text: capCheck.reason ?? "cap exceeded" }); await boundedStop(targetDir, capCheck.reason ?? "cap exceeded"); break; }
|
|
361
|
+
const hasConsult = false;
|
|
362
|
+
const { hasXLeak: _hasXLeak } = await import("./budget.ts");
|
|
363
|
+
if (_hasXLeak(rs.budget as never, hasConsult)) { await boundedStop(targetDir, xLeakReason(rs.budget as never)); break; }
|
|
364
|
+
}
|
|
365
|
+
} catch {}
|
|
366
|
+
|
|
367
|
+
// Compaction => recycle the worker (fresh session, same unit, brief from disk)
|
|
368
|
+
if (turn.compacted) {
|
|
369
|
+
recycles += 1;
|
|
370
|
+
recycleCount.set(workerLabel, recycles);
|
|
371
|
+
appendActivity(targetDir, { level: "warn", worker: "W1", text: `compaction observed on ${workerLabel} — recycling (${recycles}/${maxRecycles})` });
|
|
372
|
+
if (recycles > maxRecycles) {
|
|
373
|
+
appendActivity(targetDir, { level: "error", worker: "W1", text: `maxRecycles exceeded for ${workerLabel} — stopping` });
|
|
374
|
+
await boundedStop(targetDir, `maxRecycles exceeded for ${workerLabel}`);
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
// Dispose and re-loop the same unit with a fresh session (brief from disk, not transcript).
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Zero-tool-calls settle => re-brief once, then it feeds no-progress fingerprint
|
|
382
|
+
if (!turn.tools || turn.tools.length === 0) {
|
|
383
|
+
appendActivity(targetDir, { level: "warn", worker: "W1", text: `worker settled with no tool calls — ${turn.summary?.slice(0,120) ?? "(no summary)"}` });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Run gate to decide next action (decideNext will run it again next loop, but we can steer immediately on FAIL).
|
|
387
|
+
// For now let decideNext be the referee: the loop top will call decideNext again and pick continue/advance/stop.
|
|
388
|
+
// Update supervisor with served/usage
|
|
389
|
+
try {
|
|
390
|
+
const sup = (await import("./supervisorState.ts")).loadSupervisor(targetDir);
|
|
391
|
+
if (sup?.worker) {
|
|
392
|
+
sup.worker.servedModel = served;
|
|
393
|
+
sup.worker.tokens = { input: turn.usage?.input ?? 0, output: turn.usage?.output ?? 0, cacheRead: turn.usage?.cacheRead ?? 0, cacheWrite: turn.usage?.cacheWrite ?? 0, cost: turn.usage?.cost ?? 0, calls: (sup.worker.tokens as { calls?: number })?.calls ?? 1 } as never;
|
|
394
|
+
sup.worker.turns = (sup.worker.turns ?? 0) + 1;
|
|
395
|
+
(await import("./supervisorState.ts")).saveSupervisor(targetDir, sup);
|
|
396
|
+
}
|
|
397
|
+
} catch {}
|
|
398
|
+
|
|
399
|
+
// If loop wants to steer on FAIL, it will do so next iteration. We just close the worker (handoff).
|
|
400
|
+
// One worker = one unit = one session; closing it is the handoff.
|
|
401
|
+
iterations += 1;
|
|
402
|
+
if (iterations > 10_000) { await boundedStop(targetDir, "iteration guard"); break; }
|
|
403
|
+
|
|
404
|
+
// Small yield to keep dashboard/heartbeat flowing
|
|
405
|
+
await new Promise(r => setTimeout(r, 200));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
await boundedStop(targetDir, "loop ended");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Only run when executed as the daemon entry (not when imported).
|
|
412
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname) || process.env[WORKER_ENV] !== "1") {
|
|
413
|
+
// Allow both: `node dist/daemon/index.js <dir>` or `node src/daemon/index.ts <dir>` with --experimental-strip-types.
|
|
414
|
+
// Detect daemon entry by checking argv[2] is a directory with harness.
|
|
415
|
+
const arg = process.argv[2];
|
|
416
|
+
if (arg && existsSync(resolve(arg, "harness"))) {
|
|
417
|
+
main().catch(e => { console.error(e); process.exit(1); });
|
|
418
|
+
} else if (!arg) {
|
|
419
|
+
// In WSL/tests the daemon is not started automatically.
|
|
420
|
+
}
|
|
421
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — daemon/isolation.ts
|
|
3
|
+
*
|
|
4
|
+
* Worker isolation: a worker session must not load the harness extension into
|
|
5
|
+
* the Daemon's process, and must receive the harness tools as customTools.
|
|
6
|
+
*
|
|
7
|
+
* The SDK loads discovered extensions by default (DefaultResourceLoader with
|
|
8
|
+
* reload()). infinity-harness IS an installed pi extension, so a default
|
|
9
|
+
* loader would load it inside the Daemon and each worker would register
|
|
10
|
+
* session_start, commands and widgets — sharing module state and driving runs.
|
|
11
|
+
*
|
|
12
|
+
* This module produces:
|
|
13
|
+
* - a harness-free ResourceLoader (noExtensions, noSkills where appropriate)
|
|
14
|
+
* - the harness ToolDefinitions to hand to workers as customTools
|
|
15
|
+
* - a helper to assert isolation in tests
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ResourceLoader, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
export type IsolationOpts = {
|
|
21
|
+
cwd: string;
|
|
22
|
+
agentDir?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type HarnessedLoader = ResourceLoader;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a ResourceLoader that WILL NOT discover the harness extension.
|
|
29
|
+
* Workers receive this as `resourceLoader`.
|
|
30
|
+
*/
|
|
31
|
+
export async function createIsolatedLoader(opts: IsolationOpts): Promise<HarnessedLoader> {
|
|
32
|
+
const { DefaultResourceLoader, SettingsManager } = await import("@earendil-works/pi-coding-agent");
|
|
33
|
+
const settingsManager = SettingsManager.create(opts.cwd, opts.agentDir);
|
|
34
|
+
const loader = new DefaultResourceLoader({
|
|
35
|
+
cwd: opts.cwd,
|
|
36
|
+
agentDir: opts.agentDir ?? "",
|
|
37
|
+
settingsManager,
|
|
38
|
+
noExtensions: true,
|
|
39
|
+
noSkills: true,
|
|
40
|
+
noContextFiles: true,
|
|
41
|
+
} as unknown as ConstructorParameters<typeof DefaultResourceLoader>[0]);
|
|
42
|
+
await loader.reload();
|
|
43
|
+
return loader as HarnessedLoader;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Minimal harness tools that a worker needs to function.
|
|
48
|
+
* Daemon hands these as `customTools` so the worker's ability to record work
|
|
49
|
+
* is declared, not discovered. A worker that cannot record is a worker whose
|
|
50
|
+
* unit loops forever.
|
|
51
|
+
*/
|
|
52
|
+
export function harnessToolsForWorker(): ToolDefinition[] {
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
name: "infinity_plan",
|
|
56
|
+
description: "Atomic plan editor: submit the full task list. Omission=deletion, baseRevision guard, cycle/missingDep checks.",
|
|
57
|
+
parameters: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
baseRevision: { type: "number" },
|
|
61
|
+
tasks: { type: "array", items: { type: "object" } },
|
|
62
|
+
features: { type: "array", items: { type: "object" } },
|
|
63
|
+
goal: { type: "string" },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
handler: async () => ({ content: [{ type: "text", text: "infinity_plan stub — Daemon replaces this handler" }] }),
|
|
67
|
+
} as unknown as ToolDefinition,
|
|
68
|
+
{
|
|
69
|
+
name: "infinity_validate",
|
|
70
|
+
description: "Run the deterministic gate; model never decides PASS/FAIL.",
|
|
71
|
+
parameters: { type: "object", properties: {} },
|
|
72
|
+
handler: async () => ({ content: [{ type: "text", text: "infinity_validate stub" }] }),
|
|
73
|
+
} as unknown as ToolDefinition,
|
|
74
|
+
{
|
|
75
|
+
name: "infinity_brief",
|
|
76
|
+
description: "Return the rendered brief for the next unit.",
|
|
77
|
+
parameters: { type: "object", properties: {} },
|
|
78
|
+
handler: async () => ({ content: [{ type: "text", text: "infinity_brief stub" }] }),
|
|
79
|
+
} as unknown as ToolDefinition,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Test assertion: a worker session's loader has zero harness extension instances.
|
|
85
|
+
* The session's extensionsResult captures what was loaded; we count any factory
|
|
86
|
+
* whose id contains "infinity-harness".
|
|
87
|
+
*/
|
|
88
|
+
export function assertZeroHarnessExtensions(extensionsResult: unknown): void {
|
|
89
|
+
const result = extensionsResult as { extensions?: Array<{ id?: string; name?: string }> } | null | undefined;
|
|
90
|
+
const list = result?.extensions ?? [];
|
|
91
|
+
const found = list.filter((e) => String(e?.id ?? e?.name ?? "").includes("infinity-harness"));
|
|
92
|
+
if (found.length !== 0) {
|
|
93
|
+
throw new Error(`isolation violated: worker loaded ${found.length} harness extension(s): ${found.map(f => f.id ?? f.name).join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — daemon/preflight.ts
|
|
3
|
+
*
|
|
4
|
+
* Tier preflight: at arm time, prove each configured tier A/B/C/D/X can serve.
|
|
5
|
+
* getModel() and getAvailable() are registry checks, not auth checks. Only a
|
|
6
|
+
* real call proves a tier serves. A tier that fails preflight blocks arming
|
|
7
|
+
* (naming the tier and reason) — not a warning buried in a log.
|
|
8
|
+
*
|
|
9
|
+
* Distinct tiers are probed once. Probe uses SessionManager.inMemory() + a
|
|
10
|
+
* one-token prompt on a throwaway session with noTools.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { TierId, TierSpec, HarnessConfig } from "../core/types.ts";
|
|
14
|
+
import type { TierResults, TierPreflight } from "../core/runState.ts";
|
|
15
|
+
import { loadConfig } from "../core/config.ts";
|
|
16
|
+
|
|
17
|
+
export type PreflightResult = { tier: TierId; ok: boolean; servedModel?: string; reason?: string };
|
|
18
|
+
|
|
19
|
+
export type PreflightOpts = {
|
|
20
|
+
targetDir: string;
|
|
21
|
+
tiers?: Partial<Record<TierId, TierSpec>>;
|
|
22
|
+
/** For tests: inject a probe fn instead of doing the real SDK call. */
|
|
23
|
+
probe?: (spec: TierSpec) => Promise<{ served: string }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function tiersFromConfig(config: HarnessConfig): Partial<Record<TierId, TierSpec>> {
|
|
27
|
+
const t = (config as unknown as { tiers?: Partial<Record<TierId, TierSpec>> }).tiers;
|
|
28
|
+
return t && typeof t === "object" && !Array.isArray(t) ? t : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dedupeSpecs(tiers: Partial<Record<TierId, TierSpec>>): Map<string, { tiers: TierId[]; spec: TierSpec }> {
|
|
32
|
+
const byKey = new Map<string, { tiers: TierId[]; spec: TierSpec }>();
|
|
33
|
+
for (const [tier, spec] of Object.entries(tiers) as Array<[TierId, TierSpec]>) {
|
|
34
|
+
if (!spec?.provider || !spec?.id) continue;
|
|
35
|
+
const key = `${spec.provider}/${spec.id}`;
|
|
36
|
+
const entry = byKey.get(key);
|
|
37
|
+
if (entry) entry.tiers.push(tier);
|
|
38
|
+
else byKey.set(key, { tiers: [tier], spec });
|
|
39
|
+
}
|
|
40
|
+
return byKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function runPreflight(opts: PreflightOpts): Promise<{ results: PreflightResult[]; tierResults: TierResults; blocked: PreflightResult | null }> {
|
|
44
|
+
const configTiers = opts.tiers ?? tiersFromConfig(loadConfig(opts.targetDir).config);
|
|
45
|
+
const unique = dedupeSpecs(configTiers);
|
|
46
|
+
const results: PreflightResult[] = [];
|
|
47
|
+
const tierResults: TierResults = {};
|
|
48
|
+
|
|
49
|
+
if (unique.size === 0) {
|
|
50
|
+
return { results: [], tierResults: {}, blocked: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [key, entry] of unique) {
|
|
54
|
+
let ok = false;
|
|
55
|
+
let servedModel: string | undefined;
|
|
56
|
+
let reason: string | undefined;
|
|
57
|
+
try {
|
|
58
|
+
if (opts.probe) {
|
|
59
|
+
const r = await opts.probe(entry.spec);
|
|
60
|
+
servedModel = r.served || key;
|
|
61
|
+
ok = true;
|
|
62
|
+
} else {
|
|
63
|
+
// Real probe (SDK).
|
|
64
|
+
const { ModelRuntime, SessionManager, createAgentSession, DefaultResourceLoader, SettingsManager } = await import("@earendil-works/pi-coding-agent");
|
|
65
|
+
const runtime = await ModelRuntime.create();
|
|
66
|
+
// getModel is a registry lookup; hasConfiguredAuth guards the credential existence.
|
|
67
|
+
const model = runtime.getModel(entry.spec.provider, entry.spec.id);
|
|
68
|
+
if (!model) throw new Error(`unknown model ${key}`);
|
|
69
|
+
const hasAuth = typeof runtime.hasConfiguredAuth === "function" ? runtime.hasConfiguredAuth(entry.spec.provider) : true;
|
|
70
|
+
if (!hasAuth) {
|
|
71
|
+
let check: unknown = undefined;
|
|
72
|
+
try { check = typeof runtime.checkAuth === "function" ? await runtime.checkAuth(entry.spec.provider) : hasAuth; } catch { check = undefined; }
|
|
73
|
+
if (!check) throw new Error(`no credential for provider ${entry.spec.provider}`);
|
|
74
|
+
}
|
|
75
|
+
// Minimal prompt on an in-memory session.
|
|
76
|
+
const { resolve } = await import("node:path");
|
|
77
|
+
const cwd = opts.targetDir;
|
|
78
|
+
const agentDir = "";
|
|
79
|
+
const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
80
|
+
const loader = new DefaultResourceLoader({
|
|
81
|
+
cwd,
|
|
82
|
+
agentDir,
|
|
83
|
+
settingsManager,
|
|
84
|
+
noExtensions: true,
|
|
85
|
+
noSkills: true,
|
|
86
|
+
noContextFiles: true,
|
|
87
|
+
} as unknown as ConstructorParameters<typeof DefaultResourceLoader>[0]);
|
|
88
|
+
await loader.reload();
|
|
89
|
+
const { session } = await createAgentSession({
|
|
90
|
+
model,
|
|
91
|
+
modelRuntime: runtime,
|
|
92
|
+
cwd,
|
|
93
|
+
resourceLoader: loader,
|
|
94
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
95
|
+
noTools: "all" as unknown as string,
|
|
96
|
+
thinkingLevel: "minimal" as unknown as string,
|
|
97
|
+
} as unknown as Parameters<typeof createAgentSession>[0]);
|
|
98
|
+
try {
|
|
99
|
+
// One-token probe — the only thing that proves the tier serves.
|
|
100
|
+
await session.prompt("Reply with the single word: ok");
|
|
101
|
+
servedModel = `${(session as { model?: { provider?: string; id?: string } }).model?.provider ?? entry.spec.provider}/${(session as { model?: { id?: string } }).model?.id ?? entry.spec.id}`;
|
|
102
|
+
ok = true;
|
|
103
|
+
} finally {
|
|
104
|
+
try { (session as { dispose?: () => void }).dispose?.(); } catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
ok = false;
|
|
109
|
+
reason = e instanceof Error ? e.message : String(e);
|
|
110
|
+
servedModel = undefined;
|
|
111
|
+
}
|
|
112
|
+
for (const tier of entry.tiers) {
|
|
113
|
+
const res: PreflightResult = { tier, ok, ...(servedModel ? { servedModel } : {}), ...(reason ? { reason } : {}) };
|
|
114
|
+
results.push(res);
|
|
115
|
+
tierResults[tier] = {
|
|
116
|
+
provider: entry.spec.provider,
|
|
117
|
+
id: entry.spec.id,
|
|
118
|
+
preflight: ok ? "ok" : "fail",
|
|
119
|
+
...(servedModel ? { servedModel } : {}),
|
|
120
|
+
...(reason ? { reason } : {}),
|
|
121
|
+
} as TierPreflight;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const blocked = results.find(r => !r.ok) ?? null;
|
|
126
|
+
return { results, tierResults, blocked };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function formatPreflightResults(results: PreflightResult[]): string {
|
|
130
|
+
if (!results.length) return "no tiers configured";
|
|
131
|
+
return results.map(r => `${r.tier}:${r.ok ? "ok" : `fail(${r.reason ?? "unknown"})`}${r.servedModel ? `:${r.servedModel}` : ""}`).join(" | ");
|
|
132
|
+
}
|