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/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
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 =
|
|
25
|
+
const d = t.difficulty;
|
|
24
26
|
if (!d) continue;
|
|
25
27
|
const r = DIFFICULTY_RANK[d] ?? -1;
|
|
26
|
-
if (r > bestRank) {
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
export function tailWorkerOutput(attemptDir: string, bytes = 3000): string {
|
|
125
|
+
function isBudgetFull(targetDir: string): boolean {
|
|
135
126
|
try {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return
|
|
141
|
-
}
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 (
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
if (level === "
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 {
|
|
274
|
-
|
|
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 =
|
|
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 =
|
|
310
|
-
|
|
311
|
-
|
|
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.
|
|
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
|
-
|
|
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);
|