infinity-harness 2.6.6 → 2.8.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 +80 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +600 -26
- 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 +138 -4
- package/src/core/types.ts +49 -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 +706 -0
- package/src/goalState.ts +2 -22
- package/src/intake.ts +4 -1
- package/src/loop.ts +35 -34
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +28 -7
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +115 -135
- package/src/supervisor.ts +955 -0
- package/src/taskList.ts +41 -3
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +189 -0
- package/src/ui/wizard.ts +43 -7
- package/src/unstuck.ts +0 -0
- package/src/worker.ts +12 -8
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — daemon/budget.ts
|
|
3
|
+
*
|
|
4
|
+
* Per-tier token+cost accounting. Uses pi's UsageTotals shape.
|
|
5
|
+
* - `byTier` holds cumulative per-tier spend (one UsageTotals per A/B/C/D/X).
|
|
6
|
+
* - `cap` is the budget ceiling (costUsd / totalTokens / wallClockMs).
|
|
7
|
+
* - The X-leak tripwire: X tokens outside a consultation worker are a defect.
|
|
8
|
+
*
|
|
9
|
+
* pi reports usage cumulatively per session (last message_end usage IS the
|
|
10
|
+
* session total, not a delta). So per-session we keep the MAX, and per-tier
|
|
11
|
+
* we sum finished sessions' totals via addUsageToTotals semantics.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { UsageTotals, Budget, RunState } from "../core/runState.ts";
|
|
15
|
+
import type { TierId } from "../core/types.ts";
|
|
16
|
+
import { createUsageTotals } from "../core/runState.ts";
|
|
17
|
+
|
|
18
|
+
export type Tier = TierId;
|
|
19
|
+
|
|
20
|
+
export type Caps = { costUsd?: number | null; totalTokens?: number | null; wallClockMs?: number | null };
|
|
21
|
+
|
|
22
|
+
export type BudgetState = Budget; // alias for the RunState's budget shape
|
|
23
|
+
|
|
24
|
+
export function emptyBudget(): Budget {
|
|
25
|
+
return { byTier: {}, cap: {}, stopOnExhaustion: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function addUsageForTier(budget: Budget, tier: Tier, usage: Partial<UsageTotals> & { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: number; calls?: number }): void {
|
|
29
|
+
const byTier = (budget.byTier ??= {});
|
|
30
|
+
const cur: UsageTotals = byTier[tier] ?? createUsageTotals();
|
|
31
|
+
cur.input += usage.input ?? 0;
|
|
32
|
+
cur.output += usage.output ?? 0;
|
|
33
|
+
cur.cacheRead += usage.cacheRead ?? 0;
|
|
34
|
+
cur.cacheWrite += usage.cacheWrite ?? 0;
|
|
35
|
+
cur.cost += usage.cost ?? 0;
|
|
36
|
+
cur.calls += usage.calls ?? 1;
|
|
37
|
+
byTier[tier] = cur;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function totalTokens(budget: Budget): number {
|
|
41
|
+
let n = 0;
|
|
42
|
+
for (const u of Object.values(budget.byTier ?? {})) n += (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function totalCost(budget: Budget): number {
|
|
47
|
+
let c = 0;
|
|
48
|
+
for (const u of Object.values(budget.byTier ?? {})) c += u.cost ?? 0;
|
|
49
|
+
return c;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isCapExceeded(budget: Budget): { exceeded: boolean; reason: string | null } {
|
|
53
|
+
const cap = budget.cap ?? {};
|
|
54
|
+
const tokens = totalTokens(budget);
|
|
55
|
+
const cost = totalCost(budget);
|
|
56
|
+
if (typeof cap.totalTokens === "number" && cap.totalTokens > 0 && tokens >= cap.totalTokens) return { exceeded: true, reason: `token cap ${cap.totalTokens} exceeded (${tokens})` };
|
|
57
|
+
if (typeof cap.costUsd === "number" && cap.costUsd > 0 && cost >= cap.costUsd) return { exceeded: true, reason: `cost cap $${cap.costUsd} exceeded ($${cost.toFixed(2)})` };
|
|
58
|
+
return { exceeded: false, reason: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* X-leak tripwire. True when X has accrued cost/tokens while no consultation
|
|
63
|
+
* worker is alive. The Daemon treats this as a hard stop with reason.
|
|
64
|
+
*/
|
|
65
|
+
export function hasXLeak(budget: Budget, hasConsultationWorker: boolean): boolean {
|
|
66
|
+
if (hasConsultationWorker) return false;
|
|
67
|
+
const x = (budget.byTier as Record<string, UsageTotals | undefined>)["X"];
|
|
68
|
+
if (!x) return false;
|
|
69
|
+
return (x.cost ?? 0) > 0 || (x.input ?? 0) + (x.output ?? 0) > 0 || (x.calls ?? 0) > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function xLeakReason(budget: Budget): string {
|
|
73
|
+
const x = (budget.byTier as Record<string, UsageTotals | undefined>)["X"];
|
|
74
|
+
const cost = x?.cost ?? 0;
|
|
75
|
+
const tokens = (x?.input ?? 0) + (x?.output ?? 0);
|
|
76
|
+
return `X accrued ${tokens} tokens ($${cost.toFixed(2)}) with no consultation worker — routing leak`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function budgetFromRunState(runState: RunState | null): Budget {
|
|
80
|
+
if (!runState?.budget) return emptyBudget();
|
|
81
|
+
return runState.budget;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function budgetSummary(budget: Budget): string {
|
|
85
|
+
const parts: string[] = [];
|
|
86
|
+
for (const tier of ["A","B","C","D","X"] as Tier[]) {
|
|
87
|
+
const u = (budget.byTier as Record<string, UsageTotals | undefined>)[tier];
|
|
88
|
+
if (!u || (!u.calls && !u.cost && !u.input && !u.output)) continue;
|
|
89
|
+
parts.push(`${tier}:${u.input}+${u.output} $${(u.cost ?? 0).toFixed(2)} ×${u.calls}`);
|
|
90
|
+
}
|
|
91
|
+
const cap = budget.cap ?? {};
|
|
92
|
+
const capStr = [cap.totalTokens ? `tokens ${cap.totalTokens}` : null, cap.costUsd ? `$${cap.costUsd}` : null].filter(Boolean).join(" / ");
|
|
93
|
+
return parts.length ? `${parts.join(" | ")}${capStr ? ` — cap ${capStr}` : ""}` : (capStr ? `cap ${capStr}` : "no spend yet");
|
|
94
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — daemon/guard.ts
|
|
3
|
+
*
|
|
4
|
+
* Single-owner lock + daemon.json heartbeat + bounded stop.
|
|
5
|
+
* The Daemon is the single writer; Interfaces are readers. A second pi window
|
|
6
|
+
* on the same project must become a viewer, not a rival Daemon.
|
|
7
|
+
*
|
|
8
|
+
* daemon.json is the trust root: { pid, port, token, startedAt, heartbeatAt, runId }.
|
|
9
|
+
* Token is a random per-run secret; file is 0600. Server binds 127.0.0.1 only.
|
|
10
|
+
* Guides: Daemon writes it, liveness is process.kill(pid,0), heartbeat every 20s,
|
|
11
|
+
* stale after 90s. v2.7 shipped HEARTBEAT_MS/OWNER_STALE_MS at 20s/90s.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
15
|
+
import { existsSync, writeFileSync, readFileSync, chmodSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { daemonPath } from "../core/paths.ts";
|
|
18
|
+
import { readJsonSafe, writeJsonAtomic, fileExists, ensureDir } from "../core/fsx.ts";
|
|
19
|
+
import { dirname } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const HEARTBEAT_MS = 20_000;
|
|
22
|
+
export const OWNER_STALE_MS = 90_000;
|
|
23
|
+
|
|
24
|
+
export type DaemonInfo = {
|
|
25
|
+
pid: number;
|
|
26
|
+
port: number;
|
|
27
|
+
token: string;
|
|
28
|
+
startedAt: string;
|
|
29
|
+
heartbeatAt: string;
|
|
30
|
+
runId: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function readDaemonRaw(targetDir: string): DaemonInfo | null {
|
|
34
|
+
const raw = readJsonSafe<DaemonInfo | null>(daemonPath(targetDir), null);
|
|
35
|
+
if (!raw || typeof raw.pid !== "number" || !raw.runId) return null;
|
|
36
|
+
return raw as DaemonInfo;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadDaemon(targetDir: string): DaemonInfo | null {
|
|
40
|
+
const info = readDaemonRaw(targetDir);
|
|
41
|
+
if (!info) return null;
|
|
42
|
+
return info;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isDaemonAlive(info: DaemonInfo | null): boolean {
|
|
46
|
+
if (!info) return false;
|
|
47
|
+
// Heartbeat stale?
|
|
48
|
+
const age = Date.now() - new Date(info.heartbeatAt).getTime();
|
|
49
|
+
if (Number.isFinite(age) && age > OWNER_STALE_MS) {
|
|
50
|
+
// Also check pid liveness as tiebreaker.
|
|
51
|
+
try { process.kill(info.pid, 0); return false; } catch { return false; }
|
|
52
|
+
}
|
|
53
|
+
try { process.kill(info.pid, 0); return true; } catch { return false; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isDaemonRunning(targetDir: string): boolean {
|
|
57
|
+
const info = loadDaemon(targetDir);
|
|
58
|
+
if (!info) return false;
|
|
59
|
+
return isDaemonAlive(info);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function writeDaemon(targetDir: string, info: DaemonInfo): void {
|
|
63
|
+
ensureDir(dirname(daemonPath(targetDir)));
|
|
64
|
+
writeJsonAtomic(daemonPath(targetDir), info);
|
|
65
|
+
try { chmodSync(daemonPath(targetDir), 0o600); } catch {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function heartbeat(targetDir: string): DaemonInfo | null {
|
|
69
|
+
const info = loadDaemon(targetDir);
|
|
70
|
+
if (!info || !isDaemonAlive(info)) return null;
|
|
71
|
+
const next: DaemonInfo = { ...info, heartbeatAt: new Date().toISOString() };
|
|
72
|
+
writeDaemon(targetDir, next);
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function clearDaemon(targetDir: string): void {
|
|
77
|
+
try { if (existsSync(daemonPath(targetDir))) unlinkSync(daemonPath(targetDir)); } catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function newDaemonInfo(runId: string, port: number): DaemonInfo {
|
|
81
|
+
return {
|
|
82
|
+
pid: process.pid,
|
|
83
|
+
port,
|
|
84
|
+
token: randomBytes(24).toString("base64url"),
|
|
85
|
+
startedAt: new Date().toISOString(),
|
|
86
|
+
heartbeatAt: new Date().toISOString(),
|
|
87
|
+
runId,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Guard: refuse to start a second Daemon when one is alive.
|
|
93
|
+
* Returns null when clear, or the live DaemonInfo when blocked.
|
|
94
|
+
*/
|
|
95
|
+
export function guardSingleOwner(targetDir: string): DaemonInfo | null {
|
|
96
|
+
const live = loadDaemon(targetDir);
|
|
97
|
+
if (live && isDaemonAlive(live)) return live;
|
|
98
|
+
// Stale file — clear it so the next start succeeds.
|
|
99
|
+
if (live && !isDaemonAlive(live)) clearDaemon(targetDir);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Start heartbeat interval. Returns stop function.
|
|
105
|
+
*/
|
|
106
|
+
export function startHeartbeat(targetDir: string): () => void {
|
|
107
|
+
const timer = setInterval(() => {
|
|
108
|
+
try { heartbeat(targetDir); } catch {}
|
|
109
|
+
}, HEARTBEAT_MS);
|
|
110
|
+
// Don't keep process alive just for heartbeat if nothing else is running.
|
|
111
|
+
if (typeof (timer as NodeJS.Timeout & { unref?: () => void }).unref === "function") (timer as unknown as { unref: () => void }).unref();
|
|
112
|
+
return () => clearInterval(timer);
|
|
113
|
+
}
|
|
@@ -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
|
+
}
|