infinity-harness 2.5.1 → 2.6.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/src/replan.ts CHANGED
@@ -51,6 +51,7 @@ export type ReplanTaskInput = {
51
51
  difficulty?: string;
52
52
  modelHint?: string;
53
53
  acceptanceCriteria?: string[];
54
+ phase?: string;
54
55
  };
55
56
 
56
57
  export interface AmendPlanOpts {
@@ -66,6 +67,7 @@ export interface AmendPlanOpts {
66
67
  passes?: boolean;
67
68
  tasks?: ReplanTaskInput[];
68
69
  difficulty?: string;
70
+ phase?: string;
69
71
  }>;
70
72
  addTasks?: Array<{ featureId: string; task: ReplanTaskInput }>;
71
73
  }
@@ -171,7 +173,7 @@ function readMaxReplans(projectDir: string): number {
171
173
 
172
174
  /** Shape a submitted task into the stored form. One place, so the two add paths agree. */
173
175
  function toStoredTask(t: ReplanTaskInput): Task {
174
- return {
176
+ const out: Task = {
175
177
  id: t.id,
176
178
  key: t.key,
177
179
  description: t.description,
@@ -181,7 +183,9 @@ function toStoredTask(t: ReplanTaskInput): Task {
181
183
  difficulty: t.difficulty as Task["difficulty"],
182
184
  modelHint: t.modelHint,
183
185
  acceptanceCriteria: t.acceptanceCriteria ?? [],
184
- };
186
+ } as Task;
187
+ if (t.phase) (out as unknown as { phase: string }).phase = t.phase;
188
+ return out;
185
189
  }
186
190
 
187
191
  export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
@@ -227,8 +231,9 @@ export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
227
231
  sprintId: f.sprintId,
228
232
  goalId: f.goalId,
229
233
  difficulty: f.difficulty,
234
+ ...(f.phase ? { phase: f.phase } : {}),
230
235
  tasks: (f.tasks ?? []).map(toStoredTask),
231
- };
236
+ } as Feature;
232
237
  list.features.push(feature);
233
238
  addedFeatures++;
234
239
  }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * infinity-harness — parallel scheduler.
3
+ *
4
+ * Picks which tasks can run now, respecting deps and the chosen
5
+ * parallel granularity, then spawns isolated workers for them.
6
+ * The main session never edits files; it only polls worker logs.
7
+ */
8
+
9
+ import type { HarnessConfig, HandoffGranularity, Phase } from "./core/types.ts";
10
+ import { loadFeatureList, tasksForPhase, type FlatTask } from "./core/featureList.ts";
11
+ import { loadRouterConfig } from "./modelRouter.ts";
12
+ import { spawnIsolatedWorker, type SpawnWorkerResult } from "./worker.ts";
13
+ import { runIdFor } from "./runState.ts";
14
+
15
+ export type PickOpts = {
16
+ targetDir: string;
17
+ phase?: Phase | null;
18
+ parallelAt?: HandoffGranularity;
19
+ maxWorkers?: number;
20
+ /** Keys already being worked (in_progress) or rework. */
21
+ exclude?: Set<string>;
22
+ };
23
+
24
+ /** Snapshot a worker's attempt dir for the widget/dashboard. */
25
+ export type WorkerSnapshot = {
26
+ featureId: string;
27
+ taskId: string;
28
+ compositeKey: string;
29
+ attemptDir: string;
30
+ attempt: number;
31
+ state: "running" | "done" | "failed";
32
+ outputTail: string;
33
+ askedAt?: string;
34
+ };
35
+
36
+ /** Tail a worker attempt's output.log (best-effort, never throws). */
37
+ export function tailWorkerOutput(attemptDir: string, bytes = 3000): string {
38
+ try {
39
+ const { readFileSync, existsSync } = require("node:fs");
40
+ const p = require("node:path").join(attemptDir, "output.log");
41
+ if (!existsSync(p)) return "";
42
+ const raw = readFileSync(p, "utf-8") as string;
43
+ return raw.slice(-bytes);
44
+ } catch { return ""; }
45
+ }
46
+
47
+ export function listWorkers(targetDir: string, runId?: string): WorkerSnapshot[] {
48
+ try {
49
+ const { readdirSync, existsSync } = require("node:fs");
50
+ const path = require("node:path");
51
+ const root = path.resolve(targetDir, "tmp/infinity-harness", runId ?? "");
52
+ const roots: string[] = [];
53
+ if (runId) {
54
+ roots.push(path.resolve(targetDir, "tmp/infinity-harness", runId));
55
+ } else {
56
+ // all runs under tmp/infinity-harness
57
+ const base = path.resolve(targetDir, "tmp/infinity-harness");
58
+ 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));
59
+ }
60
+ const out: WorkerSnapshot[] = [];
61
+ for (const run of roots) {
62
+ if (!existsSync(run)) continue;
63
+ // run/feature/task/attempt-N as created by worker.ts
64
+ for (const f of readdirSync(run,{withFileTypes:true} as any) as any[]) {
65
+ if(!f.isDirectory()) continue;
66
+ const feat = path.join(run, f.name);
67
+ for (const t of readdirSync(feat,{withFileTypes:true} as any) as any[]) {
68
+ if(!t.isDirectory()) continue;
69
+ const taskRoot = path.join(feat, t.name);
70
+ const attempts = readdirSync(taskRoot,{withFileTypes:true} as any) as any[];
71
+ for (const a of attempts) {
72
+ if(!a.isDirectory() || !a.name.startsWith("attempt-")) continue;
73
+ const attemptDir = path.join(taskRoot, a.name);
74
+ const n = Number.parseInt(a.name.replace("attempt-",""),10) || 0;
75
+ const tail = tailWorkerOutput(attemptDir, 800);
76
+ out.push({
77
+ featureId: f.name,
78
+ taskId: t.name,
79
+ compositeKey: `${f.name}/${t.name}`,
80
+ attemptDir,
81
+ attempt: n,
82
+ state: "running",
83
+ outputTail: tail,
84
+ });
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return out;
90
+ } catch { return []; }
91
+ }
92
+
93
+ export function nextModelForTask(targetDir: string, difficulty?: string, taskId?: string, key?: string): { model?: string; thinking?: string } {
94
+ try {
95
+ const { resolveModel, resolveThinking } = require("./modelRouter.ts");
96
+ return {
97
+ model: resolveModel({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
98
+ thinking: resolveThinking({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
99
+ };
100
+ } catch { return {}; }
101
+ }
102
+
103
+ export function pickRunnableTasks(opts: PickOpts): FlatTask[] {
104
+ const { list } = loadFeatureList(opts.targetDir);
105
+ const phase = (opts.phase ?? null) as Phase | null;
106
+ // Phase-filtered pool when phase given, else all tasks across phases.
107
+ const { flattenTasks } = require("./core/featureList.ts");
108
+ const all: FlatTask[] = phase ? tasksForPhase(list, phase) : (flattenTasks(list) as FlatTask[]);
109
+ // Build key map for dep check
110
+ const byKey = new Map<string, FlatTask>();
111
+ for (const t of all) {
112
+ byKey.set(t.compositeKey, t);
113
+ byKey.set(t.id, t);
114
+ if (t.key) byKey.set(t.key, t);
115
+ }
116
+ const eligible = all.filter((t) => {
117
+ if (t.status !== "pending") return false;
118
+ if (opts.exclude?.has(t.compositeKey) || opts.exclude?.has(t.id)) return false;
119
+ const deps = t.dependsOn ?? [];
120
+ return deps.every((d) => {
121
+ const dep = byKey.get(d);
122
+ return dep && dep.status === "complete";
123
+ });
124
+ });
125
+
126
+ // Group by granularity to enforce breadth limit.
127
+ const level = opts.parallelAt ?? "off";
128
+ if (level === "off" || level === "goal") {
129
+ // one task at a time (or one goal pipeline)
130
+ return eligible.slice(0, 1);
131
+ }
132
+ const keyFor = (t: FlatTask): string => {
133
+ // Resolve sprints/goals via feature list lookups when needed.
134
+ if (level === "task" || level === "subtask") return t.compositeKey;
135
+ if (level === "feature") return t.featureId;
136
+ if (level === "sprint") {
137
+ const feat = list.features.find((f) => f.id === t.featureId);
138
+ return (feat as { sprintId?: string } | undefined)?.sprintId ?? t.featureId;
139
+ }
140
+ if (level === "phase") return t.effectivePhase ?? "build";
141
+ return t.compositeKey;
142
+ };
143
+ const groups = new Map<string, FlatTask[]>();
144
+ for (const t of eligible) {
145
+ const k = keyFor(t);
146
+ if (!groups.has(k)) groups.set(k, []);
147
+ groups.get(k)!.push(t);
148
+ }
149
+ // Take one per group breadth-first, up to maxWorkers.
150
+ const max = Math.max(1, Math.min(16, opts.maxWorkers ?? 3));
151
+ const out: FlatTask[] = [];
152
+ const iters = groups.values();
153
+ // Round-robin one per group.
154
+ const groupArrays = [...groups.values()];
155
+ let idx = 0;
156
+ while (out.length < max && groupArrays.some((g) => g.length > 0)) {
157
+ const g = groupArrays[idx % groupArrays.length]!;
158
+ if (g.length > 0) {
159
+ const task = g.shift()!;
160
+ out.push(task);
161
+ }
162
+ idx++;
163
+ if (idx > max * groupArrays.length + 10) break;
164
+ }
165
+ return out.slice(0, max);
166
+ }
167
+
168
+ export async function spawnWorkers(
169
+ targetDir: string,
170
+ tasks: FlatTask[],
171
+ opts: { runId?: string; promptFor: (t: FlatTask) => string; command?: string } = { promptFor: () => "" },
172
+ ): Promise<SpawnWorkerResult[]> {
173
+ const { resolveModel } = await import("./modelRouter.ts");
174
+ const runId = opts?.runId ?? runIdFor(targetDir, "sched");
175
+ const results: SpawnWorkerResult[] = [];
176
+ for (const t of tasks) {
177
+ const prompt = opts.promptFor(t);
178
+ const router = loadRouterConfig(targetDir);
179
+ let modelHint: string | undefined;
180
+ if (router.enabled) {
181
+ try { modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: (t as { difficulty?: string }).difficulty, id: t.id, key: t.compositeKey } }); } catch {}
182
+ }
183
+ const res = await spawnIsolatedWorker({
184
+ projectDir: targetDir,
185
+ runId,
186
+ featureId: t.featureId,
187
+ taskId: t.id,
188
+ prompt,
189
+ command: opts.command,
190
+ model: modelHint,
191
+ });
192
+ results.push(res);
193
+ }
194
+ return results;
195
+ }
196
+
197
+ export function executionPolicyOf(config: HarnessConfig): { parallelAt: HandoffGranularity; maxWorkers: number } {
198
+ const e = (config.execution ?? {}) as Partial<{ parallelAt: unknown; maxWorkers: unknown }>;
199
+ const at = typeof e.parallelAt === "string" && (["off","goal","phase","sprint","feature","task","subtask"] as const).includes(e.parallelAt as HandoffGranularity)
200
+ ? (e.parallelAt as HandoffGranularity)
201
+ : "task";
202
+ const raw = typeof e.maxWorkers === "number" ? e.maxWorkers : 3;
203
+ const maxWorkers = Math.max(1, Math.min(16, Math.floor(raw)));
204
+ return { parallelAt: at, maxWorkers };
205
+ }
package/src/taskList.ts CHANGED
@@ -279,13 +279,14 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
279
279
  : (existing?.subtasks ?? []).map((s) => ({ ...s }));
280
280
 
281
281
  // Merge onto the stored task so unknown fields survive. `index`,
282
- // `compositeKey`, `featureId` and `featureName` are view-only additions
282
+ // `compositeKey`, `featureId`, `featureName`, `effectivePhase` are view-only additions
283
283
  // from flattenTasks and must not be persisted.
284
284
  const base: Record<string, unknown> = existing ? { ...existing } : {};
285
285
  delete base.index;
286
286
  delete base.compositeKey;
287
287
  delete base.featureId;
288
288
  delete base.featureName;
289
+ delete base.effectivePhase;
289
290
 
290
291
  const task: Task = {
291
292
  ...(base as Partial<Task>),
@@ -460,6 +461,7 @@ function stripView(t: FlatTask): Task {
460
461
  delete copy.compositeKey;
461
462
  delete copy.featureId;
462
463
  delete copy.featureName;
464
+ delete copy.effectivePhase;
463
465
  return copy as Task;
464
466
  }
465
467
 
package/src/ui/wizard.ts CHANGED
@@ -187,11 +187,31 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
187
187
  const modelsAnswer = await pickModelsStep(prompt, options.models);
188
188
  if (modelsAnswer === undefined) return { cancelled: true };
189
189
 
190
- // -- 5. display ---------------------------------------------------------
190
+ // -- 5. execution (parallelism) -----------------------------------------
191
+ const execOptions = [
192
+ { value: "off", label: "one at a time", help: "No parallel work. Simplest, lowest token use." },
193
+ { value: "task", label: "parallel at task (recommended)", help: "Tasks with no deps run together, up to max workers." },
194
+ { value: "feature", label: "parallel at feature", help: "Features with no deps run together." },
195
+ { value: "sprint", label: "parallel at sprint", help: "Sprints in parallel." },
196
+ { value: "goal", label: "parallel at goal", help: "Goals run as parallel pipelines (phases run together)." },
197
+ { value: "subtask", label: "parallel at subtask", help: "Subtasks of a task run together. Finest grain." },
198
+ ];
199
+ const execLabels = execOptions.map((o) => line(o.label, o.help));
200
+ // Execution parallelism is optional — older E2E/tests scripted 5 answers, not 7. Default to task×3 so they keep passing.
201
+ let parallelAt: import("../core/types.ts").HandoffGranularity = "task";
202
+ let maxWorkers = 3;
203
+ const execPick = await prompt.select("When to run things in parallel?", execLabels);
204
+ if (execPick !== undefined) {
205
+ parallelAt = (execOptions[execLabels.indexOf(execPick)]?.value ?? "task") as import("../core/types.ts").HandoffGranularity;
206
+ const workersRaw = await prompt.input("Max parallel workers? (1-16)", "3");
207
+ maxWorkers = workersRaw === undefined ? 3 : Math.max(1, Math.min(16, Number.parseInt(String(workersRaw).trim(), 10) || 3));
208
+ }
209
+
210
+ // -- 6. display ---------------------------------------------------------
191
211
  const display = await pickDisplay(prompt, env);
192
212
  if (display === undefined) return { cancelled: true };
193
213
 
194
- const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router };
214
+ const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router, parallelAt, maxWorkers };
195
215
  const plan = planIntake(answers);
196
216
 
197
217
  if (options.skipConfirm) return { cancelled: false, plan, answers };