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/scheduler.ts CHANGED
@@ -8,10 +8,12 @@
8
8
 
9
9
  import type { HarnessConfig, HandoffGranularity, Phase } from "./core/types.ts";
10
10
  import { loadFeatureList, tasksForPhase, type FlatTask, flattenTasks } from "./core/featureList.ts";
11
- import { loadRouterConfig } from "./modelRouter.ts";
11
+ import { loadRouterConfig, resolveModel } from "./modelRouter.ts";
12
12
  import { spawnIsolatedWorker, type SpawnWorkerResult } from "./worker.ts";
13
13
  import { runIdFor } from "./runState.ts";
14
14
  import { loadConfig } from "./core/config.ts";
15
+ import { loadRunState } from "./core/runState.ts";
16
+ import { isCapExceeded } from "./daemon/budget.ts";
15
17
 
16
18
  /** Difficulty ranking — higher wins when collapsing a bucket to its hardest. */
17
19
  const DIFFICULTY_RANK: Record<string, number> = { easy: 1, moderate: 2, difficult: 3 };
@@ -20,10 +22,13 @@ function hardestDifficulty(tasks: Array<{ difficulty?: string }>): string | unde
20
22
  let best: string | undefined;
21
23
  let bestRank = -1;
22
24
  for (const t of tasks) {
23
- const d = (t as { difficulty?: string }).difficulty;
25
+ const d = t.difficulty;
24
26
  if (!d) continue;
25
27
  const r = DIFFICULTY_RANK[d] ?? -1;
26
- if (r > bestRank) { bestRank = r; best = d; }
28
+ if (r > bestRank) {
29
+ bestRank = r;
30
+ best = d;
31
+ }
27
32
  }
28
33
  return best;
29
34
  }
@@ -47,7 +52,6 @@ function goalIdForTask(task: FlatTask, list: import("./core/types.ts").FeatureLi
47
52
  * - handoff phase → all tasks in that phase share one model (hardest in phase)
48
53
  * - handoff feature → tasks in feature share hardest in feature
49
54
  * - handoff task → subtasks share their parent task's model
50
- * Shown in wizard + dashboard so the user knows the trade-off.
51
55
  */
52
56
  export function effectiveDifficultyForTask(
53
57
  task: FlatTask,
@@ -56,16 +60,14 @@ export function effectiveDifficultyForTask(
56
60
  ): string | undefined {
57
61
  const own = (task as { difficulty?: string }).difficulty;
58
62
  if (handoff === "task" || handoff === "subtask" || handoff === "off") {
59
- // task/subtask: subtasks are not separate tasks, so they inherit the task
60
- // off: one session for whole run — hardest in whole plan (most conservative)
61
63
  if (handoff === "off") {
62
64
  const globalHardest = hardestDifficulty(flattenTasks(list) as unknown as Array<{ difficulty?: string }>);
63
65
  return globalHardest ?? own;
64
66
  }
65
67
  return own;
66
68
  }
67
- let bucket: FlatTask[] = [];
68
69
  const all = flattenTasks(list);
70
+ let bucket: FlatTask[] = [];
69
71
  if (handoff === "phase") {
70
72
  const phase = (task as { effectivePhase?: string }).effectivePhase ?? "build";
71
73
  bucket = all.filter((t) => (t as { effectivePhase?: string }).effectivePhase === phase);
@@ -118,170 +120,142 @@ export type PickOpts = {
118
120
  exclude?: Set<string>;
119
121
  };
120
122
 
121
- /** Snapshot a worker's attempt dir for the widget/dashboard. */
122
- export type WorkerSnapshot = {
123
- featureId: string;
124
- taskId: string;
125
- compositeKey: string;
126
- attemptDir: string;
127
- attempt: number;
128
- state: "running" | "done" | "failed";
129
- outputTail: string;
130
- askedAt?: string;
131
- };
123
+ // ── pick helpers ────────────────────────────────────────────────────────────
132
124
 
133
- /** Tail a worker attempt's output.log (best-effort, never throws). */
134
- export function tailWorkerOutput(attemptDir: string, bytes = 3000): string {
125
+ function isBudgetFull(targetDir: string): boolean {
135
126
  try {
136
- const { readFileSync, existsSync } = require("node:fs") as typeof import("node:fs");
137
- const p = (require("node:path") as typeof import("node:path")).join(attemptDir, "output.log");
138
- if (!existsSync(p)) return "";
139
- const raw = readFileSync(p, "utf-8") as string;
140
- return raw.slice(-bytes);
141
- } catch { return ""; }
127
+ const rs = loadRunState(targetDir);
128
+ if (!rs?.budget) return false;
129
+ return isCapExceeded(rs.budget as never).exceeded;
130
+ } catch {
131
+ return false;
132
+ }
142
133
  }
143
134
 
144
- export function listWorkers(targetDir: string, runId?: string): WorkerSnapshot[] {
145
- try {
146
- const { readdirSync, existsSync } = require("node:fs") as typeof import("node:fs");
147
- const path = require("node:path") as typeof import("node:path");
148
- const root = path.resolve(targetDir, "tmp/infinity-harness", runId ?? "");
149
- const roots: string[] = [];
150
- if (runId) {
151
- roots.push(path.resolve(targetDir, "tmp/infinity-harness", runId));
152
- } else {
153
- // all runs under tmp/infinity-harness
154
- const base = path.resolve(targetDir, "tmp/infinity-harness");
155
- if (existsSync(base)) for (const e of readdirSync(base,{ withFileTypes: true } as any)) if((e as any).isDirectory()) roots.push(path.join(base,(e as any).name));
156
- }
157
- const out: WorkerSnapshot[] = [];
158
- for (const run of roots) {
159
- if (!existsSync(run)) continue;
160
- // run/feature/task/attempt-N as created by worker.ts
161
- for (const f of readdirSync(run,{withFileTypes:true} as any) as any[]) {
162
- if(!f.isDirectory()) continue;
163
- const feat = path.join(run, f.name);
164
- for (const t of readdirSync(feat,{withFileTypes:true} as any) as any[]) {
165
- if(!t.isDirectory()) continue;
166
- const taskRoot = path.join(feat, t.name);
167
- const attempts = readdirSync(taskRoot,{withFileTypes:true} as any) as any[];
168
- for (const a of attempts) {
169
- if(!a.isDirectory() || !a.name.startsWith("attempt-")) continue;
170
- const attemptDir = path.join(taskRoot, a.name);
171
- const n = Number.parseInt(a.name.replace("attempt-",""),10) || 0;
172
- const tail = tailWorkerOutput(attemptDir, 800);
173
- out.push({
174
- featureId: f.name,
175
- taskId: t.name,
176
- compositeKey: `${f.name}/${t.name}`,
177
- attemptDir,
178
- attempt: n,
179
- state: "running",
180
- outputTail: tail,
181
- });
182
- }
183
- }
184
- }
185
- }
186
- return out;
187
- } catch { return []; }
135
+ function hasSerializeTask(tasks: FlatTask[]): boolean {
136
+ return tasks.some(
137
+ (t) => (t as { serialize?: unknown }).serialize === true && (t.status === "pending" || t.status === "in_progress" || t.status === "rework"),
138
+ );
188
139
  }
189
140
 
190
- export function nextModelForTask(targetDir: string, difficulty?: string, taskId?: string, key?: string): { model?: string; thinking?: string } {
191
- try {
192
- const { resolveModel, resolveThinking } = require("./modelRouter.ts") as typeof import("./modelRouter.ts");
193
- return {
194
- model: resolveModel({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
195
- thinking: resolveThinking({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
196
- };
197
- } catch { return {}; }
141
+ function pickSerializeTask(tasks: FlatTask[], byKey: Map<string, FlatTask>, exclude?: Set<string>): FlatTask | null {
142
+ const serializeTask = tasks.find(
143
+ (t) =>
144
+ (t as { serialize?: unknown }).serialize === true &&
145
+ t.status === "pending" &&
146
+ (t.dependsOn ?? []).every((d) => {
147
+ const dep = byKey.get(d);
148
+ return dep !== undefined && dep.status === "complete";
149
+ }) &&
150
+ !exclude?.has(t.compositeKey) &&
151
+ !exclude?.has(t.id),
152
+ );
153
+ return serializeTask ?? null;
198
154
  }
199
155
 
200
- export function pickRunnableTasks(opts: PickOpts): FlatTask[] {
201
- const { list } = loadFeatureList(opts.targetDir);
202
- const phase = (opts.phase ?? null) as Phase | null;
203
- // Phase-filtered pool when phase given, else all tasks across phases.
204
- const all: FlatTask[] = phase ? tasksForPhase(list, phase) : (flattenTasks(list) as FlatTask[]); // imported above
205
- // Build key map for dep check
206
- const byKey = new Map<string, FlatTask>();
207
- for (const t of all) {
208
- byKey.set(t.compositeKey, t);
209
- byKey.set(t.id, t);
210
- if (t.key) byKey.set(t.key, t);
211
- }
212
- const eligible = all.filter((t) => {
156
+ function isSerializeBlocked(tasks: FlatTask[]): boolean {
157
+ return tasks.some((t) => (t as { serialize?: unknown }).serialize === true && (t.status === "in_progress" || t.status === "rework"));
158
+ }
159
+
160
+ function eligibleTasks(tasks: FlatTask[], byKey: Map<string, FlatTask>, exclude?: Set<string>): FlatTask[] {
161
+ return tasks.filter((t) => {
213
162
  if (t.status !== "pending") return false;
214
- if (opts.exclude?.has(t.compositeKey) || opts.exclude?.has(t.id)) return false;
163
+ if (exclude?.has(t.compositeKey) || exclude?.has(t.id)) return false;
215
164
  const deps = t.dependsOn ?? [];
216
165
  return deps.every((d) => {
217
166
  const dep = byKey.get(d);
218
- return dep && dep.status === "complete";
167
+ return dep !== undefined && dep.status === "complete";
219
168
  });
220
169
  });
170
+ }
221
171
 
222
- // Group by granularity to enforce breadth limit.
223
- const level = opts.parallelAt ?? "off";
224
- if (level === "off" || level === "goal") {
225
- // one task at a time (or one goal pipeline)
226
- return eligible.slice(0, 1);
172
+ function groupKeyFor(task: FlatTask, level: HandoffGranularity, list: import("./core/types.ts").FeatureList): string {
173
+ if (level === "task" || level === "subtask") return task.compositeKey;
174
+ if (level === "feature") return task.featureId;
175
+ if (level === "sprint") {
176
+ const feat = list.features.find((f) => f.id === task.featureId);
177
+ return (feat as { sprintId?: string } | undefined)?.sprintId ?? task.featureId;
227
178
  }
228
- const keyFor = (t: FlatTask): string => {
229
- // Resolve sprints/goals via feature list lookups when needed.
230
- if (level === "task" || level === "subtask") return t.compositeKey;
231
- if (level === "feature") return t.featureId;
232
- if (level === "sprint") {
233
- const feat = list.features.find((f) => f.id === t.featureId);
234
- return (feat as { sprintId?: string } | undefined)?.sprintId ?? t.featureId;
235
- }
236
- if (level === "phase") return t.effectivePhase ?? "build";
237
- return t.compositeKey;
238
- };
239
- const groups = new Map<string, FlatTask[]>();
240
- for (const t of eligible) {
241
- const k = keyFor(t);
242
- if (!groups.has(k)) groups.set(k, []);
243
- groups.get(k)!.push(t);
244
- }
245
- // Take one per group breadth-first, up to maxWorkers.
246
- const max = Math.max(1, Math.min(16, opts.maxWorkers ?? 3));
179
+ if (level === "phase") return task.effectivePhase ?? "build";
180
+ return task.compositeKey;
181
+ }
182
+
183
+ function roundRobin(groups: Map<string, FlatTask[]>, max: number): FlatTask[] {
247
184
  const out: FlatTask[] = [];
248
- const iters = groups.values();
249
- // Round-robin one per group.
250
185
  const groupArrays = [...groups.values()];
251
186
  let idx = 0;
252
187
  while (out.length < max && groupArrays.some((g) => g.length > 0)) {
253
188
  const g = groupArrays[idx % groupArrays.length]!;
254
- if (g.length > 0) {
255
- const task = g.shift()!;
256
- out.push(task);
257
- }
189
+ if (g.length > 0) out.push(g.shift()!);
258
190
  idx++;
259
191
  if (idx > max * groupArrays.length + 10) break;
260
192
  }
261
193
  return out.slice(0, max);
262
194
  }
263
195
 
196
+ export function pickRunnableTasks(opts: PickOpts): FlatTask[] {
197
+ const { list } = loadFeatureList(opts.targetDir);
198
+ const phase = (opts.phase ?? null) as Phase | null;
199
+ const all: FlatTask[] = phase ? tasksForPhase(list, phase) : (flattenTasks(list) as FlatTask[]);
200
+ const byKey = new Map<string, FlatTask>();
201
+ for (const t of all) {
202
+ byKey.set(t.compositeKey, t);
203
+ byKey.set(t.id, t);
204
+ if (t.key) byKey.set(t.key, t);
205
+ }
206
+
207
+ if (hasSerializeTask(all)) {
208
+ const serializeTask = pickSerializeTask(all, byKey, opts.exclude);
209
+ if (serializeTask) return [serializeTask];
210
+ if (isSerializeBlocked(all)) return [];
211
+ }
212
+
213
+ if (isBudgetFull(opts.targetDir)) return [];
214
+
215
+ const eligible = eligibleTasks(all, byKey, opts.exclude);
216
+ const level = opts.parallelAt ?? "off";
217
+ if (level === "off" || level === "goal") return eligible.slice(0, 1);
218
+
219
+ const groups = new Map<string, FlatTask[]>();
220
+ for (const t of eligible) {
221
+ const k = groupKeyFor(t, level, list);
222
+ if (!groups.has(k)) groups.set(k, []);
223
+ groups.get(k)!.push(t);
224
+ }
225
+ const max = Math.max(1, Math.min(16, opts.maxWorkers ?? 3));
226
+ return roundRobin(groups, max);
227
+ }
228
+
264
229
  export async function spawnWorkers(
265
230
  targetDir: string,
266
231
  tasks: FlatTask[],
267
232
  opts: { runId?: string; promptFor: (t: FlatTask) => string; command?: string } = { promptFor: () => "" },
268
233
  ): Promise<SpawnWorkerResult[]> {
269
- const { resolveModel } = await import("./modelRouter.ts");
270
234
  const runId = opts?.runId ?? runIdFor(targetDir, "sched");
271
- // handoff bucket determines effective difficulty — read once
272
235
  let handoff: HandoffGranularity = "task";
273
- try { handoff = (loadConfig(targetDir).config.session?.handoff as HandoffGranularity) ?? "task"; } catch {}
274
- const allList = (()=>{ try{ return loadFeatureList(targetDir).list; }catch{ return null as unknown as import("./core/types.ts").FeatureList; } })();
236
+ try {
237
+ handoff = (loadConfig(targetDir).config.session?.handoff as HandoffGranularity) ?? "task";
238
+ } catch {}
239
+ let allList: import("./core/types.ts").FeatureList | null = null;
240
+ try {
241
+ allList = loadFeatureList(targetDir).list;
242
+ } catch {
243
+ allList = null;
244
+ }
275
245
  const results: SpawnWorkerResult[] = [];
276
246
  for (const t of tasks) {
277
247
  const prompt = opts.promptFor(t);
278
248
  const router = loadRouterConfig(targetDir);
279
249
  let modelHint: string | undefined;
280
- if (router.enabled) {
250
+ if (router.enabled && allList) {
281
251
  try {
282
- const effDiff = allList ? effectiveDifficultyForTask(t, handoff, allList) : (t as { difficulty?: string }).difficulty;
252
+ const effDiff = effectiveDifficultyForTask(t, handoff, allList);
283
253
  modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: effDiff as string | undefined, id: t.id, key: t.compositeKey } });
284
254
  } catch {}
255
+ } else if (router.enabled) {
256
+ try {
257
+ modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: (t as { difficulty?: string }).difficulty as string | undefined, id: t.id, key: t.compositeKey } });
258
+ } catch {}
285
259
  }
286
260
  const res = await spawnIsolatedWorker({
287
261
  projectDir: targetDir,
@@ -297,12 +271,18 @@ export async function spawnWorkers(
297
271
  return results;
298
272
  }
299
273
 
300
- export function executionPolicyOf(config: HarnessConfig): { parallelAt: HandoffGranularity; maxWorkers: number } {
301
- const e = (config.execution ?? {}) as Partial<{ parallelAt: unknown; maxWorkers: unknown }>;
302
- const at = typeof e.parallelAt === "string" && (["off","goal","phase","sprint","feature","task","subtask"] as const).includes(e.parallelAt as HandoffGranularity)
303
- ? (e.parallelAt as HandoffGranularity)
304
- : "task";
274
+ export function executionPolicyOf(config: HarnessConfig): {
275
+ engine: import("./core/types.ts").ExecutionEngine;
276
+ parallelAt: HandoffGranularity;
277
+ maxWorkers: number;
278
+ } {
279
+ const e = (config.execution ?? {}) as Partial<{ engine: unknown; parallelAt: unknown; maxWorkers: unknown }>;
280
+ const engine: import("./core/types.ts").ExecutionEngine = e.engine === "main-session" ? "main-session" : "background";
281
+ const at =
282
+ typeof e.parallelAt === "string" && (["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const).includes(e.parallelAt as HandoffGranularity)
283
+ ? (e.parallelAt as HandoffGranularity)
284
+ : "task";
305
285
  const raw = typeof e.maxWorkers === "number" ? e.maxWorkers : 3;
306
286
  const maxWorkers = Math.max(1, Math.min(16, Math.floor(raw)));
307
- return { parallelAt: at, maxWorkers };
287
+ return { engine, parallelAt: at, maxWorkers };
308
288
  }