@mikenguyen69/harness 0.1.0-beta.3 → 0.1.0-beta.4
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/dist/help.js +3 -1
- package/dist/internal/orchestration/analyze/index.js +5 -4
- package/dist/internal/orchestration/cli.js +237 -23
- package/dist/internal/orchestration/config/index.js +44 -1
- package/dist/internal/orchestration/doctor/index.js +180 -114
- package/dist/internal/orchestration/harness/cli.js +3 -0
- package/dist/internal/orchestration/loop/index.js +195 -10
- package/dist/internal/orchestration/only/index.js +80 -0
- package/dist/internal/orchestration/run/index.js +36 -3
- package/dist/internal/orchestration/runners/claude/index.js +22 -2
- package/dist/internal/orchestration/runners/codex/index.js +11 -3
- package/dist/internal/orchestration/runners/cursor/index.js +13 -3
- package/dist/internal/orchestration/worktree/index.js +84 -2
- package/dist/internal/system/init/index.js +96 -4
- package/dist/internal/system/ledger/index.js +138 -5
- package/dist/internal/system/sequencer/cli.js +49 -30
- package/dist/internal/system/spec/cli.js +30 -3
- package/dist/internal/system/spec/project.js +29 -6
- package/dist/internal/system/status/cli.js +11 -3
- package/dist/internal/system/status/index.js +80 -4
- package/dist/internal/system/system/index.js +55 -8
- package/dist/internal/system/verify/cli.js +1 -0
- package/dist/routes.js +1 -0
- package/package.json +1 -1
- package/schema/spec.schema.json +5 -0
- package/schema/system.schema.json +1 -1
- package/templates/target-kit/README.md +2 -1
- package/templates/target-kit/orchestration.json +0 -2
package/dist/help.js
CHANGED
|
@@ -16,6 +16,7 @@ Advanced — verification and gates:
|
|
|
16
16
|
Advanced — specs and units:
|
|
17
17
|
harness spec check lint specs, dep DAG, consumes/produces
|
|
18
18
|
harness spec materialize write idempotent unit.planned facts
|
|
19
|
+
harness spec project emit unit YAML from OpenSpec + projection
|
|
19
20
|
harness next list units ready to claim
|
|
20
21
|
harness claim atomically take a ready unit
|
|
21
22
|
harness heartbeat keep a claim alive
|
|
@@ -74,7 +75,8 @@ export const COMMAND_HELP = {
|
|
|
74
75
|
doctor: `harness doctor read-only preflight before a run
|
|
75
76
|
--repo <dir> --routes <routes.toml> [--spool <dir>] [--json]`,
|
|
76
77
|
run: `harness run drive the spec DAG with an agent fleet
|
|
77
|
-
--repo <dir> --config <orchestration.json> --routes <routes.toml> [--spool <dir>] [--report <html>]
|
|
78
|
+
--repo <dir> --config <orchestration.json> --routes <routes.toml> [--only <id-prefix>] [--spool <dir>] [--report <html>]
|
|
79
|
+
--only <id-prefix> limits which units the run may claim (same as harness next --only)`,
|
|
78
80
|
resume: `harness resume continue a run after a signed door approval
|
|
79
81
|
--unit <id> [--repo <dir>] [--routes <routes.toml>]`,
|
|
80
82
|
status: `harness status read-only unit lanes from the ledger, with a run-status spool overlay
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const DEFAULT_HIGH_TURN_COUNT = 50;
|
|
2
|
+
const DEFAULT_EXPLORE_BEFORE_EDIT = 15;
|
|
2
3
|
const IMPLEMENT_STEPS = new Set(["implement"]);
|
|
3
4
|
const REVIEW_STEPS = new Set(["review"]);
|
|
4
5
|
export function parseActivityLog(text) {
|
|
@@ -157,17 +158,16 @@ export function findRetryWithoutDelta(unitEvents) {
|
|
|
157
158
|
}
|
|
158
159
|
return findings;
|
|
159
160
|
}
|
|
160
|
-
export function findExploreBeforeEdit(unitEvents) {
|
|
161
|
+
export function findExploreBeforeEdit(unitEvents, threshold = DEFAULT_EXPLORE_BEFORE_EDIT) {
|
|
161
162
|
const findings = [];
|
|
162
163
|
let sinceEditCount = 0;
|
|
163
164
|
const seen = [];
|
|
164
|
-
const EXPLORE_THRESHOLD = 15;
|
|
165
165
|
for (const e of unitEvents) {
|
|
166
166
|
if (e.kind !== "tool")
|
|
167
167
|
continue;
|
|
168
168
|
const isEdit = e.tool === "write" || e.tool === "edit";
|
|
169
169
|
if (isEdit) {
|
|
170
|
-
if (sinceEditCount >
|
|
170
|
+
if (sinceEditCount > threshold) {
|
|
171
171
|
findings.push({
|
|
172
172
|
id: "explore-before-edit",
|
|
173
173
|
severity: "info",
|
|
@@ -206,6 +206,7 @@ export function analyzeRun(events, opts) {
|
|
|
206
206
|
const runId = events.find((e) => e.t === "run.started")
|
|
207
207
|
?.run_id ?? "";
|
|
208
208
|
const highTurnCount = opts.thresholds?.highTurnCount ?? DEFAULT_HIGH_TURN_COUNT;
|
|
209
|
+
const exploreBeforeEdit = opts.thresholds?.exploreBeforeEdit ?? DEFAULT_EXPLORE_BEFORE_EDIT;
|
|
209
210
|
const stepsByUnit = new Map();
|
|
210
211
|
const activityByUnit = new Map();
|
|
211
212
|
for (const e of events) {
|
|
@@ -227,7 +228,7 @@ export function analyzeRun(events, opts) {
|
|
|
227
228
|
const steps = stepsByUnit.get(unitId) ?? [];
|
|
228
229
|
const activity = activityByUnit.get(unitId) ?? [];
|
|
229
230
|
units.push(unitMetrics(unitId, steps, activity, opts.costByUnit));
|
|
230
|
-
findings.push(...findRepeatedRead(activity), ...findToolFailureCluster(activity), ...findHighTurnCount(activity, highTurnCount), ...findRetryWithoutDelta(activity), ...findExploreBeforeEdit(activity), ...findVerifyHeavierThanImplement(activity));
|
|
231
|
+
findings.push(...findRepeatedRead(activity), ...findToolFailureCluster(activity), ...findHighTurnCount(activity, highTurnCount), ...findRetryWithoutDelta(activity), ...findExploreBeforeEdit(activity, exploreBeforeEdit), ...findVerifyHeavierThanImplement(activity));
|
|
231
232
|
}
|
|
232
233
|
return {
|
|
233
234
|
runId,
|
|
@@ -14,7 +14,7 @@ import { FakeHarness } from "./harness/index.js";
|
|
|
14
14
|
import { FakeRouteSource } from "./routes/index.js";
|
|
15
15
|
import { FakeRunner } from "./runner/index.js";
|
|
16
16
|
import { RunLog } from "./runstate/index.js";
|
|
17
|
-
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { join, resolve } from "node:path";
|
|
19
19
|
import { DEFAULT_CONFIG, FakeGit, resumeAfterDoor, runLoop, startupSweep, } from "./loop/index.js";
|
|
20
20
|
import { renderRunReport } from "./report/index.js";
|
|
@@ -23,16 +23,23 @@ import { runDoctor } from "./doctor/index.js";
|
|
|
23
23
|
import { resolveCommandSpec } from "./harness/cli.js";
|
|
24
24
|
import { StubRunner } from "./runners/stub/index.js";
|
|
25
25
|
import { readdirSync } from "node:fs";
|
|
26
|
-
import { analyzeRun, parseActivityLog } from "./analyze/index.js";
|
|
26
|
+
import { analyzeRun, parseActivityLog, } from "./analyze/index.js";
|
|
27
|
+
import { resolveResumeWorktreeRoot } from "./worktree/index.js";
|
|
28
|
+
import { loadOrchestrationConfig } from "./config/index.js";
|
|
29
|
+
import { matchesOnly, loadUnitStories } from "./only/index.js";
|
|
27
30
|
const USAGE = `orchestrate — drive a spec DAG to merged with an agent fleet
|
|
28
31
|
|
|
29
32
|
Usage:
|
|
30
33
|
orchestrate run [--repo <path>] [--config <path>] [--routes <path>]
|
|
31
34
|
[--only <id-prefix>] [--spool <hub-dir>] [--report <path>]
|
|
32
35
|
[--watch] [--activity-log <dir>] [--no-activity-log] [--no-ledger]
|
|
36
|
+
[--worktree-root <dir>] [--threshold <rule>=<n>]
|
|
33
37
|
real run against a checkout (default --repo: cwd);
|
|
34
38
|
--only limits claims (harness next --only); --spool feeds the board (D1);
|
|
35
39
|
--watch mirrors Cursor lane activity to stderr as well as the spool;
|
|
40
|
+
--worktree-root overrides the default per-run worktree root
|
|
41
|
+
(<repo>/.harness/worktrees/<run-id>);
|
|
42
|
+
--threshold <rule>=<n> overrides analysis thresholds (repeatable);
|
|
36
43
|
activity capture is ON by default (<repo>/.harness/runs/<run-id>/activity.jsonl);
|
|
37
44
|
analysis runs automatically at the end and appends one run.analyzed ledger
|
|
38
45
|
event unless --no-ledger; --no-activity-log disables capture entirely
|
|
@@ -41,16 +48,21 @@ Usage:
|
|
|
41
48
|
orchestrate resume --unit <id> [--repo <path>] [--routes <path>]
|
|
42
49
|
[--spool <hub-dir>] [--report <path>] [--watch]
|
|
43
50
|
[--activity-log <dir>] [--no-activity-log] [--no-ledger] [--resumes <run-id>]
|
|
51
|
+
[--worktree-root <dir>] [--threshold <rule>=<n>]
|
|
44
52
|
after a signed door approval, reverify/review/land the unit;
|
|
45
|
-
--resumes links the new run's activity log back to the run it continues
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
--resumes links the new run's activity log back to the run it continues;
|
|
54
|
+
--worktree-root overrides the resolved prior-run worktree root;
|
|
55
|
+
--threshold <rule>=<n> overrides analysis thresholds (repeatable)
|
|
56
|
+
orchestrate sweep [--repo <path>] [--routes <path>] [--json] [--no-ledger]
|
|
57
|
+
startup crash-window check (interrupted runs, stranded in-review, ledger-ahead-of-git)
|
|
48
58
|
orchestrate demo [--report <path>]
|
|
49
59
|
the M1 dry loop against a fake DAG, prints RunState
|
|
50
60
|
orchestrate analyze [--run <id> | --last | --log <path>] [--repo <path>]
|
|
61
|
+
[--config <path>] [--threshold <rule>=<n>]
|
|
51
62
|
[--out <path>] [--json] [--no-ledger]
|
|
52
63
|
synthesize a captured activity.jsonl into findings (P1);
|
|
53
64
|
--run/--last resolve under <repo>/.harness/runs/, or pass --log directly;
|
|
65
|
+
--threshold <rule>=<n> overrides analysis thresholds (repeatable);
|
|
54
66
|
writes summary.json next to the log unless --out is given
|
|
55
67
|
orchestrate report --state <run-state.json> [--out <path>]
|
|
56
68
|
render the end-of-run HTML from a saved RunState (D2)
|
|
@@ -89,7 +101,9 @@ function buildStubRunner(repo, config, stubConfig) {
|
|
|
89
101
|
const stub = new StubRunner({
|
|
90
102
|
cwd: repo,
|
|
91
103
|
baseBranch: config.baseBranch,
|
|
92
|
-
...(Object.keys(stubConfig.afterImplement ?? {}).length
|
|
104
|
+
...(Object.keys(stubConfig.afterImplement ?? {}).length
|
|
105
|
+
? { afterImplement }
|
|
106
|
+
: {}),
|
|
93
107
|
});
|
|
94
108
|
for (const [unitId, mode] of Object.entries(stubConfig.scripts ?? {})) {
|
|
95
109
|
if (mode === "out-of-scope")
|
|
@@ -116,6 +130,55 @@ function flag(name) {
|
|
|
116
130
|
const i = process.argv.indexOf(`--${name}`);
|
|
117
131
|
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
|
|
118
132
|
}
|
|
133
|
+
function repeatFlags(name) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
136
|
+
if (process.argv[i] === `--${name}` && process.argv[i + 1]) {
|
|
137
|
+
out.push(process.argv[i + 1]);
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
else if (process.argv[i]?.startsWith(`--${name}=`)) {
|
|
141
|
+
out.push(process.argv[i].slice(`--${name}=`.length));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function parseCliThresholds() {
|
|
147
|
+
const raw = repeatFlags("threshold");
|
|
148
|
+
if (raw.length === 0)
|
|
149
|
+
return null;
|
|
150
|
+
const thresholds = {};
|
|
151
|
+
for (const item of raw) {
|
|
152
|
+
const eqIdx = item.indexOf("=");
|
|
153
|
+
if (eqIdx === -1) {
|
|
154
|
+
throw new Error(`invalid --threshold format "${item}", expected <rule>=<n>`);
|
|
155
|
+
}
|
|
156
|
+
const rule = item.slice(0, eqIdx).trim();
|
|
157
|
+
const valStr = item.slice(eqIdx + 1).trim();
|
|
158
|
+
const val = Number(valStr);
|
|
159
|
+
if (!Number.isFinite(val) || val < 0) {
|
|
160
|
+
throw new Error(`invalid threshold value "${valStr}" for rule "${rule}", expected a non-negative number`);
|
|
161
|
+
}
|
|
162
|
+
if (rule === "high-turn-count" || rule === "highTurnCount") {
|
|
163
|
+
thresholds.highTurnCount = val;
|
|
164
|
+
}
|
|
165
|
+
else if (rule === "explore-before-edit" || rule === "exploreBeforeEdit") {
|
|
166
|
+
thresholds.exploreBeforeEdit = val;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
throw new Error(`unknown threshold rule "${rule}" — expected "high-turn-count" or "explore-before-edit"`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return thresholds;
|
|
173
|
+
}
|
|
174
|
+
function resolveThresholds(configThresholds, cliThresholds) {
|
|
175
|
+
if (!configThresholds && !cliThresholds)
|
|
176
|
+
return undefined;
|
|
177
|
+
return {
|
|
178
|
+
...configThresholds,
|
|
179
|
+
...cliThresholds,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
119
182
|
function summarise(state, analysis) {
|
|
120
183
|
process.stdout.write(JSON.stringify(state, null, 2) + "\n");
|
|
121
184
|
process.stdout.write(`\noutcome=${state.outcome} merged=${state.counts.merged}/${state.dagSize} ` +
|
|
@@ -131,9 +194,9 @@ function summarise(state, analysis) {
|
|
|
131
194
|
}
|
|
132
195
|
}
|
|
133
196
|
/** the `run.analyzed` body — metrics and rule ids only, no free text (P5.3: nothing for redaction to strip). */
|
|
134
|
-
function runAnalyzedBody(summary) {
|
|
197
|
+
function runAnalyzedBody(summary, fallbackRunId) {
|
|
135
198
|
return {
|
|
136
|
-
run_id: summary.runId,
|
|
199
|
+
run_id: summary.runId || fallbackRunId || "unknown-run",
|
|
137
200
|
units: summary.units.map((u) => ({
|
|
138
201
|
unit_id: u.unitId,
|
|
139
202
|
turns_total: u.turnsTotal,
|
|
@@ -160,7 +223,11 @@ async function appendRunAnalyzed(harness, runId, summary) {
|
|
|
160
223
|
// doubles for both (and, since `session` is unset, for `actor.id` too —
|
|
161
224
|
// assembleRun already sets actorId to runId). Redundant with body.run_id,
|
|
162
225
|
// but harmless — not a copy-paste bug.
|
|
163
|
-
await harness.ledgerAppend({
|
|
226
|
+
await harness.ledgerAppend({
|
|
227
|
+
type: "run.analyzed",
|
|
228
|
+
unitId: runId,
|
|
229
|
+
body: runAnalyzedBody(summary, runId),
|
|
230
|
+
});
|
|
164
231
|
}
|
|
165
232
|
/**
|
|
166
233
|
* P2 — runs automatically at the end of `orchestrate run`/`resume`. A no-op
|
|
@@ -168,7 +235,7 @@ async function appendRunAnalyzed(harness, runId, summary) {
|
|
|
168
235
|
* isn't a silent surprise — we tell the operator why. Appends one bounded
|
|
169
236
|
* `run.analyzed` ledger event unless `--no-ledger` is passed.
|
|
170
237
|
*/
|
|
171
|
-
async function runAutoAnalyze(deps, repo, runId, state) {
|
|
238
|
+
async function runAutoAnalyze(deps, repo, runId, state, cliThresholds) {
|
|
172
239
|
if (!deps.activityLog) {
|
|
173
240
|
process.stderr.write("orchestrate: activity capture was disabled — skipping analysis\n");
|
|
174
241
|
return null;
|
|
@@ -176,7 +243,12 @@ async function runAutoAnalyze(deps, repo, runId, state) {
|
|
|
176
243
|
const events = deps.activityLog.events(); // in-process — no re-read of the file needed
|
|
177
244
|
const costByUnit = new Map(state.lanes.map((l) => [l.unitId, l.cost]));
|
|
178
245
|
const logRef = `runs/${runId}/activity.jsonl`;
|
|
179
|
-
const
|
|
246
|
+
const thresholds = resolveThresholds(deps.config.analyze?.thresholds, cliThresholds);
|
|
247
|
+
const summary = analyzeRun(events, {
|
|
248
|
+
logRef,
|
|
249
|
+
costByUnit,
|
|
250
|
+
...(thresholds ? { thresholds } : {}),
|
|
251
|
+
});
|
|
180
252
|
const summaryPath = join(repo, ".harness", "runs", runId, "summary.json");
|
|
181
253
|
writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
|
182
254
|
if (!hasFlag("no-ledger")) {
|
|
@@ -211,10 +283,17 @@ function report() {
|
|
|
211
283
|
async function demo() {
|
|
212
284
|
const harness = new FakeHarness([
|
|
213
285
|
{ id: "a", scope: ["src/a/**"], mergeFacts: { door: "two-way" } },
|
|
214
|
-
{
|
|
286
|
+
{
|
|
287
|
+
id: "b",
|
|
288
|
+
deps: ["a"],
|
|
289
|
+
scope: ["src/b/**"],
|
|
290
|
+
mergeFacts: { door: "two-way" },
|
|
291
|
+
},
|
|
215
292
|
{ id: "spike", mode: "explore", scope: ["src/spike/**"] },
|
|
216
293
|
]);
|
|
217
|
-
const runners = new Map([
|
|
294
|
+
const runners = new Map([
|
|
295
|
+
["claude", new FakeRunner({ name: "claude" })],
|
|
296
|
+
]);
|
|
218
297
|
const deps = {
|
|
219
298
|
harness,
|
|
220
299
|
routes: new FakeRouteSource(),
|
|
@@ -237,6 +316,14 @@ function hasFlag(name) {
|
|
|
237
316
|
return process.argv.includes(`--${name}`);
|
|
238
317
|
}
|
|
239
318
|
async function realRun() {
|
|
319
|
+
let cliThresholds;
|
|
320
|
+
try {
|
|
321
|
+
cliThresholds = parseCliThresholds();
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
process.stderr.write(`orchestrate run: ${e.message}\n`);
|
|
325
|
+
return 2;
|
|
326
|
+
}
|
|
240
327
|
const configPath = flag("config");
|
|
241
328
|
const routesPath = flag("routes");
|
|
242
329
|
const spoolDir = flag("spool");
|
|
@@ -244,6 +331,7 @@ async function realRun() {
|
|
|
244
331
|
const watch = hasFlag("watch");
|
|
245
332
|
const activityLogDir = flag("activity-log");
|
|
246
333
|
const noActivityLog = hasFlag("no-activity-log");
|
|
334
|
+
const worktreeRoot = flag("worktree-root");
|
|
247
335
|
let assembled;
|
|
248
336
|
try {
|
|
249
337
|
assembled = assembleRun({
|
|
@@ -255,6 +343,7 @@ async function realRun() {
|
|
|
255
343
|
...(watch ? { watch } : {}),
|
|
256
344
|
...(activityLogDir ? { activityLogDir } : {}),
|
|
257
345
|
...(noActivityLog ? { activityLog: false } : {}),
|
|
346
|
+
...(worktreeRoot ? { worktreeRoot } : {}),
|
|
258
347
|
});
|
|
259
348
|
}
|
|
260
349
|
catch (e) {
|
|
@@ -264,18 +353,37 @@ async function realRun() {
|
|
|
264
353
|
const repo = resolve(flag("repo") ?? process.cwd());
|
|
265
354
|
applyStubRunners(assembled.deps, repo);
|
|
266
355
|
const { deps, runId, systemId, specRef } = assembled;
|
|
267
|
-
const
|
|
356
|
+
const allUnits = await deps.harness.units();
|
|
357
|
+
const stories = loadUnitStories({ repo });
|
|
358
|
+
if (onlyPrefix &&
|
|
359
|
+
!allUnits.some((u) => matchesOnly({
|
|
360
|
+
id: u.id,
|
|
361
|
+
story: u.story ?? stories.get(u.id),
|
|
362
|
+
}, onlyPrefix))) {
|
|
363
|
+
process.stderr.write(`orchestrate run: no units match --only prefix "${onlyPrefix}"\n`);
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
const dagSize = allUnits.length;
|
|
268
367
|
const state = await runLoop(deps, { specRef, systemId, dagSize });
|
|
269
|
-
const analysis = await runAutoAnalyze(deps, repo, runId, state);
|
|
368
|
+
const analysis = await runAutoAnalyze(deps, repo, runId, state, cliThresholds);
|
|
270
369
|
summarise(state, analysis);
|
|
271
370
|
return exitCodeForRunState(state);
|
|
272
371
|
}
|
|
273
372
|
async function resume() {
|
|
373
|
+
let cliThresholds;
|
|
374
|
+
try {
|
|
375
|
+
cliThresholds = parseCliThresholds();
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
process.stderr.write(`orchestrate resume: ${e.message}\n`);
|
|
379
|
+
return 2;
|
|
380
|
+
}
|
|
274
381
|
const unitId = flag("unit");
|
|
275
382
|
if (!unitId) {
|
|
276
383
|
process.stderr.write("orchestrate resume --unit <id> [--repo <path>] [--routes <path>]\n");
|
|
277
384
|
return 2;
|
|
278
385
|
}
|
|
386
|
+
const repo = resolve(flag("repo") ?? process.cwd());
|
|
279
387
|
const configPath = flag("config");
|
|
280
388
|
const routesPath = flag("routes");
|
|
281
389
|
const spoolDir = flag("spool");
|
|
@@ -284,10 +392,19 @@ async function resume() {
|
|
|
284
392
|
const activityLogDir = flag("activity-log");
|
|
285
393
|
const noActivityLog = hasFlag("no-activity-log");
|
|
286
394
|
const resumesRunId = flag("resumes");
|
|
395
|
+
const explicitWorktreeRoot = flag("worktree-root");
|
|
396
|
+
let worktreeRoot = explicitWorktreeRoot;
|
|
397
|
+
if (!worktreeRoot) {
|
|
398
|
+
worktreeRoot = await resolveResumeWorktreeRoot({
|
|
399
|
+
repo,
|
|
400
|
+
unitId,
|
|
401
|
+
resumesRunId,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
287
404
|
let assembled;
|
|
288
405
|
try {
|
|
289
406
|
assembled = assembleRun({
|
|
290
|
-
repo
|
|
407
|
+
repo,
|
|
291
408
|
...(configPath ? { configPath } : {}),
|
|
292
409
|
...(routesPath ? { routesPath } : {}),
|
|
293
410
|
...(onlyPrefix ? { onlyPrefix } : {}),
|
|
@@ -296,13 +413,13 @@ async function resume() {
|
|
|
296
413
|
...(activityLogDir ? { activityLogDir } : {}),
|
|
297
414
|
...(noActivityLog ? { activityLog: false } : {}),
|
|
298
415
|
...(resumesRunId ? { resumesRunId } : {}),
|
|
416
|
+
...(worktreeRoot ? { worktreeRoot } : {}),
|
|
299
417
|
});
|
|
300
418
|
}
|
|
301
419
|
catch (error) {
|
|
302
420
|
process.stderr.write(`orchestrate resume: ${error.message}\n`);
|
|
303
421
|
return 2;
|
|
304
422
|
}
|
|
305
|
-
const repo = resolve(flag("repo") ?? process.cwd());
|
|
306
423
|
applyStubRunners(assembled.deps, repo);
|
|
307
424
|
const dagSize = (await assembled.deps.harness.units()).length;
|
|
308
425
|
const result = await resumeAfterDoor(unitId, assembled.deps);
|
|
@@ -317,33 +434,95 @@ async function resume() {
|
|
|
317
434
|
systemId: assembled.systemId,
|
|
318
435
|
dagSize,
|
|
319
436
|
});
|
|
320
|
-
const analysis = await runAutoAnalyze(assembled.deps, repo, assembled.runId, state);
|
|
437
|
+
const analysis = await runAutoAnalyze(assembled.deps, repo, assembled.runId, state, cliThresholds);
|
|
321
438
|
summarise(state, analysis);
|
|
322
439
|
return exitCodeForRunState(state);
|
|
323
440
|
}
|
|
441
|
+
async function catchUpInterruptedRuns(repo, harness, configThresholds) {
|
|
442
|
+
const runsRoot = join(repo, ".harness", "runs");
|
|
443
|
+
let entries;
|
|
444
|
+
try {
|
|
445
|
+
entries = readdirSync(runsRoot, { withFileTypes: true })
|
|
446
|
+
.filter((d) => d.isDirectory())
|
|
447
|
+
.map((d) => d.name)
|
|
448
|
+
.sort();
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
const analyzed = [];
|
|
454
|
+
for (const runId of entries) {
|
|
455
|
+
const runDir = join(runsRoot, runId);
|
|
456
|
+
const activityPath = join(runDir, "activity.jsonl");
|
|
457
|
+
const summaryPath = join(runDir, "summary.json");
|
|
458
|
+
if (existsSync(summaryPath))
|
|
459
|
+
continue;
|
|
460
|
+
if (!existsSync(activityPath))
|
|
461
|
+
continue;
|
|
462
|
+
let content;
|
|
463
|
+
try {
|
|
464
|
+
content = readFileSync(activityPath, "utf8");
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
let events;
|
|
470
|
+
try {
|
|
471
|
+
events = parseActivityLog(content);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const logRef = `runs/${runId}/activity.jsonl`;
|
|
477
|
+
const summary = analyzeRun(events, {
|
|
478
|
+
logRef,
|
|
479
|
+
...(configThresholds ? { thresholds: configThresholds } : {}),
|
|
480
|
+
});
|
|
481
|
+
writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
|
482
|
+
if (!hasFlag("no-ledger")) {
|
|
483
|
+
try {
|
|
484
|
+
await appendRunAnalyzed(harness, runId, summary);
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
process.stderr.write(`orchestrate sweep: run.analyzed ledger append failed for ${runId}: ${e.message}\n`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
analyzed.push(runId);
|
|
491
|
+
}
|
|
492
|
+
return analyzed;
|
|
493
|
+
}
|
|
324
494
|
async function sweep() {
|
|
495
|
+
const repo = resolve(flag("repo") ?? process.cwd());
|
|
325
496
|
const configPath = flag("config");
|
|
326
497
|
const routesPath = flag("routes");
|
|
327
498
|
const onlyPrefix = flag("only");
|
|
499
|
+
const worktreeRoot = flag("worktree-root");
|
|
328
500
|
let assembled;
|
|
329
501
|
try {
|
|
330
502
|
assembled = assembleRun({
|
|
331
|
-
repo
|
|
503
|
+
repo,
|
|
332
504
|
...(configPath ? { configPath } : {}),
|
|
333
505
|
...(routesPath ? { routesPath } : {}),
|
|
334
506
|
...(onlyPrefix ? { onlyPrefix } : {}),
|
|
507
|
+
...(worktreeRoot ? { worktreeRoot } : {}),
|
|
508
|
+
activityLog: false,
|
|
335
509
|
});
|
|
336
510
|
}
|
|
337
511
|
catch (error) {
|
|
338
512
|
process.stderr.write(`orchestrate sweep: ${error.message}\n`);
|
|
339
513
|
return 2;
|
|
340
514
|
}
|
|
341
|
-
applyStubRunners(assembled.deps,
|
|
515
|
+
applyStubRunners(assembled.deps, repo);
|
|
516
|
+
const analyzed = await catchUpInterruptedRuns(repo, assembled.deps.harness, assembled.deps.config.analyze?.thresholds);
|
|
342
517
|
const halts = await startupSweep(assembled.deps);
|
|
343
518
|
if (process.argv.includes("--json")) {
|
|
344
|
-
|
|
519
|
+
const payload = halts.length > 0 && analyzed.length === 0 ? halts : { halts, analyzed };
|
|
520
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
345
521
|
}
|
|
346
522
|
else {
|
|
523
|
+
for (const runId of analyzed) {
|
|
524
|
+
process.stdout.write(` ✓ caught up interrupted run ${runId}\n`);
|
|
525
|
+
}
|
|
347
526
|
for (const halt of halts) {
|
|
348
527
|
process.stderr.write(` ⛔ ${halt.kind} ${halt.unitId ?? ""}: ${halt.message}\n`);
|
|
349
528
|
}
|
|
@@ -359,7 +538,10 @@ function findLastRunId(runsRoot) {
|
|
|
359
538
|
catch {
|
|
360
539
|
return undefined;
|
|
361
540
|
}
|
|
362
|
-
return entries
|
|
541
|
+
return entries
|
|
542
|
+
.filter((e) => e.startsWith("run-"))
|
|
543
|
+
.sort()
|
|
544
|
+
.at(-1);
|
|
363
545
|
}
|
|
364
546
|
async function analyze() {
|
|
365
547
|
const repo = resolve(flag("repo") ?? process.cwd());
|
|
@@ -383,8 +565,40 @@ async function analyze() {
|
|
|
383
565
|
process.stderr.write(`orchestrate analyze: cannot read ${logPath}: ${e.message}\n`);
|
|
384
566
|
return 2;
|
|
385
567
|
}
|
|
568
|
+
const configPath = flag("config") ?? join(repo, "orchestration.json");
|
|
569
|
+
let fileConfig;
|
|
570
|
+
if (flag("config")) {
|
|
571
|
+
try {
|
|
572
|
+
fileConfig = loadOrchestrationConfig(configPath);
|
|
573
|
+
}
|
|
574
|
+
catch (e) {
|
|
575
|
+
process.stderr.write(`orchestrate analyze: ${e.message}\n`);
|
|
576
|
+
return 2;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else if (existsSync(configPath)) {
|
|
580
|
+
try {
|
|
581
|
+
fileConfig = loadOrchestrationConfig(configPath);
|
|
582
|
+
}
|
|
583
|
+
catch (e) {
|
|
584
|
+
process.stderr.write(`orchestrate analyze: ${e.message}\n`);
|
|
585
|
+
return 2;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
let cliThresholds;
|
|
589
|
+
try {
|
|
590
|
+
cliThresholds = parseCliThresholds();
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
process.stderr.write(`orchestrate analyze: ${e.message}\n`);
|
|
594
|
+
return 2;
|
|
595
|
+
}
|
|
596
|
+
const thresholds = resolveThresholds(fileConfig?.analyze?.thresholds, cliThresholds);
|
|
386
597
|
const logRef = runId ? `runs/${runId}/activity.jsonl` : logPath;
|
|
387
|
-
const summary = analyzeRun(events, {
|
|
598
|
+
const summary = analyzeRun(events, {
|
|
599
|
+
logRef,
|
|
600
|
+
...(thresholds ? { thresholds } : {}),
|
|
601
|
+
});
|
|
388
602
|
const outPath = flag("out") ?? (runId ? join(runsRoot, runId, "summary.json") : undefined);
|
|
389
603
|
if (outPath)
|
|
390
604
|
writeFileSync(outPath, JSON.stringify(summary, null, 2));
|
|
@@ -21,7 +21,14 @@ export function parseOrchestrationConfig(raw, source = "orchestration.json") {
|
|
|
21
21
|
if (f.forge !== undefined && f.forge !== "local" && f.forge !== "github") {
|
|
22
22
|
throw new ConfigError(`${source}: forge must be "local" or "github"`);
|
|
23
23
|
}
|
|
24
|
-
for (const k of [
|
|
24
|
+
for (const k of [
|
|
25
|
+
"maxParallel",
|
|
26
|
+
"retryBudget",
|
|
27
|
+
"maxRunCost",
|
|
28
|
+
"maxRunDuration",
|
|
29
|
+
"maxUnitCost",
|
|
30
|
+
"maxUnitDuration",
|
|
31
|
+
]) {
|
|
25
32
|
if (f[k] !== undefined && (typeof f[k] !== "number" || f[k] < 0)) {
|
|
26
33
|
throw new ConfigError(`${source}: ${k} must be a non-negative number`);
|
|
27
34
|
}
|
|
@@ -30,6 +37,22 @@ export function parseOrchestrationConfig(raw, source = "orchestration.json") {
|
|
|
30
37
|
if (enabled !== undefined && (!Array.isArray(enabled) || enabled.some((e) => typeof e !== "string"))) {
|
|
31
38
|
throw new ConfigError(`${source}: runners.enabled must be an array of strings`);
|
|
32
39
|
}
|
|
40
|
+
if (f.analyze !== undefined) {
|
|
41
|
+
if (f.analyze === null || typeof f.analyze !== "object" || Array.isArray(f.analyze)) {
|
|
42
|
+
throw new ConfigError(`${source}: analyze must be an object`);
|
|
43
|
+
}
|
|
44
|
+
const t = f.analyze.thresholds;
|
|
45
|
+
if (t !== undefined) {
|
|
46
|
+
if (t === null || typeof t !== "object" || Array.isArray(t)) {
|
|
47
|
+
throw new ConfigError(`${source}: analyze.thresholds must be an object`);
|
|
48
|
+
}
|
|
49
|
+
for (const k of ["highTurnCount", "exploreBeforeEdit"]) {
|
|
50
|
+
if (t[k] !== undefined && (typeof t[k] !== "number" || t[k] < 0)) {
|
|
51
|
+
throw new ConfigError(`${source}: analyze.thresholds.${k} must be a non-negative number`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
33
56
|
const cfg = {
|
|
34
57
|
forge: f.forge ?? DEFAULT_CONFIG.forge,
|
|
35
58
|
baseBranch: f.baseBranch ?? DEFAULT_CONFIG.baseBranch,
|
|
@@ -44,6 +67,26 @@ export function parseOrchestrationConfig(raw, source = "orchestration.json") {
|
|
|
44
67
|
cfg.maxRunCost = f.maxRunCost;
|
|
45
68
|
if (f.maxRunDuration !== undefined)
|
|
46
69
|
cfg.maxRunDuration = f.maxRunDuration;
|
|
70
|
+
if (f.maxUnitCost !== undefined)
|
|
71
|
+
cfg.maxUnitCost = f.maxUnitCost;
|
|
72
|
+
if (f.maxUnitDuration !== undefined)
|
|
73
|
+
cfg.maxUnitDuration = f.maxUnitDuration;
|
|
74
|
+
if (f.analyze !== undefined) {
|
|
75
|
+
cfg.analyze = {
|
|
76
|
+
...(f.analyze.thresholds !== undefined
|
|
77
|
+
? {
|
|
78
|
+
thresholds: {
|
|
79
|
+
...(f.analyze.thresholds.highTurnCount !== undefined
|
|
80
|
+
? { highTurnCount: f.analyze.thresholds.highTurnCount }
|
|
81
|
+
: {}),
|
|
82
|
+
...(f.analyze.thresholds.exploreBeforeEdit !== undefined
|
|
83
|
+
? { exploreBeforeEdit: f.analyze.thresholds.exploreBeforeEdit }
|
|
84
|
+
: {}),
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
47
90
|
return cfg;
|
|
48
91
|
}
|
|
49
92
|
export function loadOrchestrationConfig(path) {
|