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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — core/runState.ts (Core owns type + path, Daemon owns writes).
|
|
3
|
+
*
|
|
4
|
+
* v2.7's `src/runState.ts` had { armed, runId, startedAt, sessions, stoppedAt, stopReason }.
|
|
5
|
+
* v3 extends it with: baseModel, tiers (preflight results), budget (byTier UsageTotals + caps).
|
|
6
|
+
* Existing fields are kept; the file stays `harness/run.json`.
|
|
7
|
+
*
|
|
8
|
+
* Core owns the type, the path spelling, and the read helpers used by Interfaces.
|
|
9
|
+
* Daemon owns the writes (arm, heartbeat, budget, preflight) — but the types
|
|
10
|
+
* are here so Interfaces and Core can read the same truth without importing
|
|
11
|
+
* the Daemon.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { runStatePath } from "./paths.ts";
|
|
15
|
+
import { readJsonSafe, writeJsonAtomic, removeFile } from "./fsx.ts";
|
|
16
|
+
|
|
17
|
+
export type ProviderModel = { provider: string; id: string; thinkingLevel?: string };
|
|
18
|
+
|
|
19
|
+
export type TierPreflight = { provider: string; id: string; preflight: "ok" | "fail"; servedModel?: string; reason?: string };
|
|
20
|
+
|
|
21
|
+
export type TierResults = Partial<Record<"A" | "B" | "C" | "D" | "X", TierPreflight>>;
|
|
22
|
+
|
|
23
|
+
export type UsageTotals = { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; calls: number };
|
|
24
|
+
|
|
25
|
+
export function createUsageTotals(): UsageTotals {
|
|
26
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, calls: 0 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Budget = {
|
|
30
|
+
byTier: Partial<Record<"A" | "B" | "C" | "D" | "X", UsageTotals>>;
|
|
31
|
+
cap: { totalTokens?: number | null; costUsd?: number | null; wallClockMs?: number | null };
|
|
32
|
+
stopOnExhaustion?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type RunState = {
|
|
36
|
+
armed: boolean;
|
|
37
|
+
runId: string;
|
|
38
|
+
startedAt: string;
|
|
39
|
+
sessions: number;
|
|
40
|
+
stoppedAt: string | null;
|
|
41
|
+
stopReason: string | null;
|
|
42
|
+
/** Captured from ctx.model at arm time. The detached Daemon has no ctx. */
|
|
43
|
+
baseModel: ProviderModel | null;
|
|
44
|
+
/** Preflight outcome per tier. A failing tier blocks arming. */
|
|
45
|
+
tiers: TierResults;
|
|
46
|
+
/** Per-tier spend and caps. X outside consultation is a defect signal, not a budget. */
|
|
47
|
+
budget: Budget;
|
|
48
|
+
wallClockMs?: number;
|
|
49
|
+
escalation?: { level: string | null; since: string | null };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function newRunState(runId: string, now = new Date()): RunState {
|
|
53
|
+
return {
|
|
54
|
+
armed: true,
|
|
55
|
+
runId,
|
|
56
|
+
startedAt: now.toISOString(),
|
|
57
|
+
sessions: 1,
|
|
58
|
+
stoppedAt: null,
|
|
59
|
+
stopReason: null,
|
|
60
|
+
baseModel: null,
|
|
61
|
+
tiers: {},
|
|
62
|
+
budget: { byTier: {}, cap: {}, stopOnExhaustion: true },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function loadRunState(targetDir: string): RunState | null {
|
|
67
|
+
const raw = readJsonSafe<Record<string, unknown> | null>(runStatePath(targetDir), null);
|
|
68
|
+
if (!raw || typeof raw.runId !== "string" || !raw.runId) return null;
|
|
69
|
+
const sessions = typeof raw.sessions === "number" && (raw.sessions as number) > 0 ? (raw.sessions as number) : 1;
|
|
70
|
+
// Back-compat: older file had no baseModel/tiers/budget — treat as null/empty.
|
|
71
|
+
const baseModel = (() => {
|
|
72
|
+
const bm = (raw as Record<string, unknown>).baseModel;
|
|
73
|
+
if (!bm || typeof bm !== "object" || Array.isArray(bm)) return null;
|
|
74
|
+
const b = bm as Record<string, unknown>;
|
|
75
|
+
if (typeof b.provider === "string" && typeof b.id === "string") return { provider: String(b.provider), id: String(b.id), ...(typeof b.thinkingLevel === "string" ? { thinkingLevel: b.thinkingLevel } : {}) } as ProviderModel;
|
|
76
|
+
// legacy: baseModel was a string "provider/id"
|
|
77
|
+
if (typeof raw.baseModel === "string" && String(raw.baseModel).trim()) {
|
|
78
|
+
const s = String(raw.baseModel).trim();
|
|
79
|
+
const parts = s.split("/");
|
|
80
|
+
if (parts.length >= 2) return { provider: parts[0]!, id: parts.slice(1).join("/") };
|
|
81
|
+
return { provider: "anthropic", id: s };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
})();
|
|
85
|
+
const tiersRaw = (raw as Record<string, unknown>).tiers;
|
|
86
|
+
const tiers: TierResults = (tiersRaw && typeof tiersRaw === "object" && !Array.isArray(tiersRaw) ? tiersRaw : {}) as TierResults;
|
|
87
|
+
const budgetRaw = (raw as Record<string, unknown>).budget;
|
|
88
|
+
const budget: Budget = (budgetRaw && typeof budgetRaw === "object" && !Array.isArray(budgetRaw)
|
|
89
|
+
? budgetRaw as Budget
|
|
90
|
+
: { byTier: {}, cap: {}, stopOnExhaustion: true });
|
|
91
|
+
if (!budget.byTier || typeof budget.byTier !== "object") budget.byTier = {};
|
|
92
|
+
if (!budget.cap || typeof budget.cap !== "object") budget.cap = {};
|
|
93
|
+
return {
|
|
94
|
+
armed: raw.armed === true,
|
|
95
|
+
runId: raw.runId as string,
|
|
96
|
+
startedAt: typeof raw.startedAt === "string" ? (raw.startedAt as string) : new Date(0).toISOString(),
|
|
97
|
+
sessions,
|
|
98
|
+
stoppedAt: typeof raw.stoppedAt === "string" ? (raw.stoppedAt as string) : null,
|
|
99
|
+
stopReason: typeof raw.stopReason === "string" ? (raw.stopReason as string) : null,
|
|
100
|
+
baseModel,
|
|
101
|
+
tiers,
|
|
102
|
+
budget,
|
|
103
|
+
...(typeof (raw as Record<string, unknown>).wallClockMs === "number" ? { wallClockMs: (raw as Record<string, unknown>).wallClockMs as number } : {}),
|
|
104
|
+
...(typeof (raw as Record<string, unknown>).escalation === "object" ? { escalation: (raw as Record<string, unknown>).escalation as RunState["escalation"] } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function saveRunState(targetDir: string, state: RunState): void {
|
|
109
|
+
try { writeJsonAtomic(runStatePath(targetDir), state); } catch { /* run bookkeeping must not kill the session */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function armRun(targetDir: string, runId: string, now = new Date()): RunState {
|
|
113
|
+
const existing = loadRunState(targetDir);
|
|
114
|
+
const state = existing && existing.armed ? { ...existing, stoppedAt: null, stopReason: null } : newRunState(runId, now);
|
|
115
|
+
saveRunState(targetDir, state);
|
|
116
|
+
return state;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function disarmRun(targetDir: string, reason: string, now = new Date()): RunState | null {
|
|
120
|
+
const existing = loadRunState(targetDir);
|
|
121
|
+
if (!existing) return null;
|
|
122
|
+
const state: RunState = { ...existing, armed: false, stoppedAt: now.toISOString(), stopReason: reason };
|
|
123
|
+
saveRunState(targetDir, state);
|
|
124
|
+
return state;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function countSession(targetDir: string): RunState | null {
|
|
128
|
+
const existing = loadRunState(targetDir);
|
|
129
|
+
if (!existing) return null;
|
|
130
|
+
const state = { ...existing, sessions: existing.sessions + 1 };
|
|
131
|
+
saveRunState(targetDir, state);
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function clearRunState(targetDir: string): void {
|
|
136
|
+
try { removeFile(runStatePath(targetDir)); } catch { /* nothing to clear */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function runIdFor(targetDir: string, fallback: string): string {
|
|
140
|
+
const state = loadRunState(targetDir);
|
|
141
|
+
return state && state.armed ? state.runId : fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Parse a legacy baseModel string "provider/id" into a ProviderModel. */
|
|
145
|
+
export function parseBaseModelString(s: string | null | undefined): ProviderModel | null {
|
|
146
|
+
if (!s || !String(s).trim()) return null;
|
|
147
|
+
const str = String(s).trim();
|
|
148
|
+
const parts = str.split("/");
|
|
149
|
+
if (parts.length >= 2) return { provider: parts[0]!, id: parts.slice(1).join("/") };
|
|
150
|
+
return { provider: "anthropic", id: str };
|
|
151
|
+
}
|
package/src/core/settings.ts
CHANGED
|
@@ -188,6 +188,125 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
188
188
|
help: "A paused pipeline refuses to advance and stops the continuous run.",
|
|
189
189
|
type: { kind: "boolean" },
|
|
190
190
|
},
|
|
191
|
+
{
|
|
192
|
+
path: "pilot",
|
|
193
|
+
file: "config",
|
|
194
|
+
label: "Pilot",
|
|
195
|
+
help: "Run-level preset over per-phase modes. copilot every phase stops, autopilot builds without you, full is hands-off intake->SHIP.",
|
|
196
|
+
type: { kind: "choice", choices: ["copilot", "autopilot", "full"] },
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
path: "tiers.A.provider",
|
|
200
|
+
file: "config",
|
|
201
|
+
label: "Tier A provider",
|
|
202
|
+
help: "Provider for general harness work (orchestration, short-lived A workers).",
|
|
203
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
path: "tiers.A.id",
|
|
207
|
+
file: "config",
|
|
208
|
+
label: "Tier A model",
|
|
209
|
+
help: "Model id for tier A general work.",
|
|
210
|
+
type: { kind: "text", placeholder: "claude-sonnet-4-5", allowEmpty: true },
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
path: "tiers.B.provider",
|
|
214
|
+
file: "config",
|
|
215
|
+
label: "Tier B provider",
|
|
216
|
+
help: "Provider for easy tasks.",
|
|
217
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
path: "tiers.B.id",
|
|
221
|
+
file: "config",
|
|
222
|
+
label: "Tier B model",
|
|
223
|
+
help: "Model id for B (easy). Empty means use baseModel X.",
|
|
224
|
+
type: { kind: "text", placeholder: "claude-sonnet-4-5", allowEmpty: true },
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
path: "tiers.C.provider",
|
|
228
|
+
file: "config",
|
|
229
|
+
label: "Tier C provider",
|
|
230
|
+
help: "Provider for moderate tasks.",
|
|
231
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
path: "tiers.C.id",
|
|
235
|
+
file: "config",
|
|
236
|
+
label: "Tier C model",
|
|
237
|
+
help: "Model id for C (moderate).",
|
|
238
|
+
type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
path: "tiers.D.provider",
|
|
242
|
+
file: "config",
|
|
243
|
+
label: "Tier D provider",
|
|
244
|
+
help: "Provider for difficult tasks.",
|
|
245
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
path: "tiers.D.id",
|
|
249
|
+
file: "config",
|
|
250
|
+
label: "Tier D model",
|
|
251
|
+
help: "Model id for D (difficult).",
|
|
252
|
+
type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
path: "tiers.X.provider",
|
|
256
|
+
file: "config",
|
|
257
|
+
label: "Tier X provider",
|
|
258
|
+
help: "Provider for consultation / escalation (strongest).",
|
|
259
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
path: "tiers.X.id",
|
|
263
|
+
file: "config",
|
|
264
|
+
label: "Tier X model",
|
|
265
|
+
help: "Model id for X (consultation).",
|
|
266
|
+
type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
path: "limits.unitWallClockMs",
|
|
270
|
+
file: "config",
|
|
271
|
+
label: "Unit wall-clock ms",
|
|
272
|
+
help: "How long one worker may run before abort + dispose. Default 30m.",
|
|
273
|
+
type: { kind: "number", min: 60000, max: 7200000, unit: "ms" },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
path: "limits.maxRecycles",
|
|
277
|
+
file: "config",
|
|
278
|
+
label: "Max recycles",
|
|
279
|
+
help: "How many compaction recycles one unit tolerates before stopping. Default 2.",
|
|
280
|
+
type: { kind: "number", min: 0, max: 10 },
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
path: "limits.maxReworkPerUnit",
|
|
284
|
+
file: "config",
|
|
285
|
+
label: "Max rework per unit",
|
|
286
|
+
help: "How many times a unit may go back to rework. Default 2.",
|
|
287
|
+
type: { kind: "number", min: 0, max: 10 },
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
path: "limits.maxReplansPerPhase",
|
|
291
|
+
file: "config",
|
|
292
|
+
label: "Max replans per phase",
|
|
293
|
+
help: "How many plan mutations a phase may have. Default 3.",
|
|
294
|
+
type: { kind: "number", min: 0, max: 10 },
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
path: "limits.tokenCap",
|
|
298
|
+
file: "config",
|
|
299
|
+
label: "Token cap",
|
|
300
|
+
help: "Total tokens before the run stops. Null means unconstrained.",
|
|
301
|
+
type: { kind: "number", min: 1000, max: 100000000 },
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
path: "limits.costCap",
|
|
305
|
+
file: "config",
|
|
306
|
+
label: "Cost cap USD",
|
|
307
|
+
help: "Spend ceiling across A/B/C/D/X. Null means unconstrained.",
|
|
308
|
+
type: { kind: "number", min: 0, max: 100000 },
|
|
309
|
+
},
|
|
191
310
|
],
|
|
192
311
|
},
|
|
193
312
|
{
|
|
@@ -388,16 +507,23 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
388
507
|
path: "execution.parallelAt",
|
|
389
508
|
file: "config",
|
|
390
509
|
label: "Parallel at",
|
|
391
|
-
help: "
|
|
510
|
+
help: "The plan-phase for parallel work. The background engine runs one worker until worktrees land, so this is only the future parallelism knob.",
|
|
392
511
|
type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
|
|
393
512
|
},
|
|
394
513
|
{
|
|
395
514
|
path: "execution.maxWorkers",
|
|
396
515
|
file: "config",
|
|
397
516
|
label: "Max workers",
|
|
398
|
-
help: "
|
|
517
|
+
help: "Hard cap on concurrent workers (1..16). Guarded by lock and budget; 1 until worktrees exist. Effective from Daemon.",
|
|
399
518
|
type: { kind: "number", min: 1, max: 16 },
|
|
400
519
|
},
|
|
520
|
+
{
|
|
521
|
+
path: "execution.isolation",
|
|
522
|
+
file: "config",
|
|
523
|
+
label: "Isolation",
|
|
524
|
+
help: "How concurrent workers stay out of each others way. none forces maxWorkers=1 and no worktree.",
|
|
525
|
+
type: { kind: "choice", choices: ["worktree", "none"] },
|
|
526
|
+
},
|
|
401
527
|
],
|
|
402
528
|
},
|
|
403
529
|
{
|
|
@@ -465,7 +591,7 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
465
591
|
path: "gates.antiPlaceholder.enabled",
|
|
466
592
|
file: "config",
|
|
467
593
|
label: "Reject placeholders",
|
|
468
|
-
help: "Fail the gate
|
|
594
|
+
help: "Fail the gate when source still has unfinished markers.",
|
|
469
595
|
type: { kind: "boolean" },
|
|
470
596
|
},
|
|
471
597
|
],
|
|
@@ -681,6 +807,7 @@ export function writeSetting(targetDir: string, setting: Setting, value: unknown
|
|
|
681
807
|
|
|
682
808
|
/** How a value is shown in the menu. Empty model slots read as inherited. */
|
|
683
809
|
export function formatValue(setting: Setting, value: unknown): string {
|
|
810
|
+
if (setting.path === "execution.isolation") return typeof value === "string" && value.trim() ? value : "worktree";
|
|
684
811
|
switch (setting.type.kind) {
|
|
685
812
|
case "boolean":
|
|
686
813
|
return value ? "on" : "off";
|
package/src/core/types.ts
CHANGED
|
@@ -74,6 +74,22 @@ export type SubtaskStatus = (typeof SUBTASK_STATUSES)[number];
|
|
|
74
74
|
|
|
75
75
|
export type Difficulty = "easy" | "moderate" | "difficult";
|
|
76
76
|
|
|
77
|
+
export type PilotMode = "copilot" | "autopilot" | "full";
|
|
78
|
+
|
|
79
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
80
|
+
|
|
81
|
+
export type TierSpec = {
|
|
82
|
+
provider: string;
|
|
83
|
+
id: string;
|
|
84
|
+
thinkingLevel?: ThinkingLevel | "";
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type TierId = "A" | "B" | "C" | "D" | "X";
|
|
88
|
+
|
|
89
|
+
export type TierMap = Partial<Record<TierId, TierSpec>>;
|
|
90
|
+
|
|
91
|
+
export type ExecutionIsolation = "worktree" | "none";
|
|
92
|
+
|
|
77
93
|
// ── Feature list (the SSOT on disk) ─────────────────────────────────────────
|
|
78
94
|
|
|
79
95
|
export type Subtask = {
|
|
@@ -95,6 +111,8 @@ export type Task = {
|
|
|
95
111
|
difficulty?: Difficulty;
|
|
96
112
|
modelHint?: string;
|
|
97
113
|
criteria?: string[];
|
|
114
|
+
/** When true this task must run alone — whole-tree isolation (lockfile regen, codemod). */
|
|
115
|
+
serialize?: boolean;
|
|
98
116
|
/** Free-form extras are preserved verbatim on round-trip. */
|
|
99
117
|
[k: string]: unknown;
|
|
100
118
|
};
|
|
@@ -185,6 +203,8 @@ export type ExecutionPolicy = {
|
|
|
185
203
|
parallelAt: HandoffGranularity;
|
|
186
204
|
/** Max parallel workers (1..16). Guarded by lock and budget. */
|
|
187
205
|
maxWorkers: number;
|
|
206
|
+
/** How concurrent workers stay out of each other's way. */
|
|
207
|
+
isolation: ExecutionIsolation;
|
|
188
208
|
};
|
|
189
209
|
|
|
190
210
|
export type ExecutionEngine = "background" | "main-session";
|
|
@@ -319,6 +339,19 @@ export type HarnessConfig = {
|
|
|
319
339
|
workflow: { id: string; name: string } | null;
|
|
320
340
|
display: DisplayPolicy;
|
|
321
341
|
intake: IntakeState;
|
|
342
|
+
/** Run-level pilot preset over per-phase modes. `full` = hands-off intake→SHIP. */
|
|
343
|
+
pilot: PilotMode;
|
|
344
|
+
/** Tier definitions A/B/C/D/X. Each is provider+id+thinking. Empty means use baseModel. */
|
|
345
|
+
tiers: TierMap;
|
|
346
|
+
/** Global caps that bound a run. */
|
|
347
|
+
limits: {
|
|
348
|
+
unitWallClockMs: number;
|
|
349
|
+
maxRecycles: number;
|
|
350
|
+
maxReworkPerUnit: number;
|
|
351
|
+
maxReplansPerPhase: number;
|
|
352
|
+
tokenCap: number | null;
|
|
353
|
+
costCap: number | null;
|
|
354
|
+
};
|
|
322
355
|
/** Set when a gate passed but the phase needs a human signature first. */
|
|
323
356
|
awaitingApproval: Phase | null;
|
|
324
357
|
/** Budgets that bound an unattended continuous run. See src/loop.ts. */
|
|
@@ -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
|
+
}
|