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.
Files changed (45) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +68 -15
  3. package/extensions/infinity-harness/index.ts +600 -26
  4. package/harness/docs/ARCHITECTURE.md +13 -7
  5. package/harness/docs/CONSTRAINTS.md +13 -5
  6. package/harness/docs/DECISIONS.md +44 -0
  7. package/harness/docs/DOMAIN.md +44 -8
  8. package/package.json +1 -1
  9. package/src/core/config.ts +88 -1
  10. package/src/core/featureList.ts +85 -17
  11. package/src/core/gates.ts +8 -6
  12. package/src/core/init.ts +33 -3
  13. package/src/core/modelRouter.ts +149 -0
  14. package/src/core/paths.ts +29 -0
  15. package/src/core/plan.ts +39 -0
  16. package/src/core/runState.ts +151 -0
  17. package/src/core/settings.ts +138 -4
  18. package/src/core/types.ts +49 -0
  19. package/src/daemon/budget.ts +94 -0
  20. package/src/daemon/guard.ts +113 -0
  21. package/src/daemon/index.ts +421 -0
  22. package/src/daemon/isolation.ts +95 -0
  23. package/src/daemon/preflight.ts +132 -0
  24. package/src/daemon/server.ts +153 -0
  25. package/src/daemon/supervisorState.ts +83 -0
  26. package/src/daemon/worker.ts +239 -0
  27. package/src/daemon/worktree.ts +95 -0
  28. package/src/exec/piWorker.ts +706 -0
  29. package/src/goalState.ts +2 -22
  30. package/src/intake.ts +4 -1
  31. package/src/loop.ts +35 -34
  32. package/src/modelRouter.ts +0 -0
  33. package/src/remote.ts +28 -7
  34. package/src/replan.ts +7 -3
  35. package/src/rework.ts +9 -3
  36. package/src/runState.ts +15 -121
  37. package/src/scheduler.ts +115 -135
  38. package/src/supervisor.ts +955 -0
  39. package/src/taskList.ts +41 -3
  40. package/src/ui/dashboard.ts +127 -0
  41. package/src/ui/viewState.ts +77 -0
  42. package/src/ui/widget.ts +189 -0
  43. package/src/ui/wizard.ts +43 -7
  44. package/src/unstuck.ts +0 -0
  45. package/src/worker.ts +12 -8
package/src/goalState.ts CHANGED
@@ -9,9 +9,8 @@ import {
9
9
  validateGoalLoopState,
10
10
  } from "./goalLoop.ts";
11
11
  import { type GoalSpecification, validateGoalSpecification } from "./goalSpec.ts";
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
13
13
  import { dirname, resolve } from "node:path";
14
- declare const require: any;
15
14
 
16
15
  export const GOAL_STATE_FILE = "GOAL_STATE.json";
17
16
  export const GOAL_TRACE_FILE = "GOAL_TRACE.jsonl";
@@ -25,26 +24,7 @@ export function canonicalGoalSpecPath(projectDir = process.cwd()): string {
25
24
  return resolve(projectDir, CANONICAL_GOAL_SPEC_DIR, CANONICAL_GOAL_SPEC_FILE);
26
25
  }
27
26
 
28
- function writeCanonicalWithLockSync(projectDir: string, content: string): void {
29
- const target = canonicalGoalSpecPath(projectDir);
30
- // try proper-lockfile sync-ish via dynamic import fallback to plain write
31
- try {
32
- mkdirSync(dirname(target), { recursive: true });
33
- // use proper-lockfile if available (async variant would need async; use sync file write with lock attempt)
34
- // For sync canonical we rely on atomic tmp+rename and ignore lock if unavailable — async wrapper below handles lock
35
- const tmp = `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
36
- writeFileSync(tmp, content, "utf8");
37
- // rename via node:fs renameSync equivalent (import already has rename async, but we use writeFileSync+rename via fs)
38
- const { renameSync } = require("node:fs");
39
- renameSync(tmp, target);
40
- } catch {
41
- // fallback simple write
42
- try {
43
- mkdirSync(dirname(target), { recursive: true });
44
- writeFileSync(target, content, "utf8");
45
- } catch {}
46
- }
47
- }
27
+
48
28
 
49
29
 
50
30
  export interface GoalStateStoreOptions {
package/src/intake.ts CHANGED
@@ -56,6 +56,8 @@ export type IntakeAnswers = {
56
56
  /** Session handoff policy. Defaults to a fresh session per phase. */
57
57
  handoff?: SessionPolicy["handoff"];
58
58
  parallelAt?: import("./core/types.ts").HandoffGranularity;
59
+ /** Where the work runs. Defaults to background — its own pi session per unit. */
60
+ engine?: import("./core/types.ts").ExecutionEngine;
59
61
  maxWorkers?: number;
60
62
  /** What the surfaces should draw. Defaults to the `focus` template. */
61
63
  display?: DisplayPolicy;
@@ -126,6 +128,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
126
128
  carryNotes: true,
127
129
  };
128
130
  const parallelAt = answers.parallelAt ?? "task";
131
+ const engine = answers.engine ?? "background";
129
132
  const maxWorkers = Math.max(1, Math.min(16, Number.isFinite(answers.maxWorkers as number) ? Math.floor(answers.maxWorkers as number) : 3));
130
133
 
131
134
  const display = normalizeDisplay(answers.display ?? defaultDisplay());
@@ -174,7 +177,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
174
177
  plan: phaseModes.plan === "copilot",
175
178
  },
176
179
  session,
177
- execution: { parallelAt, maxWorkers },
180
+ execution: { engine, parallelAt, maxWorkers, isolation: "worktree" as const },
178
181
  display,
179
182
  router: answers.router,
180
183
  summary: summarize(workflow, phases, phaseModes, session, display, brief, _researchDepth),
package/src/loop.ts CHANGED
@@ -38,7 +38,6 @@ import {
38
38
  describeEscalation,
39
39
  type EscalationState,
40
40
  } from "./escalate.ts";
41
- import { executionPolicyOf, pickRunnableTasks } from "./scheduler.ts";
42
41
  import { loadGoal, recordPipelinePass, viewOf } from "./goal.ts";
43
42
  import {
44
43
  needsApproval,
@@ -95,9 +94,29 @@ export type LoopBudget = {
95
94
  noProgressLimit: number;
96
95
  };
97
96
 
97
+ /**
98
+ * What the supervisor needs to know about an escalation, without re-deriving
99
+ * it. A rung that names a stronger model is a model switch, and a model
100
+ * switch is a new worker session — the supervisor cannot act on `reason`.
101
+ */
102
+ export type DecisionEscalation = { strategy: string; model: string | null; level: string | null };
103
+
98
104
  export type LoopDecision =
99
- | { action: "continue"; message: string; reason: string }
100
- | { action: "advanced"; toPhase: Phase; message: string; reason: string }
105
+ | {
106
+ action: "continue";
107
+ message: string;
108
+ reason: string;
109
+ /**
110
+ * The instruction on its own, without the brief appended.
111
+ *
112
+ * A worker that is already on this unit has the brief in its context
113
+ * from the turn before; re-sending several kilobytes of it every cycle
114
+ * is the single largest avoidable cost in a long run.
115
+ */
116
+ headline?: string;
117
+ escalation?: DecisionEscalation | null;
118
+ }
119
+ | { action: "advanced"; toPhase: Phase; message: string; reason: string; headline?: string }
101
120
  | { action: "stop"; reason: string; detail: string }
102
121
  | { action: "wait"; reason: string; detail: string }
103
122
  /**
@@ -440,6 +459,9 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
440
459
  action: "advanced",
441
460
  toPhase: upcoming,
442
461
  message: renderBrief(brief, moved.config ?? undefined),
462
+ headline:
463
+ `The ${phase.toUpperCase()} gate passed and the run has advanced to ` +
464
+ `${upcoming.toUpperCase()}. Work the new phase now.`,
443
465
  reason: `gate passed on ${phase}`,
444
466
  });
445
467
  }
@@ -550,44 +572,19 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
550
572
 
551
573
  // Charge retries at the active level as well as the legacy phase counter.
552
574
  const fresh = loadConfig(targetDir);
553
- let execPolicy: { parallelAt: string; maxWorkers: number } | null = null;
554
575
  if (fresh.ok) {
555
- execPolicy = executionPolicyOf(fresh.config) as { parallelAt: string; maxWorkers: number };
556
576
  incrementRetryLevel(fresh.config, retryLevel ?? "phase");
557
577
  // Keep phase counter in step as the global guard so existing budgets still fire.
558
578
  if ((retryLevel ?? "phase") !== "phase") incrementPhaseRetry(fresh.config);
559
579
  saveConfig(targetDir, fresh.config);
560
580
  }
561
581
 
562
- // Auto-spawn isolated workers for eligible tasks at the current phase —
563
- // the main session stays as orchestrator/visualisation only. Workers are
564
- // created empty (no shell command) so realpi/e2e without a worker runtime
565
- // still advances via gate; the brief still drives the main session until a
566
- // real runner picks the attempt up. This makes the main session safe to
567
- // observe but not edit the plan.
568
- try {
569
- if (execPolicy && execPolicy.parallelAt !== "off" && fresh?.ok) {
570
- const eligible = pickRunnableTasks({
571
- targetDir,
572
- phase: fresh.config.currentPhase as import("./core/types.ts").Phase | null,
573
- parallelAt: execPolicy.parallelAt as import("./core/types.ts").HandoffGranularity,
574
- maxWorkers: execPolicy.maxWorkers,
575
- });
576
- if (eligible.length > 0) {
577
- const { spawnWorkers } = await import("./scheduler.ts");
578
- const curBrief = await buildBrief(targetDir);
579
- const briefFor = (t: import("./core/featureList.ts").FlatTask): string =>
580
- `Task ${t.compositeKey} in ${fresh.config.currentPhase}: ${t.description}` +
581
- `\nAcceptance: ${(t.criteria ?? (curBrief.criteria ?? [])).join("; ")}`;
582
- // Fire-and-forget so the brief still returns promptly; harness does not
583
- // depend on the child process (covered by e2e). Errors are best-effort.
584
- spawnWorkers(targetDir, eligible, {
585
- runId,
586
- promptFor: briefFor,
587
- }).catch(() => {});
588
- }
589
- }
590
- } catch {}
582
+ // There used to be a fire-and-forget `spawnWorkers` here. It spawned
583
+ // workers with no command, so all it ever did was create an empty
584
+ // `tmp/infinity-harness/<run>/<feature>/<task>/attempt-N/` on every failing
585
+ // gate litter in the user's tree, an unawaited promise that outlived the
586
+ // call, and no work. Background execution is the supervisor's job now
587
+ // (`src/supervisor.ts`), which spawns a real pi session and waits for it.
591
588
 
592
589
  const brief = await buildBrief(targetDir);
593
590
  const failures = gate
@@ -618,6 +615,10 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
618
615
  : escalation?.strategy
619
616
  ? `escalated: ${describeEscalation(escalation)}`
620
617
  : "gate failed",
618
+ headline: head,
619
+ escalation: escalation?.strategy
620
+ ? { strategy: escalation.strategy, model: escalation.model ?? null, level: retryLevel ?? null }
621
+ : null,
621
622
  message: `${head}\n${renderBrief(brief, fresh.ok ? fresh.config : undefined)}`,
622
623
  });
623
624
  }
File without changes
package/src/remote.ts CHANGED
@@ -25,7 +25,9 @@ import { modelRouterPath, reworkPath } from "./core/paths.ts";
25
25
  import { readJsonSafe } from "./core/fsx.ts";
26
26
  import { loadRunState } from "./runState.ts";
27
27
  import { normalizeDisplay } from "./ui/display.ts";
28
+ import { supervisorStatePath, activityPath } from "./supervisor.ts";
28
29
  import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
30
+ import { executionPolicyOf } from "./scheduler.ts";
29
31
 
30
32
  export { escapeHtml };
31
33
 
@@ -58,7 +60,13 @@ export interface RemoteState {
58
60
  /** What the reader has asked the dashboard to draw. */
59
61
  display: DisplayPolicy;
60
62
  execution: unknown;
63
+ /** Where the work runs: background pi sessions, or this session. */
64
+ engine: "background" | "main-session" | null;
65
+ /** The supervisor's state file, verbatim. Opaque here. */
66
+ supervisor: unknown;
61
67
  workers: unknown;
68
+ /** Tail of the background log. */
69
+ activity: unknown;
62
70
  }
63
71
 
64
72
  export interface RemoteServer {
@@ -118,14 +126,21 @@ export function buildRemoteState(projectDir?: string): RemoteState {
118
126
  rework: readJsonSafe<unknown>(reworkPath(dir), null),
119
127
  awaitingApproval: config.awaitingApproval ?? null,
120
128
  sessions: loadRunState(dir)?.sessions ?? null,
121
- execution: (() => { try { const { executionPolicyOf } = require("./scheduler.ts"); return executionPolicyOf(config); } catch { return null; } })(),
129
+ execution: (() => { try { return executionPolicyOf(config); } catch { return null; } })(),
130
+ engine: (() => { try { return executionPolicyOf(config).engine as "background" | "main-session"; } catch { return null; } })(),
131
+ // The supervisor's own state is the truth about what is running. This used
132
+ // to scan the attempt-directory tree, which reported every attempt ever
133
+ // made as a live worker.
134
+ supervisor: readJsonSafe<unknown>(supervisorStatePath(dir), null),
122
135
  workers: (() => {
123
- try {
124
- const { listWorkers } = require("./scheduler.ts");
125
- const runId = (() => { try { return (require("./runState.ts") as { runIdFor: (d:string,f:string)=>string }).runIdFor(dir, ""); } catch { return undefined; } })();
126
- const ws = listWorkers(dir, runId || undefined) as unknown[];
127
- return Array.isArray(ws) ? ws.slice(0, 6) : [];
128
- } catch { return []; }
136
+ const sup = readJsonSafe<{ worker?: unknown; history?: unknown[] } | null>(supervisorStatePath(dir), null);
137
+ const live = sup?.worker ? [sup.worker] : [];
138
+ const past = Array.isArray(sup?.history) ? sup!.history!.slice(-3).reverse() : [];
139
+ return [...live, ...past];
140
+ })(),
141
+ activity: (() => {
142
+ const raw = readJsonSafe<{ lines?: unknown[] } | null>(activityPath(dir), null);
143
+ return Array.isArray(raw?.lines) ? raw!.lines!.slice(-60) : [];
129
144
  })(),
130
145
  goalPass:
131
146
  typeof config.goalPass === "number" && typeof config.goalMaxPasses === "number"
@@ -152,6 +167,9 @@ function toDashboardState(s: RemoteState): DashboardState {
152
167
  sessions: s.sessions,
153
168
  goalPass: s.goalPass,
154
169
  display: s.display,
170
+ engine: s.engine,
171
+ workers: (s.workers as import("./ui/dashboard.ts").DashWorker[] | null) ?? [],
172
+ activity: (s.activity as import("./ui/dashboard.ts").DashActivity[] | null) ?? [],
155
173
  };
156
174
  }
157
175
 
@@ -173,6 +191,9 @@ export function buildApiPayload(state: RemoteState & { dashboardUrl?: string | n
173
191
  rework: state.rework,
174
192
  awaitingApproval: state.awaitingApproval,
175
193
  sessions: state.sessions,
194
+ engine: state.engine,
195
+ workers: state.workers,
196
+ activity: state.activity,
176
197
  goalPass: state.goalPass,
177
198
  sprints: state.sprints,
178
199
  display: state.display,
package/src/replan.ts CHANGED
@@ -20,13 +20,13 @@
20
20
  * implementation of each now, in `core/`.
21
21
  */
22
22
 
23
- import { unlinkSync } from "node:fs";
23
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
24
24
  import type { Feature, FeatureList, Subtask, Task, TaskStatus } from "./core/types.ts";
25
25
  import { TASK_STATUSES } from "./core/types.ts";
26
26
  import { detectCycle, flattenTasks, loadFeatureList, saveFeatureList } from "./core/featureList.ts";
27
27
  import { fileExists, readJsonSafe, writeJsonAtomic } from "./core/fsx.ts";
28
28
  import { loadConfig } from "./core/config.ts";
29
- import { featureListPath, replanPath } from "./core/paths.ts";
29
+ import { planPath, replanPath } from "./core/paths.ts";
30
30
  import { withLockSync } from "./core/lock.ts";
31
31
 
32
32
  /**
@@ -161,8 +161,12 @@ function appendReplanHistory(projectDir: string, entry: ReplanHistoryEntry): voi
161
161
 
162
162
  function readMaxReplans(projectDir: string): number {
163
163
  const { config } = loadConfig(projectDir);
164
+ const lim = (config as unknown as { limits?: { maxReplansPerPhase?: unknown } }).limits as { maxReplansPerPhase?: unknown } | undefined;
165
+ let hasLimitsFile = false;
166
+ try { const p = `${projectDir}/harness/config.json`; if (existsSync(p)) { const raw = JSON.parse(readFileSync(p,"utf-8")); if (raw && typeof raw.limits === "object") hasLimitsFile = true; } } catch {}
164
167
  const replan = config.replan as { maxReplans?: unknown; maxReplansPerRun?: unknown } | undefined;
165
168
  const budgets = config.budgets as { maxReplansPerRun?: unknown } | undefined;
169
+ if (hasLimitsFile && typeof lim?.maxReplansPerPhase === "number") return lim.maxReplansPerPhase;
166
170
  if (typeof replan?.maxReplans === "number") return replan.maxReplans;
167
171
  if (typeof replan?.maxReplansPerRun === "number") return replan.maxReplansPerRun;
168
172
  if (typeof budgets?.maxReplansPerRun === "number") return budgets.maxReplansPerRun;
@@ -198,7 +202,7 @@ export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
198
202
 
199
203
  // Read, amend, validate, bump, write — one atomic section. Adding to the
200
204
  // plan is a read-apply-write over the same file every parallel worker edits.
201
- const result = withLockSync(featureListPath(projectDir), () => {
205
+ const result = withLockSync(planPath(projectDir), () => {
202
206
  const list = loadPlan(projectDir);
203
207
 
204
208
  let addedSprints = 0;
package/src/rework.ts CHANGED
@@ -17,12 +17,12 @@
17
17
  * other. One implementation of each now, in `core/`.
18
18
  */
19
19
 
20
- import { unlinkSync } from "node:fs";
20
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
21
21
  import type { FeatureList, Task } from "./core/types.ts";
22
22
  import { flattenTasks, loadFeatureList, saveFeatureList } from "./core/featureList.ts";
23
23
  import { fileExists, readJsonSafe, writeJsonAtomic } from "./core/fsx.ts";
24
24
  import { loadConfig } from "./core/config.ts";
25
- import { featureListPath, reworkPath } from "./core/paths.ts";
25
+ import { planPath, reworkPath } from "./core/paths.ts";
26
26
  import { withLockSync } from "./core/lock.ts";
27
27
 
28
28
  /**
@@ -165,8 +165,14 @@ function appendReworkRecord(projectDir: string, record: ReworkRecord): void {
165
165
 
166
166
  function readMaxReworks(projectDir: string): number {
167
167
  const { config } = loadConfig(projectDir);
168
+ const lim = (config as unknown as { limits?: { maxReworkPerUnit?: unknown } }).limits as { maxReworkPerUnit?: unknown } | undefined;
169
+ // limits.maxReworkPerUnit wins only when writable: config actually has a limits file with it.
170
+ // Test configs write { rework: {maxReworks: 3} } onto a partial config that still has DEFAULT_LIMITS via merge — without this guard every test would see 2.
171
+ let hasLimitsFile = false;
172
+ try { const p = `${projectDir}/harness/config.json`; if (existsSync(p)) { const raw = JSON.parse(readFileSync(p,"utf-8")); if (raw && typeof raw.limits === "object") hasLimitsFile = true; } } catch {}
168
173
  const rework = config.rework as { maxReworks?: unknown } | undefined;
169
174
  const budgets = config.budgets as { maxReworksPerRun?: unknown } | undefined;
175
+ if (hasLimitsFile && typeof lim?.maxReworkPerUnit === "number") return lim.maxReworkPerUnit;
170
176
  if (typeof rework?.maxReworks === "number") return rework.maxReworks;
171
177
  if (typeof budgets?.maxReworksPerRun === "number") return budgets.maxReworksPerRun;
172
178
  return DEFAULT_MAX_REWORKS;
@@ -209,7 +215,7 @@ export async function startRework(opts: StartReworkOpts): Promise<StartReworkRes
209
215
 
210
216
  // Read, flip, bump, write — one atomic section. The status flip is a
211
217
  // read-apply-write over the same file every parallel worker edits.
212
- const result = withLockSync(featureListPath(projectDir), () => {
218
+ const result = withLockSync(planPath(projectDir), () => {
213
219
  const list = loadPlan(projectDir);
214
220
  const tasks = flattenTasks(list);
215
221
  if (!tasks.some((t) => taskKey(t) === originKey)) {
package/src/runState.ts CHANGED
@@ -1,121 +1,15 @@
1
- /**
2
- * infinity-harness is a continuous run armed, and which run is it?
3
- *
4
- * This used to be a `let loopEnabled = false` inside the extension closure,
5
- * which meant the answer died with the pi session that held it. That was fine
6
- * while the harness lived in exactly one session forever, and wrong the moment
7
- * it did not:
8
- *
9
- * - a fresh session per handoff (the whole point of `src/handoff.ts`) starts
10
- * a new extension instance, and the run it was continuing was over
11
- * - `/reload`, `/new`, `/resume` and a crash all did the same thing
12
- * - the run id was a `randomUUID()` per session, so `loadLoopState` saw a
13
- * different run each time and reset the iteration count, the wall-clock
14
- * budget, the no-progress streak and the escalation ladder — every budget
15
- * that exists to stop a runaway run
16
- *
17
- * A run is a property of the project, not of the terminal window that started
18
- * it. It lives on disk.
19
- */
20
-
21
- import { runStatePath } from "./core/paths.ts";
22
- import { readJsonSafe, writeJsonAtomic, removeFile } from "./core/fsx.ts";
23
-
24
- export type RunState = {
25
- /** Whether the loop should keep driving. Read on every session start. */
26
- armed: boolean;
27
- /** Stable across every session this run spans. */
28
- runId: string;
29
- startedAt: string;
30
- /** How many pi sessions this run has used. Shown in the widget. */
31
- sessions: number;
32
- /** Why the run last stopped, so a returning human is not left guessing. */
33
- stoppedAt: string | null;
34
- stopReason: string | null;
35
- };
36
-
37
- export function newRunState(runId: string, now = new Date()): RunState {
38
- return {
39
- armed: true,
40
- runId,
41
- startedAt: now.toISOString(),
42
- sessions: 1,
43
- stoppedAt: null,
44
- stopReason: null,
45
- };
46
- }
47
-
48
- export function loadRunState(targetDir: string): RunState | null {
49
- const raw = readJsonSafe<Partial<RunState> | null>(runStatePath(targetDir), null);
50
- if (!raw || typeof raw.runId !== "string" || !raw.runId) return null;
51
- return {
52
- armed: raw.armed === true,
53
- runId: raw.runId,
54
- startedAt: typeof raw.startedAt === "string" ? raw.startedAt : new Date(0).toISOString(),
55
- sessions: typeof raw.sessions === "number" && raw.sessions > 0 ? raw.sessions : 1,
56
- stoppedAt: typeof raw.stoppedAt === "string" ? raw.stoppedAt : null,
57
- stopReason: typeof raw.stopReason === "string" ? raw.stopReason : null,
58
- };
59
- }
60
-
61
- export function saveRunState(targetDir: string, state: RunState): void {
62
- try {
63
- writeJsonAtomic(runStatePath(targetDir), state);
64
- } catch {
65
- // Losing the file costs the run its cross-session budgets, which is bad,
66
- // but throwing here would kill the session, which is worse.
67
- }
68
- }
69
-
70
- /** Arm a run. Reuses the existing run id when one is already armed. */
71
- export function armRun(targetDir: string, runId: string, now = new Date()): RunState {
72
- const existing = loadRunState(targetDir);
73
- const state =
74
- existing && existing.armed
75
- ? { ...existing, stoppedAt: null, stopReason: null }
76
- : newRunState(runId, now);
77
- saveRunState(targetDir, state);
78
- return state;
79
- }
80
-
81
- export function disarmRun(targetDir: string, reason: string, now = new Date()): RunState | null {
82
- const existing = loadRunState(targetDir);
83
- if (!existing) return null;
84
- const state: RunState = {
85
- ...existing,
86
- armed: false,
87
- stoppedAt: now.toISOString(),
88
- stopReason: reason,
89
- };
90
- saveRunState(targetDir, state);
91
- return state;
92
- }
93
-
94
- /** Count one more pi session against this run. Called from `session_start`. */
95
- export function countSession(targetDir: string): RunState | null {
96
- const existing = loadRunState(targetDir);
97
- if (!existing) return null;
98
- const state = { ...existing, sessions: existing.sessions + 1 };
99
- saveRunState(targetDir, state);
100
- return state;
101
- }
102
-
103
- export function clearRunState(targetDir: string): void {
104
- try {
105
- removeFile(runStatePath(targetDir));
106
- } catch {
107
- /* nothing to clear */
108
- }
109
- }
110
-
111
- /**
112
- * The run id the loop should use.
113
- *
114
- * An armed run keeps its id so `loadLoopState` finds the same budgets after a
115
- * handoff. With nothing armed, the caller's session id is the run id — an
116
- * ad-hoc `/infinity:validate` should not inherit a finished run's strikes.
117
- */
118
- export function runIdFor(targetDir: string, fallback: string): string {
119
- const state = loadRunState(targetDir);
120
- return state && state.armed ? state.runId : fallback;
121
- }
1
+ // Re-export from core so both import paths work.
2
+ // Canonical type now lives in src/core/runState.ts (with baseModel/tiers/budget).
3
+ export {
4
+ loadRunState,
5
+ saveRunState,
6
+ armRun,
7
+ disarmRun,
8
+ countSession,
9
+ clearRunState,
10
+ runIdFor,
11
+ newRunState,
12
+ parseBaseModelString,
13
+ createUsageTotals,
14
+ } from "./core/runState.ts";
15
+ export type { RunState, ProviderModel, TierResults, TierPreflight, Budget, UsageTotals } from "./core/runState.ts";