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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — ui/viewState.ts
|
|
3
|
+
*
|
|
4
|
+
* View states — "not running" is a state, not an absence.
|
|
5
|
+
* Every Interface reads daemon.json BEFORE rendering, and derives a view state.
|
|
6
|
+
* Heartbeat 20s, stale after 90s — v2.7 shipped values, kept deliberately.
|
|
7
|
+
* Only the single place these numbers are defined.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readJsonSafe } from "../core/fsx.ts";
|
|
11
|
+
import { daemonPath, runStatePath, supervisorPath } from "../core/paths.ts";
|
|
12
|
+
|
|
13
|
+
export const HEARTBEAT_MS = 20_000;
|
|
14
|
+
export const OWNER_STALE_MS = 90_000;
|
|
15
|
+
|
|
16
|
+
export type ViewState =
|
|
17
|
+
| "running"
|
|
18
|
+
| "stale"
|
|
19
|
+
| "not-running"
|
|
20
|
+
| "never-armed"
|
|
21
|
+
| "awaiting-approval"
|
|
22
|
+
| "stopped";
|
|
23
|
+
|
|
24
|
+
export type ViewSnapshot = {
|
|
25
|
+
state: ViewState;
|
|
26
|
+
daemon: { pid?: number; heartbeatAt?: string; runId?: string } | null;
|
|
27
|
+
run: { armed?: boolean; stopReason?: string | null; runId?: string } | null;
|
|
28
|
+
supervisor: { state?: string; updatedAt?: string } | null;
|
|
29
|
+
reason?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function deriveViewState(targetDir: string): ViewSnapshot {
|
|
33
|
+
const run = readJsonSafe<Record<string, unknown> | null>(runStatePath(targetDir), null);
|
|
34
|
+
const daemon = readJsonSafe<Record<string, unknown> | null>(daemonPath(targetDir), null);
|
|
35
|
+
const supervisor = readJsonSafe<Record<string, unknown> | null>(supervisorPath(targetDir), null);
|
|
36
|
+
|
|
37
|
+
if (!run || !run.runId) {
|
|
38
|
+
return { state: "never-armed", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: "No run has been armed. Run the wizard." };
|
|
39
|
+
}
|
|
40
|
+
if (run && (run as { armed?: boolean }).armed === false) {
|
|
41
|
+
const stopReason = typeof (run as { stopReason?: unknown }).stopReason === "string" ? (run as { stopReason?: string }).stopReason : null;
|
|
42
|
+
return { state: "stopped", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: stopReason ?? "Run stopped." };
|
|
43
|
+
}
|
|
44
|
+
// Armed but no daemon file
|
|
45
|
+
if (!daemon) {
|
|
46
|
+
return { state: "not-running", daemon: null, run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: "Daemon not running — /infinity:run to start." };
|
|
47
|
+
}
|
|
48
|
+
const heartbeatAt = typeof (daemon as { heartbeatAt?: unknown }).heartbeatAt === "string" ? (daemon as { heartbeatAt: string }).heartbeatAt : null;
|
|
49
|
+
const ageMs = heartbeatAt ? Date.now() - new Date(heartbeatAt).getTime() : Infinity;
|
|
50
|
+
if (Number.isFinite(ageMs) && ageMs > OWNER_STALE_MS) {
|
|
51
|
+
// Check pid liveness as tiebreaker: stale heartbeat but pid still alive => stale, else not-running.
|
|
52
|
+
const pid = typeof (daemon as { pid?: unknown }).pid === "number" ? (daemon as { pid: number }).pid : undefined;
|
|
53
|
+
let pidAlive = false;
|
|
54
|
+
if (typeof pid === "number") { try { process.kill(pid, 0); pidAlive = true; } catch { pidAlive = false; } }
|
|
55
|
+
if (pidAlive) {
|
|
56
|
+
return { state: "stale", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: `Daemon unresponsive since ${heartbeatAt ?? "?"}` };
|
|
57
|
+
}
|
|
58
|
+
return { state: "not-running", daemon: null, run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: "Daemon process is gone." };
|
|
59
|
+
}
|
|
60
|
+
// Check awaiting-approval (supervisor state)
|
|
61
|
+
const supState = typeof (supervisor as { state?: unknown } | null)?.state === "string" ? String((supervisor as { state: string }).state) : null;
|
|
62
|
+
if (supState === "awaiting-approval") {
|
|
63
|
+
return { state: "awaiting-approval", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"] };
|
|
64
|
+
}
|
|
65
|
+
return { state: "running", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function viewStateLabel(s: ViewState): string {
|
|
69
|
+
switch (s) {
|
|
70
|
+
case "running": return "running";
|
|
71
|
+
case "stale": return "stale";
|
|
72
|
+
case "not-running": return "not running";
|
|
73
|
+
case "never-armed": return "never armed";
|
|
74
|
+
case "awaiting-approval": return "awaiting approval";
|
|
75
|
+
case "stopped": return "stopped";
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/ui/widget.ts
CHANGED
|
@@ -46,6 +46,12 @@ export type WidgetState = {
|
|
|
46
46
|
enabledPhases?: readonly string[] | null;
|
|
47
47
|
paused?: boolean;
|
|
48
48
|
gate?: { overall: boolean; failures: string[] } | null;
|
|
49
|
+
viewState?: import("./viewState.ts").ViewSnapshot | null;
|
|
50
|
+
pilot?: string | null;
|
|
51
|
+
phaseModes?: Record<string, string> | null;
|
|
52
|
+
tierSpend?: Record<string, { input: number; output: number; cost: number; calls: number }> | null;
|
|
53
|
+
reworkDepth?: number | null;
|
|
54
|
+
replanDiff?: string | null;
|
|
49
55
|
/** Dashboard URL to show near the top, clickable. Meaningful host:port, not just numbers. */
|
|
50
56
|
dashboardUrl?: string | null;
|
|
51
57
|
/** Model routing note: e.g. "Model per task — subtasks share parent". Shown once. */
|
|
@@ -510,6 +516,55 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
510
516
|
|
|
511
517
|
const progress = computeProgress(state.list);
|
|
512
518
|
const tasks = flattenTasks(state.list);
|
|
519
|
+
const viewSnap = (state as WidgetState & { viewState?: import("./viewState.ts").ViewSnapshot | null }).viewState ?? null;
|
|
520
|
+
|
|
521
|
+
// -- viewState banner -----------------------------------------------------
|
|
522
|
+
// Reads daemon.json first. Widget never renders dead run as live.
|
|
523
|
+
if (viewSnap && viewSnap.state !== "running" && viewSnap.state !== "awaiting-approval") {
|
|
524
|
+
const hb = (viewSnap.daemon as { heartbeatAt?: string } | null)?.heartbeatAt ?? null;
|
|
525
|
+
const ts = hb ? s.fg("muted", ` (as of ${String(hb).slice(11, 16)})`) : "";
|
|
526
|
+
const msg =
|
|
527
|
+
viewSnap.state === "stale"
|
|
528
|
+
? s.fg("rework", `\u26A0 Daemon unresponsive since ${hb ? String(hb).slice(11, 16) : "?"}`) + ts
|
|
529
|
+
: viewSnap.state === "not-running"
|
|
530
|
+
? s.fg("blocked", "Daemon not running \u2014 /infinity:run to start.") + ts
|
|
531
|
+
: viewSnap.state === "never-armed"
|
|
532
|
+
? s.fg("muted", viewSnap.reason ?? "Never armed \u2014 run the wizard.")
|
|
533
|
+
: viewSnap.state === "stopped"
|
|
534
|
+
? s.fg("blocked", `STOPPED: ${viewSnap.reason ?? (viewSnap.run as { stopReason?: string } | null)?.stopReason ?? "stopped"}`) + ts
|
|
535
|
+
: s.fg("muted", viewSnap.reason ?? "");
|
|
536
|
+
push(truncate(msg, inner));
|
|
537
|
+
}
|
|
538
|
+
if (viewSnap && viewSnap.state === "awaiting-approval") {
|
|
539
|
+
const phase2 = ((viewSnap.supervisor as { phase?: string } | null)?.phase ?? state.phase ?? "?");
|
|
540
|
+
push(truncate(s.fg("active", `\u23F8 Awaiting approval: ${String(phase2).toUpperCase()} \u2192 /infinity:approve`) + s.fg("muted", " (run is healthy, not stuck)"), inner));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// -- pilot badge + per-tier spend + rework queue ---------------------------
|
|
544
|
+
if ((state as WidgetState).pilot) {
|
|
545
|
+
const pilot = (state as WidgetState).pilot as string;
|
|
546
|
+
const tag = s.bold(s.fg(pilot === "full" ? "rework" : pilot === "autopilot" ? "success" : "accent", pilot.toUpperCase()));
|
|
547
|
+
const pModes = (state as WidgetState).phaseModes ?? null;
|
|
548
|
+
const modesStr = pModes ? Object.entries(pModes).map(([ph, m]) => s.fg(m === "autopilot" ? "success" : "active", `${ph}:${m}`)).join(s.fg("rule", " \u00b7 ")) : "";
|
|
549
|
+
push(truncate(s.fg("muted", "Pilot ") + tag + (modesStr ? s.fg("rule", " ") + modesStr : ""), inner));
|
|
550
|
+
}
|
|
551
|
+
{
|
|
552
|
+
const ts2 = (state as WidgetState).tierSpend ?? null;
|
|
553
|
+
if (ts2) {
|
|
554
|
+
const parts: string[] = [];
|
|
555
|
+
for (const tier of ["A", "B", "C", "D", "X"] as const) {
|
|
556
|
+
const t = (ts2 as Record<string,{input:number;output:number;cost:number;calls:number}>)[tier];
|
|
557
|
+
if (!t) continue;
|
|
558
|
+
const label = `${tier}:${t.input + t.output}`;
|
|
559
|
+
parts.push(tier === "X" && (t.cost > 0 || t.input + t.output > 0) ? s.bold(s.fg("blocked", label)) : s.fg("muted", label));
|
|
560
|
+
}
|
|
561
|
+
if (parts.length) push(truncate(s.fg("muted", "Spend ") + parts.join(s.fg("rule", " \u00b7 ")), inner));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (typeof (state as WidgetState).reworkDepth === "number" && ((state as WidgetState).reworkDepth as number) > 0) {
|
|
565
|
+
push(truncate(s.fg("rework", `\u21BB Rework queue: ${(state as WidgetState).reworkDepth} item(s) holding gate open`), inner));
|
|
566
|
+
}
|
|
567
|
+
if ((state as WidgetState).replanDiff) push(truncate(s.fg("muted", "Replan: ") + s.fg("text", String((state as WidgetState).replanDiff).slice(0, 80)), inner));
|
|
513
568
|
|
|
514
569
|
// -- header ---------------------------------------------------------------
|
|
515
570
|
const brand = s.bold(s.fg("brand", "∞ INFINITY"));
|
package/src/unstuck.ts
CHANGED
|
File without changes
|
package/src/worker.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
12
12
|
import { resolve, join, dirname } from "node:path";
|
|
13
13
|
import { spawn, execSync } from "node:child_process";
|
|
14
|
-
import {
|
|
14
|
+
import { loadFeatureList, resolvePlanFile } from "./core/featureList.ts";
|
|
15
|
+
import { planPath } from "./core/paths.ts";
|
|
15
16
|
|
|
16
17
|
// ── constants ───────────────────────────────────────────────────────────────
|
|
17
18
|
export const WORKER_ROOT_SEGMENT = "tmp/infinity-harness";
|
|
@@ -53,10 +54,8 @@ function gitHeadSync(projectDir: string): string | undefined {
|
|
|
53
54
|
|
|
54
55
|
function readBaseRevision(projectDir: string): number {
|
|
55
56
|
try {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const raw = JSON.parse(stripBom(readFileSync(p, "utf-8")));
|
|
59
|
-
return typeof raw.baseRevision === "number" ? raw.baseRevision : 0;
|
|
57
|
+
const { list } = loadFeatureList(projectDir);
|
|
58
|
+
return typeof list.baseRevision === "number" ? list.baseRevision : 0;
|
|
60
59
|
} catch {
|
|
61
60
|
return 0;
|
|
62
61
|
}
|
|
@@ -75,8 +74,8 @@ export function buildFingerprint(opts: {
|
|
|
75
74
|
const baseRevision = opts.baseRevision ?? readBaseRevision(projectDir);
|
|
76
75
|
let featureListHash: number | undefined;
|
|
77
76
|
try {
|
|
78
|
-
const
|
|
79
|
-
if (existsSync(
|
|
77
|
+
const { path: planFile } = resolvePlanFile(projectDir);
|
|
78
|
+
if (existsSync(planFile)) featureListHash = hashLite(readFileSync(planFile, "utf-8"));
|
|
80
79
|
} catch {}
|
|
81
80
|
return {
|
|
82
81
|
runId: opts.runId,
|
|
@@ -178,7 +177,12 @@ async function withLock<T>(targetPath: string, fn: () => Promise<T> | T): Promis
|
|
|
178
177
|
}
|
|
179
178
|
|
|
180
179
|
export async function withFeatureListLock<T>(projectDir: string, fn: () => Promise<T> | T): Promise<T> {
|
|
181
|
-
const p =
|
|
180
|
+
const p = planPath(projectDir);
|
|
181
|
+
try {
|
|
182
|
+
const d = dirname(p);
|
|
183
|
+
mkdirSync(d, { recursive: true });
|
|
184
|
+
if (!existsSync(p)) writeFileSync(p, "", "utf-8");
|
|
185
|
+
} catch {}
|
|
182
186
|
return withLock(p, fn);
|
|
183
187
|
}
|
|
184
188
|
|