infinity-harness 2.0.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 +114 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/extensions/infinity-harness/index.ts +870 -0
- package/harness/docs/ARCHITECTURE.md +159 -0
- package/harness/docs/CONSTRAINTS.md +19 -0
- package/harness/docs/DECISIONS.md +107 -0
- package/harness/docs/DOMAIN.md +13 -0
- package/harness/docs/agents/evaluator.md +14 -0
- package/harness/docs/agents/generator.md +13 -0
- package/harness/docs/agents/planner.md +13 -0
- package/harness/docs/agents/simplifier.md +13 -0
- package/harness/docs/api-patterns.md +23 -0
- package/harness/docs/phases/build.md +47 -0
- package/harness/docs/phases/define.md +58 -0
- package/harness/docs/phases/plan.md +50 -0
- package/harness/docs/phases/review.md +47 -0
- package/harness/docs/phases/ship.md +43 -0
- package/harness/docs/phases/simplify.md +45 -0
- package/harness/docs/phases/verify.md +46 -0
- package/harness/model-router.json +28 -0
- package/harness/skills/README.md +60 -0
- package/harness/skills/auth-security.md +56 -0
- package/harness/skills/building-mcp-servers.md +70 -0
- package/harness/skills/building-tools.md +60 -0
- package/harness/skills/capability-acquisition.md +72 -0
- package/harness/skills/cli-design.md +55 -0
- package/harness/skills/code-review.md +57 -0
- package/harness/skills/codebase-design.md +70 -0
- package/harness/skills/concurrency-async.md +61 -0
- package/harness/skills/config-and-secrets.md +52 -0
- package/harness/skills/context-hygiene.md +51 -0
- package/harness/skills/databases.md +63 -0
- package/harness/skills/diagnosing-bugs.md +84 -0
- package/harness/skills/domain-modeling.md +65 -0
- package/harness/skills/error-handling-logging.md +56 -0
- package/harness/skills/frontend-ui.md +56 -0
- package/harness/skills/grilling.md +48 -0
- package/harness/skills/http-apis.md +60 -0
- package/harness/skills/performance.md +53 -0
- package/harness/skills/pi-todo-adapted.md +41 -0
- package/harness/skills/planning-tasks.md +86 -0
- package/harness/skills/prototype.md +39 -0
- package/harness/skills/research.md +32 -0
- package/harness/skills/resolving-merge-conflicts.md +30 -0
- package/harness/skills/scope-discipline.md +49 -0
- package/harness/skills/self-review.md +45 -0
- package/harness/skills/stuck-protocol.md +51 -0
- package/harness/skills/tdd.md +80 -0
- package/harness/skills/testing-infra.md +57 -0
- package/harness/skills/writing-skills.md +60 -0
- package/package.json +61 -0
- package/src/core/brief.ts +242 -0
- package/src/core/config.ts +265 -0
- package/src/core/exec.ts +130 -0
- package/src/core/featureList.ts +286 -0
- package/src/core/fsx.ts +119 -0
- package/src/core/gates.ts +444 -0
- package/src/core/lock.ts +192 -0
- package/src/core/paths.ts +95 -0
- package/src/core/phases.ts +143 -0
- package/src/core/settings.ts +445 -0
- package/src/core/types.ts +245 -0
- package/src/goalLoop.ts +628 -0
- package/src/goalSpec.ts +679 -0
- package/src/goalState.ts +338 -0
- package/src/loop.ts +355 -0
- package/src/modelRouter.ts +184 -0
- package/src/remote.ts +244 -0
- package/src/replan.ts +300 -0
- package/src/review.ts +53 -0
- package/src/rework.ts +274 -0
- package/src/taskList.ts +355 -0
- package/src/ui/config.ts +286 -0
- package/src/ui/dashboard.ts +1066 -0
- package/src/ui/theme.ts +317 -0
- package/src/ui/widget.ts +370 -0
- package/src/unstuck.ts +214 -0
- package/src/worker.ts +351 -0
- package/types/proper-lockfile.d.ts +19 -0
package/src/goalState.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
GOAL_LOOP_STATE_SCHEMA_VERSION,
|
|
6
|
+
type GoalIterationState,
|
|
7
|
+
type GoalLoopState,
|
|
8
|
+
type GoalLoopTraceEvent,
|
|
9
|
+
validateGoalLoopState,
|
|
10
|
+
} from "./goalLoop.ts";
|
|
11
|
+
import { type GoalSpecification, validateGoalSpecification } from "./goalSpec.ts";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, resolve } from "node:path";
|
|
14
|
+
declare const require: any;
|
|
15
|
+
|
|
16
|
+
export const GOAL_STATE_FILE = "GOAL_STATE.json";
|
|
17
|
+
export const GOAL_TRACE_FILE = "GOAL_TRACE.jsonl";
|
|
18
|
+
export const GOAL_RESULT_FILE = "GOAL_RESULT.md";
|
|
19
|
+
export const GOAL_SPEC_FILE = "GOAL_SPEC.json";
|
|
20
|
+
|
|
21
|
+
export const CANONICAL_GOAL_SPEC_DIR = "harness/goals";
|
|
22
|
+
export const CANONICAL_GOAL_SPEC_FILE = "GOAL_SPEC.json";
|
|
23
|
+
|
|
24
|
+
export function canonicalGoalSpecPath(projectDir = process.cwd()): string {
|
|
25
|
+
return resolve(projectDir, CANONICAL_GOAL_SPEC_DIR, CANONICAL_GOAL_SPEC_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeCanonicalWithLockSync(projectDir: string, content: string): void {
|
|
29
|
+
const target = canonicalGoalSpecPath(projectDir);
|
|
30
|
+
// try proper-lockfile sync-ish via dynamic import fallback to plain write
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
33
|
+
// use proper-lockfile if available (async variant would need async; use sync file write with lock attempt)
|
|
34
|
+
// For sync canonical we rely on atomic tmp+rename and ignore lock if unavailable — async wrapper below handles lock
|
|
35
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
36
|
+
writeFileSync(tmp, content, "utf8");
|
|
37
|
+
// rename via node:fs renameSync equivalent (import already has rename async, but we use writeFileSync+rename via fs)
|
|
38
|
+
const { renameSync } = require("node:fs");
|
|
39
|
+
renameSync(tmp, target);
|
|
40
|
+
} catch {
|
|
41
|
+
// fallback simple write
|
|
42
|
+
try {
|
|
43
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
44
|
+
writeFileSync(target, content, "utf8");
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export interface GoalStateStoreOptions {
|
|
51
|
+
cwd?: string;
|
|
52
|
+
goalRunId: string;
|
|
53
|
+
goalRunDir?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GoalStateStorePaths {
|
|
57
|
+
goalRunId: string;
|
|
58
|
+
goalRunDir: string;
|
|
59
|
+
statePath: string;
|
|
60
|
+
tracePath: string;
|
|
61
|
+
resultPath: string;
|
|
62
|
+
goalSpecPath: string;
|
|
63
|
+
iterationsDir: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class GoalStateStore {
|
|
67
|
+
readonly paths: GoalStateStorePaths;
|
|
68
|
+
|
|
69
|
+
constructor(options: GoalStateStoreOptions) {
|
|
70
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
71
|
+
const goalRunDir = options.goalRunDir ?? path.join(cwd, "tmp", "infinity-harness", "goals", options.goalRunId);
|
|
72
|
+
this.paths = {
|
|
73
|
+
goalRunId: options.goalRunId,
|
|
74
|
+
goalRunDir,
|
|
75
|
+
statePath: path.join(goalRunDir, GOAL_STATE_FILE),
|
|
76
|
+
tracePath: path.join(goalRunDir, GOAL_TRACE_FILE),
|
|
77
|
+
resultPath: path.join(goalRunDir, GOAL_RESULT_FILE),
|
|
78
|
+
goalSpecPath: path.join(goalRunDir, GOAL_SPEC_FILE),
|
|
79
|
+
iterationsDir: path.join(goalRunDir, "iterations"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async ensureRunDir(): Promise<void> {
|
|
84
|
+
await mkdir(this.paths.iterationsDir, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async saveState(state: GoalLoopState): Promise<void> {
|
|
88
|
+
validateGoalLoopState(state);
|
|
89
|
+
await this.ensureRunDir();
|
|
90
|
+
await atomicWriteFile(this.paths.statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async loadState(): Promise<GoalLoopState> {
|
|
94
|
+
const text = await readFile(this.paths.statePath, "utf8");
|
|
95
|
+
return validateGoalLoopState(JSON.parse(text));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async saveGoalSpecification(spec: GoalSpecification): Promise<void> {
|
|
99
|
+
validateGoalSpecification(spec);
|
|
100
|
+
await this.ensureRunDir();
|
|
101
|
+
await atomicWriteFile(this.paths.goalSpecPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Persist spec to runDir AND canonical harness/goals/GOAL_SPEC.json with proper-lockfile. */
|
|
105
|
+
async saveGoalSpecificationWithCanonical(
|
|
106
|
+
spec: GoalSpecification,
|
|
107
|
+
projectDir = process.cwd(),
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
await this.saveGoalSpecification(spec);
|
|
110
|
+
await this.saveCanonicalGoalSpecification(spec, projectDir);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async saveCanonicalGoalSpecification(spec: GoalSpecification, projectDir = process.cwd()): Promise<void> {
|
|
114
|
+
validateGoalSpecification(spec);
|
|
115
|
+
const content = `${JSON.stringify(spec, null, 2)}\n`;
|
|
116
|
+
const target = canonicalGoalSpecPath(projectDir);
|
|
117
|
+
// Use proper-lockfile async lock when available to serialize concurrent writers
|
|
118
|
+
let release: (() => Promise<void>) | null = null;
|
|
119
|
+
try {
|
|
120
|
+
const mod: any = await import("proper-lockfile");
|
|
121
|
+
const lockfile = mod.default ?? mod;
|
|
122
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
123
|
+
if (!existsSync(target)) writeFileSync(target, "", "utf8");
|
|
124
|
+
release = (await (lockfile as any).lock(target, {
|
|
125
|
+
retries: { retries: 8, minTimeout: 20, maxTimeout: 80 },
|
|
126
|
+
stale: 10000,
|
|
127
|
+
realpath: false,
|
|
128
|
+
})) as any;
|
|
129
|
+
} catch {
|
|
130
|
+
release = null;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
134
|
+
await writeFile(tmp, content, "utf8");
|
|
135
|
+
await rename(tmp, target);
|
|
136
|
+
} finally {
|
|
137
|
+
if (release) {
|
|
138
|
+
try { await (release as any)(); } catch {}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async loadCanonicalGoalSpecification(projectDir = process.cwd()): Promise<GoalSpecification> {
|
|
144
|
+
const text = await readFile(canonicalGoalSpecPath(projectDir), "utf8");
|
|
145
|
+
return validateGoalSpecification(JSON.parse(text));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async tryLoadCanonicalGoalSpecification(projectDir = process.cwd()): Promise<GoalSpecification | undefined> {
|
|
149
|
+
try {
|
|
150
|
+
return await this.loadCanonicalGoalSpecification(projectDir);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isNodeErrnoException(error) && (error as any).code === "ENOENT") return undefined;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async loadGoalSpecification(): Promise<GoalSpecification> {
|
|
158
|
+
const text = await readFile(this.paths.goalSpecPath, "utf8");
|
|
159
|
+
return validateGoalSpecification(JSON.parse(text));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async tryLoadGoalSpecification(): Promise<GoalSpecification | undefined> {
|
|
163
|
+
try {
|
|
164
|
+
return await this.loadGoalSpecification();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (isNodeErrnoException(error) && error.code === "ENOENT") {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async appendTrace(event: GoalLoopTraceEvent): Promise<void> {
|
|
174
|
+
await this.ensureRunDir();
|
|
175
|
+
await appendFile(this.paths.tracePath, `${JSON.stringify(event)}\n`, "utf8");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async appendNewTraceEvents(previousTraceLength: number, state: GoalLoopState): Promise<void> {
|
|
179
|
+
const events = state.trace.slice(Math.max(0, previousTraceLength));
|
|
180
|
+
for (const event of events) {
|
|
181
|
+
await this.appendTrace(event);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async initializeResultIfMissing(state: GoalLoopState): Promise<boolean> {
|
|
186
|
+
try {
|
|
187
|
+
await readFile(this.paths.resultPath, "utf8");
|
|
188
|
+
return false;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (!isNodeErrnoException(error) || error.code !== "ENOENT") {
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
await this.initializeResult(state);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async durableTraceLength(): Promise<number> {
|
|
199
|
+
try {
|
|
200
|
+
const text = await readFile(this.paths.tracePath, "utf8");
|
|
201
|
+
return text.split(/\r?\n/).filter((line) => line.trim()).length;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (isNodeErrnoException(error) && error.code === "ENOENT") {
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async initializeResult(state: GoalLoopState): Promise<void> {
|
|
211
|
+
validateGoalLoopState(state);
|
|
212
|
+
await this.ensureRunDir();
|
|
213
|
+
const lines = [
|
|
214
|
+
"# Pi Goal Task Result",
|
|
215
|
+
"",
|
|
216
|
+
`Run: ${state.goalRunId}`,
|
|
217
|
+
`Goal: ${state.goal}`,
|
|
218
|
+
`Started: ${state.startedAt}`,
|
|
219
|
+
`State: ${this.paths.statePath}`,
|
|
220
|
+
`Trace: ${this.paths.tracePath}`,
|
|
221
|
+
`Goal specification: ${this.paths.goalSpecPath}`,
|
|
222
|
+
"",
|
|
223
|
+
"## Safety limits",
|
|
224
|
+
"",
|
|
225
|
+
`- Minimum iterations before completion: ${state.limits.minIterations}`,
|
|
226
|
+
`- Max iterations: ${state.limits.maxIterations}`,
|
|
227
|
+
`- Run timeout: ${state.limits.timeoutMs}ms`,
|
|
228
|
+
`- Iteration timeout: ${state.limits.iterationTimeoutMs}ms`,
|
|
229
|
+
`- Reviewer timeout: ${state.limits.reviewerTimeoutMs}ms`,
|
|
230
|
+
"",
|
|
231
|
+
];
|
|
232
|
+
await writeFile(this.paths.resultPath, `${lines.join("\n")}\n`, "utf8");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async appendIterationResult(iteration: GoalIterationState): Promise<void> {
|
|
236
|
+
await this.ensureRunDir();
|
|
237
|
+
const lines = [
|
|
238
|
+
"",
|
|
239
|
+
`## Iteration ${iteration.iteration}`,
|
|
240
|
+
"",
|
|
241
|
+
`Status: ${iteration.status}`,
|
|
242
|
+
`Started: ${iteration.startedAt}`,
|
|
243
|
+
`Updated: ${iteration.updatedAt}`,
|
|
244
|
+
];
|
|
245
|
+
if (iteration.deadlineAt) {
|
|
246
|
+
lines.push(`Deadline: ${iteration.deadlineAt}`);
|
|
247
|
+
}
|
|
248
|
+
if (iteration.generatedTodo) {
|
|
249
|
+
lines.push("", "### Generated TODO", "", `Path: ${iteration.generatedTodo.todoPath}`);
|
|
250
|
+
if (iteration.generatedTodo.summary) {
|
|
251
|
+
lines.push(`Summary: ${iteration.generatedTodo.summary}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (iteration.workerResult) {
|
|
255
|
+
lines.push(
|
|
256
|
+
"",
|
|
257
|
+
"### Worker result",
|
|
258
|
+
"",
|
|
259
|
+
`Status: ${iteration.workerResult.status}`,
|
|
260
|
+
`Summary: ${iteration.workerResult.summary}`,
|
|
261
|
+
);
|
|
262
|
+
if (iteration.workerResult.resultPath) {
|
|
263
|
+
lines.push(`Result path: ${iteration.workerResult.resultPath}`);
|
|
264
|
+
}
|
|
265
|
+
if (iteration.workerResult.todoPath) {
|
|
266
|
+
lines.push(`TODO path: ${iteration.workerResult.todoPath}`);
|
|
267
|
+
}
|
|
268
|
+
if (iteration.workerResult.taskResultPath) {
|
|
269
|
+
lines.push(`Task result path: ${iteration.workerResult.taskResultPath}`);
|
|
270
|
+
}
|
|
271
|
+
if (iteration.workerResult.workerProgressPath) {
|
|
272
|
+
lines.push(`Worker progress log: ${iteration.workerResult.workerProgressPath}`);
|
|
273
|
+
}
|
|
274
|
+
if (iteration.workerResult.error) {
|
|
275
|
+
lines.push(`Error: ${iteration.workerResult.error}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (iteration.reviewerResult) {
|
|
279
|
+
lines.push(
|
|
280
|
+
"",
|
|
281
|
+
"### Reviewer result",
|
|
282
|
+
"",
|
|
283
|
+
`Decision: ${iteration.reviewerResult.decision}`,
|
|
284
|
+
`Complete: ${iteration.reviewerResult.complete ? "yes" : "no"}`,
|
|
285
|
+
`Summary: ${iteration.reviewerResult.summary}`,
|
|
286
|
+
`Rationale: ${iteration.reviewerResult.rationale}`,
|
|
287
|
+
);
|
|
288
|
+
if (iteration.reviewerResult.remainingWork.length > 0) {
|
|
289
|
+
lines.push("", "Remaining work:", ...iteration.reviewerResult.remainingWork.map((item) => `- ${item}`));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (iteration.completion) {
|
|
293
|
+
lines.push(
|
|
294
|
+
"",
|
|
295
|
+
"### Completion",
|
|
296
|
+
"",
|
|
297
|
+
`Status: ${iteration.completion.status}`,
|
|
298
|
+
`Reason: ${iteration.completion.reason}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
await appendFile(this.paths.resultPath, `${lines.join("\n")}\n`, "utf8");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async writeIterationSnapshot(iteration: GoalIterationState): Promise<string> {
|
|
305
|
+
const iterationDir = this.iterationDir(iteration.iteration);
|
|
306
|
+
await mkdir(iterationDir, { recursive: true });
|
|
307
|
+
const snapshotPath = path.join(iterationDir, "ITERATION_STATE.json");
|
|
308
|
+
await atomicWriteFile(snapshotPath, `${JSON.stringify(iteration, null, 2)}\n`);
|
|
309
|
+
return snapshotPath;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
iterationDir(iteration: number): string {
|
|
313
|
+
return path.join(this.paths.iterationsDir, String(iteration).padStart(2, "0"));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function goalStatePaths(options: GoalStateStoreOptions): GoalStateStorePaths {
|
|
318
|
+
return new GoalStateStore(options).paths;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function atomicWriteFile(filePath: string, content: string): Promise<void> {
|
|
322
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
323
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
324
|
+
await writeFile(tmpPath, content, "utf8");
|
|
325
|
+
await rename(tmpPath, filePath);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function isGoalLoopState(value: unknown): value is GoalLoopState {
|
|
329
|
+
try {
|
|
330
|
+
return validateGoalLoopState(value).schemaVersion === GOAL_LOOP_STATE_SCHEMA_VERSION;
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function isNodeErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
337
|
+
return error instanceof Error && "code" in error;
|
|
338
|
+
}
|
package/src/loop.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the continuous run driver.
|
|
3
|
+
*
|
|
4
|
+
* This is what lets the harness work for hours or days without a human at the
|
|
5
|
+
* keyboard. After the agent goes idle, `decideNext` looks at the state on disk
|
|
6
|
+
* and answers one question: keep going, advance, wait, or stop.
|
|
7
|
+
*
|
|
8
|
+
* The hard part is not continuing — it is knowing when to *stop*. A loop that
|
|
9
|
+
* always continues will burn a weekend of tokens re-running a failing gate
|
|
10
|
+
* against an unchanged tree. Every guard here exists because that is the
|
|
11
|
+
* default failure mode of an autonomous loop paired with a weak model:
|
|
12
|
+
*
|
|
13
|
+
* - a wall-clock budget, so a forgotten run ends on its own
|
|
14
|
+
* - an iteration ceiling, independent of time
|
|
15
|
+
* - a *no-progress* detector: if the gate fails repeatedly and the working
|
|
16
|
+
* tree fingerprint has not moved, the agent is spinning, not working
|
|
17
|
+
* - a retry budget per task, so one impossible task cannot consume the run
|
|
18
|
+
* - an explicit human brake (`paused`, or a stop file) checked every tick
|
|
19
|
+
*
|
|
20
|
+
* The loop never silently gives up: every stop carries a reason the human
|
|
21
|
+
* reads when they come back.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
import type { HarnessConfig, Phase } from "./core/types.ts";
|
|
27
|
+
import { loadConfig, saveConfig, isRetryExhausted, incrementPhaseRetry } from "./core/config.ts";
|
|
28
|
+
import { loadFeatureList, computeProgress, nextActionableTask } from "./core/featureList.ts";
|
|
29
|
+
import { runChecks } from "./core/gates.ts";
|
|
30
|
+
import { advancePhase, isFinalPhase, nextPhase } from "./core/phases.ts";
|
|
31
|
+
import { buildBrief, renderBrief } from "./core/brief.ts";
|
|
32
|
+
import { harnessDir } from "./core/paths.ts";
|
|
33
|
+
import { readJsonSafe, writeJsonAtomic, fileExists } from "./core/fsx.ts";
|
|
34
|
+
import { run } from "./core/exec.ts";
|
|
35
|
+
|
|
36
|
+
export const LOOP_STATE_FILE = "loop-state.json";
|
|
37
|
+
export const STOP_FILE = "STOP";
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_MAX_ITERATIONS = 2000;
|
|
40
|
+
export const DEFAULT_MAX_WALL_CLOCK_MS = 24 * 60 * 60 * 1000; // 24h
|
|
41
|
+
/** Consecutive gate failures with an unchanged tree before we call it stuck. */
|
|
42
|
+
export const DEFAULT_NO_PROGRESS_LIMIT = 3;
|
|
43
|
+
|
|
44
|
+
export type LoopState = {
|
|
45
|
+
runId: string;
|
|
46
|
+
startedAt: string;
|
|
47
|
+
iterations: number;
|
|
48
|
+
lastFingerprint: string | null;
|
|
49
|
+
/** Consecutive failures where the tree fingerprint did not move. */
|
|
50
|
+
noProgressStreak: number;
|
|
51
|
+
lastPhase: string | null;
|
|
52
|
+
lastDecision: string | null;
|
|
53
|
+
stoppedAt: string | null;
|
|
54
|
+
stopReason: string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type LoopBudget = {
|
|
58
|
+
maxIterations: number;
|
|
59
|
+
maxWallClockMs: number;
|
|
60
|
+
noProgressLimit: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type LoopDecision =
|
|
64
|
+
| { action: "continue"; message: string; reason: string }
|
|
65
|
+
| { action: "advanced"; toPhase: Phase; message: string; reason: string }
|
|
66
|
+
| { action: "stop"; reason: string; detail: string }
|
|
67
|
+
| { action: "wait"; reason: string; detail: string };
|
|
68
|
+
|
|
69
|
+
export function loopStatePath(targetDir: string): string {
|
|
70
|
+
return resolve(harnessDir(targetDir), LOOP_STATE_FILE);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function stopFilePath(targetDir: string): string {
|
|
74
|
+
return resolve(harnessDir(targetDir), STOP_FILE);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function newLoopState(runId: string, now = new Date()): LoopState {
|
|
78
|
+
return {
|
|
79
|
+
runId,
|
|
80
|
+
startedAt: now.toISOString(),
|
|
81
|
+
iterations: 0,
|
|
82
|
+
lastFingerprint: null,
|
|
83
|
+
noProgressStreak: 0,
|
|
84
|
+
lastPhase: null,
|
|
85
|
+
lastDecision: null,
|
|
86
|
+
stoppedAt: null,
|
|
87
|
+
stopReason: null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadLoopState(targetDir: string, runId: string, now = new Date()): LoopState {
|
|
92
|
+
const stored = readJsonSafe<LoopState | null>(loopStatePath(targetDir), null);
|
|
93
|
+
if (stored && stored.runId === runId) return stored;
|
|
94
|
+
return newLoopState(runId, now);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function saveLoopState(targetDir: string, state: LoopState): void {
|
|
98
|
+
try {
|
|
99
|
+
writeJsonAtomic(loopStatePath(targetDir), state);
|
|
100
|
+
} catch {
|
|
101
|
+
// Loop bookkeeping is not worth aborting a run over. Worst case the
|
|
102
|
+
// budget restarts, and the wall-clock guard still bounds it.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function budgetFrom(config: HarnessConfig): LoopBudget {
|
|
107
|
+
const loop = (config.loop ?? {}) as Record<string, unknown>;
|
|
108
|
+
const num = (v: unknown, fallback: number): number =>
|
|
109
|
+
typeof v === "number" && Number.isFinite(v) && v > 0 ? v : fallback;
|
|
110
|
+
return {
|
|
111
|
+
maxIterations: num(loop.maxIterations, DEFAULT_MAX_ITERATIONS),
|
|
112
|
+
maxWallClockMs: num(loop.maxWallClockMs, DEFAULT_MAX_WALL_CLOCK_MS),
|
|
113
|
+
noProgressLimit: num(loop.noProgressLimit, DEFAULT_NO_PROGRESS_LIMIT),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A cheap fingerprint of the working tree plus the plan.
|
|
119
|
+
*
|
|
120
|
+
* Used only to answer "did anything change since the last failed gate?".
|
|
121
|
+
* `git status --porcelain` covers edits, and the plan revision covers task
|
|
122
|
+
* updates that leave no file trace. Outside a git repo we fall back to the
|
|
123
|
+
* plan alone, which still catches the common spin.
|
|
124
|
+
*/
|
|
125
|
+
export async function fingerprint(targetDir: string): Promise<string> {
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
const r = await run("git status --porcelain", { cwd: targetDir, timeoutMs: 15_000 });
|
|
128
|
+
if (r.ok) parts.push(r.stdout);
|
|
129
|
+
const head = await run("git rev-parse HEAD", { cwd: targetDir, timeoutMs: 10_000 });
|
|
130
|
+
if (head.ok) parts.push(head.stdout);
|
|
131
|
+
try {
|
|
132
|
+
const { list } = loadFeatureList(targetDir);
|
|
133
|
+
parts.push(String(list.baseRevision));
|
|
134
|
+
parts.push(
|
|
135
|
+
(list.features ?? [])
|
|
136
|
+
.flatMap((f) => (f.tasks ?? []).map((t) => `${t.id}:${t.status}`))
|
|
137
|
+
.join(","),
|
|
138
|
+
);
|
|
139
|
+
} catch {
|
|
140
|
+
/* plan unreadable — the git half still fingerprints */
|
|
141
|
+
}
|
|
142
|
+
return createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type DecideOptions = {
|
|
146
|
+
targetDir: string;
|
|
147
|
+
runId: string;
|
|
148
|
+
now?: Date;
|
|
149
|
+
/** Skip the gate run. Used by callers that already have a verdict. */
|
|
150
|
+
skipGate?: boolean;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Decide what happens after the agent settles.
|
|
155
|
+
*
|
|
156
|
+
* Order matters: human brakes first, then terminal conditions, then budgets,
|
|
157
|
+
* then the gate. A paused run must stop even if the gate would pass.
|
|
158
|
+
*/
|
|
159
|
+
export async function decideNext(options: DecideOptions): Promise<{ decision: LoopDecision; state: LoopState }> {
|
|
160
|
+
const { targetDir, runId } = options;
|
|
161
|
+
const now = options.now ?? new Date();
|
|
162
|
+
const state = loadLoopState(targetDir, runId, now);
|
|
163
|
+
state.iterations += 1;
|
|
164
|
+
|
|
165
|
+
const finish = (decision: LoopDecision): { decision: LoopDecision; state: LoopState } => {
|
|
166
|
+
state.lastDecision = decision.action;
|
|
167
|
+
if (decision.action === "stop") {
|
|
168
|
+
state.stoppedAt = now.toISOString();
|
|
169
|
+
state.stopReason = decision.reason;
|
|
170
|
+
}
|
|
171
|
+
saveLoopState(targetDir, state);
|
|
172
|
+
return { decision, state };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// -- human brakes ---------------------------------------------------------
|
|
176
|
+
if (fileExists(stopFilePath(targetDir))) {
|
|
177
|
+
return finish({
|
|
178
|
+
action: "stop",
|
|
179
|
+
reason: "stop-file",
|
|
180
|
+
detail: `harness/${STOP_FILE} exists — delete it to resume.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { config, ok } = loadConfig(targetDir);
|
|
185
|
+
if (!ok) {
|
|
186
|
+
return finish({
|
|
187
|
+
action: "stop",
|
|
188
|
+
reason: "no-config",
|
|
189
|
+
detail: "harness/config.json is missing or unreadable.",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (config.paused) {
|
|
193
|
+
return finish({
|
|
194
|
+
action: "wait",
|
|
195
|
+
reason: "paused",
|
|
196
|
+
detail: "The pipeline is paused. Unpause to continue.",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
state.lastPhase = config.currentPhase;
|
|
201
|
+
|
|
202
|
+
// -- terminal conditions --------------------------------------------------
|
|
203
|
+
const { list } = loadFeatureList(targetDir);
|
|
204
|
+
const progress = computeProgress(list);
|
|
205
|
+
const allTasksDone = progress.tasksTotal > 0 && progress.tasksDone === progress.tasksTotal;
|
|
206
|
+
|
|
207
|
+
if (isFinalPhase(config.currentPhase, config.phases?.enabled) && allTasksDone) {
|
|
208
|
+
return finish({
|
|
209
|
+
action: "stop",
|
|
210
|
+
reason: "complete",
|
|
211
|
+
detail: `Pipeline complete: ${progress.tasksDone}/${progress.tasksTotal} tasks across ${progress.featuresTotal} feature(s).`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const exhausted = isRetryExhausted(config);
|
|
216
|
+
if (exhausted.exhausted) {
|
|
217
|
+
return finish({
|
|
218
|
+
action: "stop",
|
|
219
|
+
reason: "retry-budget",
|
|
220
|
+
detail: `The ${exhausted.which} retry budget is exhausted. A human needs to look at this.`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// -- budgets --------------------------------------------------------------
|
|
225
|
+
const budget = budgetFrom(config);
|
|
226
|
+
if (state.iterations > budget.maxIterations) {
|
|
227
|
+
return finish({
|
|
228
|
+
action: "stop",
|
|
229
|
+
reason: "max-iterations",
|
|
230
|
+
detail: `Reached the ${budget.maxIterations}-iteration ceiling for this run.`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const elapsed = now.getTime() - new Date(state.startedAt).getTime();
|
|
234
|
+
if (elapsed > budget.maxWallClockMs) {
|
|
235
|
+
const hours = (budget.maxWallClockMs / 3_600_000).toFixed(1);
|
|
236
|
+
return finish({
|
|
237
|
+
action: "stop",
|
|
238
|
+
reason: "max-wall-clock",
|
|
239
|
+
detail: `Run exceeded its ${hours}h wall-clock budget.`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// -- the gate -------------------------------------------------------------
|
|
244
|
+
const phase = config.currentPhase;
|
|
245
|
+
if (!phase) {
|
|
246
|
+
return finish({
|
|
247
|
+
action: "wait",
|
|
248
|
+
reason: "not-started",
|
|
249
|
+
detail: "No current phase. Initialise the harness before running the loop.",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const gate = options.skipGate ? null : await runChecks(targetDir, phase, { record: true });
|
|
254
|
+
|
|
255
|
+
if (gate && gate.overall) {
|
|
256
|
+
state.noProgressStreak = 0;
|
|
257
|
+
state.lastFingerprint = await fingerprint(targetDir);
|
|
258
|
+
|
|
259
|
+
const upcoming = nextPhase(phase, config.phases?.enabled);
|
|
260
|
+
if (upcoming === null) {
|
|
261
|
+
return finish({
|
|
262
|
+
action: "stop",
|
|
263
|
+
reason: "complete",
|
|
264
|
+
detail: `Gate passed on the final phase (${phase}).`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const moved = await advancePhase(targetDir);
|
|
269
|
+
if (!moved.ok) {
|
|
270
|
+
return finish({
|
|
271
|
+
action: "wait",
|
|
272
|
+
reason: "advance-failed",
|
|
273
|
+
detail: moved.error ?? "phase advance failed",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const brief = await buildBrief(targetDir);
|
|
278
|
+
return finish({
|
|
279
|
+
action: "advanced",
|
|
280
|
+
toPhase: upcoming,
|
|
281
|
+
message: renderBrief(brief, moved.config ?? undefined),
|
|
282
|
+
reason: `gate passed on ${phase}`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Gate failed (or was skipped). Is the agent actually making progress?
|
|
287
|
+
//
|
|
288
|
+
// The first failure of a run has no baseline to compare against, so it is
|
|
289
|
+
// never counted as a stall: the streak starts only once we have seen the
|
|
290
|
+
// tree twice and it did not move. Capture the previous fingerprint before
|
|
291
|
+
// overwriting it, or the comparison is always against itself.
|
|
292
|
+
const previous = state.lastFingerprint;
|
|
293
|
+
const fp = await fingerprint(targetDir);
|
|
294
|
+
state.lastFingerprint = fp;
|
|
295
|
+
|
|
296
|
+
if (previous === null || previous !== fp) {
|
|
297
|
+
state.noProgressStreak = 0;
|
|
298
|
+
} else {
|
|
299
|
+
state.noProgressStreak += 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (state.noProgressStreak >= budget.noProgressLimit) {
|
|
303
|
+
return finish({
|
|
304
|
+
action: "stop",
|
|
305
|
+
reason: "no-progress",
|
|
306
|
+
detail:
|
|
307
|
+
`The gate has failed ${state.noProgressStreak} times in a row with no change to the working tree ` +
|
|
308
|
+
`or the plan. The agent is looping without making progress` +
|
|
309
|
+
(gate ? `: ${gate.failures.join(", ")}` : "") +
|
|
310
|
+
`. Stopping so a human can intervene.`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Charge a phase retry so the configured budget still bounds the run even
|
|
315
|
+
// when the tree keeps changing but the gate never opens.
|
|
316
|
+
const fresh = loadConfig(targetDir);
|
|
317
|
+
if (fresh.ok) {
|
|
318
|
+
incrementPhaseRetry(fresh.config);
|
|
319
|
+
saveConfig(targetDir, fresh.config);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const brief = await buildBrief(targetDir);
|
|
323
|
+
const failures = gate
|
|
324
|
+
? gate.checks
|
|
325
|
+
.filter((c) => !c.pass)
|
|
326
|
+
.map((c) => ` x ${c.name}: ${c.detail}`)
|
|
327
|
+
.join("\n")
|
|
328
|
+
: "(gate not run)";
|
|
329
|
+
|
|
330
|
+
const task = nextActionableTask(list);
|
|
331
|
+
const focus = task ? `\nCurrent task: ${task.compositeKey} — ${task.description}` : "";
|
|
332
|
+
|
|
333
|
+
return finish({
|
|
334
|
+
action: "continue",
|
|
335
|
+
reason: "gate failed",
|
|
336
|
+
message:
|
|
337
|
+
`The ${phase.toUpperCase()} gate did not pass. Fix exactly these, then stop talking — ` +
|
|
338
|
+
`the harness will re-validate automatically.\n\n${failures}${focus}\n\n` +
|
|
339
|
+
renderBrief(brief, fresh.ok ? fresh.config : undefined),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Human-readable one-liner for the status bar / notify. */
|
|
344
|
+
export function describeDecision(d: LoopDecision): string {
|
|
345
|
+
switch (d.action) {
|
|
346
|
+
case "continue":
|
|
347
|
+
return `continuing — ${d.reason}`;
|
|
348
|
+
case "advanced":
|
|
349
|
+
return `advanced to ${d.toPhase}`;
|
|
350
|
+
case "wait":
|
|
351
|
+
return `waiting — ${d.detail}`;
|
|
352
|
+
case "stop":
|
|
353
|
+
return `stopped — ${d.detail}`;
|
|
354
|
+
}
|
|
355
|
+
}
|