infinity-harness 2.7.0 → 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.
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";
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,
@@ -303,12 +277,11 @@ export function executionPolicyOf(config: HarnessConfig): {
303
277
  maxWorkers: number;
304
278
  } {
305
279
  const e = (config.execution ?? {}) as Partial<{ engine: unknown; parallelAt: unknown; maxWorkers: unknown }>;
306
- // Anything but the explicit legacy value means background: a config written
307
- // before this setting existed should get the new behaviour, not the bug.
308
280
  const engine: import("./core/types.ts").ExecutionEngine = e.engine === "main-session" ? "main-session" : "background";
309
- const at = typeof e.parallelAt === "string" && (["off","goal","phase","sprint","feature","task","subtask"] as const).includes(e.parallelAt as HandoffGranularity)
310
- ? (e.parallelAt as HandoffGranularity)
311
- : "task";
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";
312
285
  const raw = typeof e.maxWorkers === "number" ? e.maxWorkers : 3;
313
286
  const maxWorkers = Math.max(1, Math.min(16, Math.floor(raw)));
314
287
  return { engine, parallelAt: at, maxWorkers };
package/src/taskList.ts CHANGED
@@ -33,8 +33,9 @@ import {
33
33
  validateKey,
34
34
  type FlatTask,
35
35
  } from "./core/featureList.ts";
36
- import { featureListPath } from "./core/paths.ts";
36
+ import { featureListPath, planPath } from "./core/paths.ts";
37
37
  import { withLockSync } from "./core/lock.ts";
38
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
38
39
 
39
40
  /** One task as submitted by the agent. Only `key` is mandatory. */
40
41
  export type TaskInput = {
@@ -47,6 +48,8 @@ export type TaskInput = {
47
48
  difficulty?: string;
48
49
  modelHint?: string;
49
50
  criteria?: string[];
51
+ phase?: string;
52
+ serialize?: boolean;
50
53
  };
51
54
 
52
55
  /**
@@ -300,7 +303,20 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
300
303
 
301
304
  if (raw.difficulty !== undefined) task.difficulty = raw.difficulty as Task["difficulty"];
302
305
  if (raw.modelHint !== undefined) task.modelHint = raw.modelHint;
303
- if (raw.criteria !== undefined) task.criteria = raw.criteria;
306
+ if (raw.phase !== undefined) {
307
+ const pa = String(raw.phase).trim().toLowerCase();
308
+ const VALID_PHASES = ["init","research","define","plan","build","verify","simplify","review","ship"];
309
+ if (!VALID_PHASES.includes(pa)) throw new ValidationError(`${path}.phase is invalid: ${String(raw.phase)}`);
310
+ (task as unknown as { phase: string }).phase = pa;
311
+ }
312
+ if (raw.serialize !== undefined) {
313
+ (task as unknown as { serialize: boolean }).serialize = Boolean(raw.serialize);
314
+ }
315
+ if (raw.criteria !== undefined) {
316
+ if (!Array.isArray(raw.criteria)) throw new ValidationError(`${path}.criteria must be an array`);
317
+ const criteria = validateCriteria(raw.criteria, `${path}.criteria`);
318
+ (task as unknown as { criteria: string[] }).criteria = criteria;
319
+ }
304
320
 
305
321
  staged.push({ featureId, task, compositeKey: key });
306
322
  }
@@ -479,7 +495,29 @@ function stripView(t: FlatTask): Task {
479
495
  * losing an edit.
480
496
  */
481
497
  export function writeTaskList(targetDir: string, input: ApplyInput): ApplyResult {
482
- return withLockSync(featureListPath(targetDir), () => {
498
+ // Acquire a lock that covers both legacy and canonical to avoid racing with
499
+ // hand-edits that touch legacy. We lock canonical (which is plan.json), then
500
+ // sync any legacy hand-edit before reading.
501
+ return withLockSync(planPath(targetDir), () => {
502
+ // Back-compat: tests modify legacy via readFileSync+writeFileSync then call writeTaskList.
503
+ // Since loadFeatureList prefers canonical when both exist, a legacy-only hand-edit would be lost.
504
+ // Detect and mirror a legacy that differs from canonical.
505
+ const legacy = featureListPath(targetDir);
506
+ const canonical = planPath(targetDir);
507
+ if (existsSync(legacy) && existsSync(canonical)) {
508
+ try {
509
+ const rawLegacy = readFileSync(legacy, "utf-8");
510
+ const rawCanon = readFileSync(canonical, "utf-8");
511
+ if (rawLegacy !== rawCanon) {
512
+ try {
513
+ const p = JSON.parse(rawLegacy);
514
+ if (p && typeof p === "object" && Array.isArray(p.features) && typeof p.baseRevision === "number") {
515
+ writeFileSync(canonical, rawLegacy, "utf-8");
516
+ }
517
+ } catch {}
518
+ }
519
+ } catch {}
520
+ }
483
521
  const { list } = loadFeatureList(targetDir);
484
522
  const result = applyTaskList(list, input);
485
523
  if (result.changed) saveFeatureList(targetDir, result.list);