@mikenguyen69/harness 0.1.0-beta.1

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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/dist/build-identity.json +1 -0
  4. package/dist/cancellation.js +138 -0
  5. package/dist/cli.js +47 -0
  6. package/dist/dispatch.js +70 -0
  7. package/dist/help.js +47 -0
  8. package/dist/internal/agent/adapter/conformance.js +53 -0
  9. package/dist/internal/agent/adapter/hook.js +113 -0
  10. package/dist/internal/agent/adapter/install.js +30 -0
  11. package/dist/internal/agent/adapter/scope.js +55 -0
  12. package/dist/internal/agent/adapter/session.js +63 -0
  13. package/dist/internal/agent/adapter/settings.js +51 -0
  14. package/dist/internal/agent/cli.js +79 -0
  15. package/dist/internal/agent/evals/index.js +111 -0
  16. package/dist/internal/agent/index.js +15 -0
  17. package/dist/internal/agent/ledger-read.js +27 -0
  18. package/dist/internal/agent/routing/cli.js +104 -0
  19. package/dist/internal/agent/routing/index.js +123 -0
  20. package/dist/internal/agent/telemetry/index.js +97 -0
  21. package/dist/internal/orchestration/activity/index.js +111 -0
  22. package/dist/internal/orchestration/analyze/index.js +237 -0
  23. package/dist/internal/orchestration/cli.js +475 -0
  24. package/dist/internal/orchestration/config/index.js +66 -0
  25. package/dist/internal/orchestration/doctor/index.js +202 -0
  26. package/dist/internal/orchestration/forge/index.js +121 -0
  27. package/dist/internal/orchestration/git/index.js +181 -0
  28. package/dist/internal/orchestration/harness/cli.js +378 -0
  29. package/dist/internal/orchestration/harness/index.js +186 -0
  30. package/dist/internal/orchestration/harness/shim.js +182 -0
  31. package/dist/internal/orchestration/index.js +29 -0
  32. package/dist/internal/orchestration/loop/index.js +1107 -0
  33. package/dist/internal/orchestration/prompts/index.js +199 -0
  34. package/dist/internal/orchestration/report/index.js +125 -0
  35. package/dist/internal/orchestration/routes/index.js +85 -0
  36. package/dist/internal/orchestration/run/index.js +221 -0
  37. package/dist/internal/orchestration/runner/index.js +90 -0
  38. package/dist/internal/orchestration/runners/claude/index.js +246 -0
  39. package/dist/internal/orchestration/runners/claude/sdk.js +198 -0
  40. package/dist/internal/orchestration/runners/claude/wiring.js +98 -0
  41. package/dist/internal/orchestration/runners/codex/exec.js +114 -0
  42. package/dist/internal/orchestration/runners/codex/index.js +72 -0
  43. package/dist/internal/orchestration/runners/conformance/index.js +169 -0
  44. package/dist/internal/orchestration/runners/cursor/exec.js +531 -0
  45. package/dist/internal/orchestration/runners/cursor/index.js +184 -0
  46. package/dist/internal/orchestration/runners/stub/index.js +168 -0
  47. package/dist/internal/orchestration/runners/telemetry.js +36 -0
  48. package/dist/internal/orchestration/runstate/index.js +213 -0
  49. package/dist/internal/orchestration/runstate/publish.js +115 -0
  50. package/dist/internal/orchestration/worktree/index.js +89 -0
  51. package/dist/internal/system/adapters/index.js +19 -0
  52. package/dist/internal/system/agentops/index.js +16 -0
  53. package/dist/internal/system/agentops/scope.js +67 -0
  54. package/dist/internal/system/assets.js +45 -0
  55. package/dist/internal/system/board/cli.js +56 -0
  56. package/dist/internal/system/board/index.js +276 -0
  57. package/dist/internal/system/board/live.js +277 -0
  58. package/dist/internal/system/calibration/cli.js +45 -0
  59. package/dist/internal/system/calibration/index.js +141 -0
  60. package/dist/internal/system/cli.js +174 -0
  61. package/dist/internal/system/corpus/replay.js +79 -0
  62. package/dist/internal/system/digest/cli.js +23 -0
  63. package/dist/internal/system/digest/index.js +145 -0
  64. package/dist/internal/system/doors/classify.js +94 -0
  65. package/dist/internal/system/doors/cli.js +184 -0
  66. package/dist/internal/system/doors/index.js +249 -0
  67. package/dist/internal/system/doors/metrics.js +47 -0
  68. package/dist/internal/system/escapes/cli.js +45 -0
  69. package/dist/internal/system/escapes/index.js +81 -0
  70. package/dist/internal/system/explore/cli.js +77 -0
  71. package/dist/internal/system/explore/index.js +91 -0
  72. package/dist/internal/system/gates/cli.js +43 -0
  73. package/dist/internal/system/gates/detectors.js +55 -0
  74. package/dist/internal/system/gates/pre.js +51 -0
  75. package/dist/internal/system/hub/cli.js +51 -0
  76. package/dist/internal/system/hub/index.js +146 -0
  77. package/dist/internal/system/init/index.js +83 -0
  78. package/dist/internal/system/ledger/cli.js +54 -0
  79. package/dist/internal/system/ledger/index.js +176 -0
  80. package/dist/internal/system/manifest/index.js +207 -0
  81. package/dist/internal/system/provenance/index.js +13 -0
  82. package/dist/internal/system/report/index.js +15 -0
  83. package/dist/internal/system/runner/index.js +74 -0
  84. package/dist/internal/system/runstatus/cli.js +86 -0
  85. package/dist/internal/system/runstatus/index.js +168 -0
  86. package/dist/internal/system/sequencer/briefing.js +75 -0
  87. package/dist/internal/system/sequencer/cli.js +170 -0
  88. package/dist/internal/system/sequencer/index.js +156 -0
  89. package/dist/internal/system/spec/cli.js +110 -0
  90. package/dist/internal/system/spec/index.js +212 -0
  91. package/dist/internal/system/spec/materialize.js +111 -0
  92. package/dist/internal/system/spec/openspec-parse.js +83 -0
  93. package/dist/internal/system/spec/project.js +208 -0
  94. package/dist/internal/system/substrate/index.js +14 -0
  95. package/dist/internal/system/system/index.js +159 -0
  96. package/dist/internal/system/units/index.js +87 -0
  97. package/dist/internal/system/verify/cli.js +103 -0
  98. package/dist/internal/system/verify/declined-reasons.js +61 -0
  99. package/dist/internal/system/verify/index.js +188 -0
  100. package/dist/internal/system/verify/signing.js +79 -0
  101. package/dist/paths.js +50 -0
  102. package/dist/routes.js +93 -0
  103. package/dist/version.js +30 -0
  104. package/dist/win32-process-group.js +360 -0
  105. package/package.json +41 -0
  106. package/profiles/python/bindings.toml +59 -0
  107. package/profiles/terraform/bindings.toml +66 -0
  108. package/profiles/typescript/bindings.toml +64 -0
  109. package/profiles/typescript/smoke/README.md +8 -0
  110. package/schema/baseline.schema.json +26 -0
  111. package/schema/decision-classes.yaml +45 -0
  112. package/schema/envelope.schema.json +186 -0
  113. package/schema/ledger-event.schema.json +235 -0
  114. package/schema/manifest.schema.json +119 -0
  115. package/schema/routes.schema.json +55 -0
  116. package/schema/run-status.schema.json +162 -0
  117. package/schema/session-event.schema.json +74 -0
  118. package/schema/spec.schema.json +114 -0
  119. package/schema/system.schema.json +142 -0
  120. package/templates/ci/verify.yml +23 -0
  121. package/templates/target-kit/README.md +78 -0
  122. package/templates/target-kit/component.toml +20 -0
  123. package/templates/target-kit/orchestration.json +9 -0
  124. package/templates/target-kit/profiles/target/bindings.toml +6 -0
  125. package/templates/target-kit/routes.toml +5 -0
  126. package/templates/target-kit/specs/first-unit.yaml +11 -0
  127. package/templates/target-kit/system.toml +7 -0
@@ -0,0 +1,1107 @@
1
+ import { reconstructSessionEvents } from "../runners/telemetry.js";
2
+ import { NULL_PUBLISHER } from "../runstate/publish.js";
3
+ import picomatch from "picomatch";
4
+ function isRemoteTrackingRef(ref) {
5
+ return ref.startsWith("refs/remotes/") || ref.startsWith("origin/");
6
+ }
7
+ /**
8
+ * In-memory `Git` for the dry loop and tests. Defaults: a clean rebase produces
9
+ * `<branch>@rebased`, the base ref never moves, FF always succeeds, every sha is
10
+ * an ancestor. Test hooks script the failure modes M3 must handle.
11
+ */
12
+ export class FakeGit {
13
+ landed = [];
14
+ rebases = [];
15
+ /** branches passed to `fetchRemoteTracking` — the startup sweep's refresh. */
16
+ fetches = [];
17
+ detached = new Set();
18
+ remoteOnly = new Set();
19
+ localOnly = new Set();
20
+ fetchFailed = false;
21
+ fetchThrows = false;
22
+ changed = new Map();
23
+ dirtyWorktrees = new Map();
24
+ conflicts = new Map(); // branch -> queued conflict messages
25
+ ffFail = new Map();
26
+ heads = new Map();
27
+ baseMoves = 0;
28
+ /** mark `sha` as NOT an ancestor of anything (simulate ledger-ahead-of-git). */
29
+ detach(sha) {
30
+ this.detached.add(sha);
31
+ return this;
32
+ }
33
+ /** sha is an ancestor of the remote-tracking base, not the stale local ref. */
34
+ staleLocal(sha) {
35
+ this.remoteOnly.add(sha);
36
+ return this;
37
+ }
38
+ /** sha is on the local base; the remote-tracking ref is behind. */
39
+ remoteBehind(sha) {
40
+ this.localOnly.add(sha);
41
+ return this;
42
+ }
43
+ /** simulate no remote / offline host / fetch failure. */
44
+ failFetch() {
45
+ this.fetchFailed = true;
46
+ return this;
47
+ }
48
+ /** simulate a thrown fetch (must not become an infrastructure-failure halt). */
49
+ throwOnFetch() {
50
+ this.fetchThrows = true;
51
+ return this;
52
+ }
53
+ /** what `git diff --name-only` returns for a branch. */
54
+ setChangedFiles(branch, files) {
55
+ this.changed.set(branch, files);
56
+ return this;
57
+ }
58
+ /** uncommitted paths in a lane worktree before the orchestrator backstop commits. */
59
+ setWorktreeDirty(path, files) {
60
+ this.dirtyWorktrees.set(path, files);
61
+ return this;
62
+ }
63
+ /** queue a rebase conflict for a branch — one per `rebase()` call, in order. */
64
+ conflictOn(branch, message = "CONFLICT (content): merge conflict in src/x") {
65
+ this.conflicts.set(branch, [...(this.conflicts.get(branch) ?? []), message]);
66
+ return this;
67
+ }
68
+ /** fail the fast-forward of `ref` (push race / branch protection). */
69
+ failFastForward(ref, reason = "! [rejected] (fetch first)") {
70
+ this.ffFail.set(ref, reason);
71
+ return this;
72
+ }
73
+ /** make `headSha(ref)` return a different sha on its Nth call onward (base moved). */
74
+ moveBaseAfter(calls) {
75
+ this.baseMoves = calls;
76
+ return this;
77
+ }
78
+ headCalls = new Map();
79
+ async headSha(ref) {
80
+ const n = (this.headCalls.get(ref) ?? 0) + 1;
81
+ this.headCalls.set(ref, n);
82
+ if (this.baseMoves && n > this.baseMoves)
83
+ return `${ref}@moved`;
84
+ return this.heads.get(ref) ?? `${ref}@head`;
85
+ }
86
+ async isAncestor(sha, ref) {
87
+ if (this.remoteOnly.has(sha))
88
+ return isRemoteTrackingRef(ref);
89
+ if (this.localOnly.has(sha))
90
+ return !isRemoteTrackingRef(ref);
91
+ return !this.detached.has(sha);
92
+ }
93
+ async fetchRemoteTracking(branch) {
94
+ this.fetches.push(branch);
95
+ if (this.fetchThrows)
96
+ throw new Error("network unreachable");
97
+ if (this.fetchFailed)
98
+ return { ok: false };
99
+ return { ok: true, trackingRef: `origin/${branch.replace(/^refs\/heads\//, "")}` };
100
+ }
101
+ async rebase(branch, onto, _workspace) {
102
+ this.rebases.push({ branch, onto });
103
+ const queued = this.conflicts.get(branch);
104
+ if (queued && queued.length) {
105
+ return { ok: false, conflict: queued.shift() };
106
+ }
107
+ return { ok: true, tipSha: `${branch}@rebased` };
108
+ }
109
+ async fastForward(ref, sha) {
110
+ const reason = this.ffFail.get(ref);
111
+ if (reason)
112
+ return { ok: false, reason };
113
+ this.landed.push({ ref, sha });
114
+ return { ok: true };
115
+ }
116
+ async changedFiles(branch, _base, _workspace) {
117
+ return this.changed.get(branch) ?? [];
118
+ }
119
+ diffs = new Map();
120
+ setDiffText(branch, text) {
121
+ this.diffs.set(branch, text);
122
+ return this;
123
+ }
124
+ async diffText(branch, _base, _workspace) {
125
+ return this.diffs.get(branch) ?? "";
126
+ }
127
+ async workingTreeFiles(workspace) {
128
+ return this.dirtyWorktrees.get(workspace.path) ?? [];
129
+ }
130
+ async commitWorkspace(workspace, _message) {
131
+ const files = this.dirtyWorktrees.get(workspace.path) ?? [];
132
+ this.dirtyWorktrees.delete(workspace.path);
133
+ const branch = workspace.branch;
134
+ const existing = this.changed.get(branch) ?? [];
135
+ this.changed.set(branch, [...existing, ...files]);
136
+ return `${branch}@committed`;
137
+ }
138
+ }
139
+ export const DEFAULT_CONFIG = {
140
+ forge: "local",
141
+ baseBranch: "main",
142
+ maxParallel: 1,
143
+ retryBudget: 2,
144
+ systemToml: null,
145
+ runnersEnabled: ["claude"],
146
+ activityFeedIntervalMs: 5000,
147
+ };
148
+ const needsYouKindFor = {
149
+ "one-way-door": "one-way-door",
150
+ "retry-exhausted": "retry-exhausted",
151
+ escalation: "escalation",
152
+ "verify-halt": "escalation",
153
+ "stranded-in-review": "stranded-in-review",
154
+ "ledger-ahead-of-git": "ledger-ahead-of-git",
155
+ "infrastructure-failure": "infrastructure-failure",
156
+ "config-error": "budget",
157
+ budget: "budget",
158
+ };
159
+ function recordHalt(log, halt) {
160
+ log.append({
161
+ t: "needs.you",
162
+ kind: needsYouKindFor[halt.kind],
163
+ ...(halt.unitId ? { unitId: halt.unitId } : {}),
164
+ message: halt.message,
165
+ unblock: halt.unblock,
166
+ });
167
+ }
168
+ /** Best-effort publish — never awaited by the loop hot path. */
169
+ function publishFeed(deps, runId) {
170
+ const publisher = deps.publisher ?? NULL_PUBLISHER;
171
+ publisher.publish(deps.log.snapshot()).catch((e) => {
172
+ deps.log.append({
173
+ t: "lane.note",
174
+ unitId: runId,
175
+ note: `run-status publish threw (${e?.message ?? e}) — feed degraded`,
176
+ });
177
+ });
178
+ }
179
+ function makeActivitySink(deps, unitId, runId, lane) {
180
+ const { log, config } = deps;
181
+ const now = deps.now ?? Date.now;
182
+ const interval = config.activityFeedIntervalMs ?? 5000;
183
+ let lastMidRunPublish = 0;
184
+ const onActivity = (u) => {
185
+ try {
186
+ log.recordLaneActivity(unitId, u);
187
+ }
188
+ catch (e) {
189
+ log.append({
190
+ t: "lane.note",
191
+ unitId,
192
+ note: `activity fold failed (${e.message}) — feed degraded`,
193
+ });
194
+ return;
195
+ }
196
+ // Independent try/catch: a durable-log write failure must never mask (or
197
+ // be masked by) the RunState fold above.
198
+ try {
199
+ deps.activityLog?.writeAgentActivity({
200
+ runId,
201
+ unitId,
202
+ step: lane.getStep(),
203
+ attempt: lane.getAttempt(),
204
+ session: lane.session,
205
+ runner: lane.runner,
206
+ model: lane.model,
207
+ at: now(),
208
+ line: u.line,
209
+ turn: u.turn,
210
+ tokens: u.tokens,
211
+ ...(u.kind ? { kind: u.kind } : {}),
212
+ ...(u.tool ? { tool: u.tool } : {}),
213
+ ...(u.target ? { target: u.target } : {}),
214
+ });
215
+ }
216
+ catch (e) {
217
+ log.append({
218
+ t: "lane.note",
219
+ unitId,
220
+ note: `activity log write failed (${e.message}) — durable log degraded`,
221
+ });
222
+ }
223
+ const t = now();
224
+ if (t - lastMidRunPublish >= interval) {
225
+ lastMidRunPublish = t;
226
+ publishFeed(deps, runId);
227
+ }
228
+ };
229
+ return { onActivity, publishOnStep: () => publishFeed(deps, runId) };
230
+ }
231
+ function mutationFailure(unitId, operation, error, log) {
232
+ const detail = error instanceof Error ? error.message : String(error);
233
+ const halt = {
234
+ kind: "infrastructure-failure",
235
+ unitId,
236
+ message: `${unitId}: ${operation} failed — ${detail}`,
237
+ unblock: `repair the harness CLI/ledger failure, then rerun ${unitId}`,
238
+ };
239
+ recordHalt(log, halt);
240
+ return { outcome: "halted", halt };
241
+ }
242
+ /** literal prefix before the first wildcard — the M5 "deliberately dumb" scope check. */
243
+ function scopePrefix(glob) {
244
+ const star = glob.search(/[*?[]/);
245
+ const head = star === -1 ? glob : glob.slice(0, star);
246
+ const slash = head.lastIndexOf("/");
247
+ return slash === -1 ? head : head.slice(0, slash + 1);
248
+ }
249
+ /** two scopes overlap unless cheap string comparison proves them disjoint. */
250
+ export function scopesDisjoint(a, b) {
251
+ for (const ga of a) {
252
+ const pa = scopePrefix(ga);
253
+ for (const gb of b) {
254
+ const pb = scopePrefix(gb);
255
+ if (pa === pb || pa.startsWith(pb) || pb.startsWith(pa))
256
+ return false;
257
+ }
258
+ }
259
+ return true;
260
+ }
261
+ export async function plan(deps) {
262
+ const { harness, routes, config, log } = deps;
263
+ const parked = [];
264
+ const halts = [];
265
+ // escalations raised out-of-band reach the operator via needs-you (D0 acceptance).
266
+ for (const u of await harness.escalated()) {
267
+ log.append({
268
+ t: "unit.escalated",
269
+ unitId: u.id,
270
+ reason: "raised out-of-band (harness next --escalated)",
271
+ wants: "operator decision: widen | split | abort",
272
+ });
273
+ const halt = {
274
+ kind: "escalation",
275
+ unitId: u.id,
276
+ message: `${u.id} is escalated and waiting on an operator decision`,
277
+ unblock: `harness door approve ${u.id} --reason "<widen|split|abort rationale>" --who <operator>`,
278
+ };
279
+ recordHalt(log, halt);
280
+ halts.push(halt);
281
+ }
282
+ const ready = await harness.next(deps.onlyPrefix ? { only: deps.onlyPrefix } : undefined);
283
+ const buildable = [];
284
+ for (const u of ready) {
285
+ if (u.mode === "explore") {
286
+ // explore units have no edge to merged — parked, never claimed, never
287
+ // counted against maxParallel or the retry budget (M1).
288
+ parked.push({ unitId: u.id, reason: "explore — operator-driven" });
289
+ log.append({ t: "unit.parked", unitId: u.id, reason: "explore — operator-driven" });
290
+ continue;
291
+ }
292
+ buildable.push(u);
293
+ }
294
+ // resolve routing; a runner not in `enabled` is a config error at plan time.
295
+ const resolved = [];
296
+ for (const u of buildable) {
297
+ const policy = await routes.resolve(u.id, u.component);
298
+ if (!config.runnersEnabled.includes(policy.runner)) {
299
+ const halt = {
300
+ kind: "config-error",
301
+ unitId: u.id,
302
+ message: `route for ${u.id} resolves to runner "${policy.runner}" which is not in orchestration.json runners.enabled`,
303
+ unblock: `add "${policy.runner}" to runners.enabled in orchestration.json, or fix the route`,
304
+ };
305
+ recordHalt(log, halt);
306
+ halts.push(halt);
307
+ continue;
308
+ }
309
+ resolved.push({ unit: u, policy });
310
+ }
311
+ // order by deps then id; take up to maxParallel that are pairwise "probably
312
+ // disjoint" (M5). The real per-unit scope globs are only known post-claim, so
313
+ // this is the cheap conservative proxy: two units in the SAME component are
314
+ // treated as overlapping (serialise them); different components, or — absent a
315
+ // component — different literal id-prefixes, are treated as disjoint. A false
316
+ // overlap only costs one round of serialisation.
317
+ resolved.sort((a, b) => a.unit.deps.length - b.unit.deps.length || a.unit.id.localeCompare(b.unit.id));
318
+ const lanes = [];
319
+ for (const cand of resolved) {
320
+ if (lanes.length >= config.maxParallel)
321
+ break;
322
+ const overlaps = lanes.some((l) => !probablyDisjoint(l.unit, cand.unit));
323
+ if (!overlaps)
324
+ lanes.push(cand);
325
+ }
326
+ return { lanes, parked, halts };
327
+ }
328
+ /** the cheap pre-claim disjoint proxy — see the comment at the call site. */
329
+ function probablyDisjoint(a, b) {
330
+ if (a.component && b.component)
331
+ return a.component !== b.component;
332
+ return scopesDisjoint([`src/${a.id}/`], [`src/${b.id}/`]);
333
+ }
334
+ export async function step(unit, policy, deps) {
335
+ const { harness, runners, git, config, log } = deps;
336
+ const mint = deps.mintSession ?? defaultSession;
337
+ const runner = runners.get(policy.runner);
338
+ if (!runner) {
339
+ const halt = {
340
+ kind: "config-error",
341
+ unitId: unit.id,
342
+ message: `no Runner implementation registered for "${policy.runner}"`,
343
+ unblock: `register a ${policy.runner} runner or re-route ${unit.id}`,
344
+ };
345
+ recordHalt(log, halt);
346
+ return { outcome: "halted", halt };
347
+ }
348
+ // one session id per lane — unique per dispatch, stable across the lane's steps.
349
+ const session = mint();
350
+ const runId = deps.runId ?? unit.id;
351
+ const now = deps.now ?? Date.now;
352
+ // Hoisted above makeActivitySink/setStep (and the retry loop that mutates
353
+ // `attempt` below) so the very first `setStep("claim")` call — before any
354
+ // retry has happened — has an initialized attempt/step pair to report to the
355
+ // durable activity log. `currentStep` exists only for that log; RunState's
356
+ // `lane.step` events are unaffected (unchanged shape/order).
357
+ let attempt = 0;
358
+ let currentStep = "claim";
359
+ const { onActivity, publishOnStep } = makeActivitySink(deps, unit.id, runId, {
360
+ session,
361
+ runner: runner.name,
362
+ model: policy.model,
363
+ getStep: () => currentStep,
364
+ getAttempt: () => attempt,
365
+ });
366
+ const setStep = (s) => {
367
+ currentStep = s;
368
+ log.append({ t: "lane.step", unitId: unit.id, step: s });
369
+ try {
370
+ deps.activityLog?.writeOrchestratorStep({
371
+ runId,
372
+ unitId: unit.id,
373
+ step: s,
374
+ attempt,
375
+ session,
376
+ at: now(),
377
+ });
378
+ }
379
+ catch (e) {
380
+ log.append({
381
+ t: "lane.note",
382
+ unitId: unit.id,
383
+ note: `activity log step write failed (${e.message})`,
384
+ });
385
+ }
386
+ publishOnStep();
387
+ };
388
+ // --- claim -------------------------------------------------------------
389
+ setStep("claim");
390
+ const briefing = await harness.claim(unit.id, session);
391
+ if (!briefing) {
392
+ // lost the race (a sibling lane took it, or it stopped being ready) — not a
393
+ // halt: the loop re-plans and moves on.
394
+ return { outcome: "yielded", unitId: unit.id };
395
+ }
396
+ let workspace;
397
+ try {
398
+ workspace = await deps.workspaces?.acquire({
399
+ unitId: unit.id,
400
+ branch: briefing.branch,
401
+ baseBranch: config.baseBranch,
402
+ });
403
+ }
404
+ catch (error) {
405
+ return mutationFailure(unit.id, "worktree acquisition", error, log);
406
+ }
407
+ log.append({
408
+ t: "lane.opened",
409
+ unitId: unit.id,
410
+ session,
411
+ runner: runner.name,
412
+ model: policy.model,
413
+ retryBudget: config.retryBudget,
414
+ emitter: runner.capabilities().sessionTelemetry ? "session" : "orchestrator",
415
+ });
416
+ // retry loop: a retry re-dispatches the implementer on the same branch with a
417
+ // `retry` block carrying verbatim failure evidence (M2). Escalation parks and
418
+ // breaks out immediately — it never consumes the budget (M1).
419
+ for (;;) {
420
+ attempt += 1;
421
+ log.append({ t: "lane.attempt", unitId: unit.id, attempt });
422
+ // --- implement -----------------------------------------------------
423
+ setStep("implement");
424
+ try {
425
+ await harness.heartbeat(unit.id, session);
426
+ }
427
+ catch (error) {
428
+ return mutationFailure(unit.id, "heartbeat mutation", error, log);
429
+ }
430
+ const impl = await runner.run(briefing, policy, "implementer", workspace, onActivity);
431
+ log.append({ t: "lane.cost", unitId: unit.id, delta: impl.cost });
432
+ if (impl.telemetryError)
433
+ return mutationFailure(unit.id, "implementer session telemetry", impl.telemetryError, log);
434
+ await emitReconstructedTelemetry(deps, { unitId: unit.id, session, runner: runner.name, model: policy.model, kind: "implementer" }, runner, impl);
435
+ if (impl.escalation) {
436
+ log.append({
437
+ t: "unit.escalated",
438
+ unitId: unit.id,
439
+ reason: impl.escalation.reason,
440
+ wants: impl.escalation.wants,
441
+ });
442
+ const halt = {
443
+ kind: "escalation",
444
+ unitId: unit.id,
445
+ message: `${unit.id}: implementer escalated — ${impl.escalation.reason} (wants: ${impl.escalation.wants})`,
446
+ unblock: `resolve the escalation, then: harness door approve ${unit.id} --reason "<why>" --who <operator>`,
447
+ };
448
+ recordHalt(log, halt);
449
+ return { outcome: "halted", halt };
450
+ }
451
+ if (!impl.ok) {
452
+ if (registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, "implementer gate/verify failure"))
453
+ continue;
454
+ return exhausted(unit.id, config.retryBudget, log);
455
+ }
456
+ if (workspace) {
457
+ const materialized = await materializeImplementerWorkspace(git, workspace, briefing, unit.id);
458
+ if (!materialized.ok) {
459
+ if (registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, materialized.evidence))
460
+ continue;
461
+ return exhausted(unit.id, config.retryBudget, log, materialized.evidence);
462
+ }
463
+ }
464
+ // --- authoritative orchestration backstop (M2.5) ------------------
465
+ // A write-time hook gives fast feedback, but never replaces this branch
466
+ // diff check. Every runner is checked against git before verify/merge.
467
+ const gitFiles = await git.changedFiles(briefing.branch, config.baseBranch, workspace);
468
+ const touched = [...new Set([...impl.filesTouched, ...gitFiles])];
469
+ const violation = outOfScope(touched, briefing.scope);
470
+ if (violation.length) {
471
+ const evidence = `post-hoc scope violation: ${violation.join(", ")}`;
472
+ const retry = registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, evidence);
473
+ if (retry)
474
+ continue;
475
+ return exhausted(unit.id, config.retryBudget, log, evidence);
476
+ }
477
+ // --- rebase before verify (M3) — the verified tree must be the landed tree.
478
+ setStep("rebase");
479
+ const rb = await git.rebase(briefing.branch, config.baseBranch, workspace);
480
+ if (!rb.ok || !rb.tipSha) {
481
+ if (registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, `rebase onto ${config.baseBranch} conflicted:\n${rb.conflict ?? "(no detail)"}`))
482
+ continue;
483
+ return exhausted(unit.id, config.retryBudget, log);
484
+ }
485
+ let rebasedTip = rb.tipSha;
486
+ let baseAtRebase = await git.headSha(config.baseBranch);
487
+ // --- done (ledger append) — in-review, so the verifier reviews a unit the
488
+ // projection agrees is done. A retry after this point re-emits unit.done
489
+ // (in-review → in-review is an idempotent no-op in the fold — v0-plan M3).
490
+ setStep("done");
491
+ try {
492
+ await harness.ledgerAppend({ type: "unit.done", unitId: unit.id, session });
493
+ }
494
+ catch (error) {
495
+ return mutationFailure(unit.id, "unit.done ledger append", error, log);
496
+ }
497
+ // --- verify ring 1, then ring 2 — both against the REBASED tip.
498
+ setStep("verify");
499
+ const verifySession = impl.sessionId ?? session;
500
+ const v1 = await harness.verify(briefing, verifySession, baseAtRebase, workspace);
501
+ if (v1.status === "halt")
502
+ return verifyHalt(unit.id, v1.evidence, log);
503
+ if (v1.status === "fail") {
504
+ const retry = registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, v1.evidence);
505
+ if (retry)
506
+ continue;
507
+ return exhausted(unit.id, config.retryBudget, log);
508
+ }
509
+ if (v1.envelopeHash)
510
+ log.append({ t: "lane.envelope", unitId: unit.id, hash: v1.envelopeHash });
511
+ if (config.systemToml) {
512
+ setStep("verify-system");
513
+ const v2 = await harness.verifySystem(unit.id, config.systemToml, workspace);
514
+ if (v2.status === "halt")
515
+ return verifyHalt(unit.id, v2.evidence, log);
516
+ if (v2.status === "fail") {
517
+ const retry = registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, v2.evidence);
518
+ if (retry)
519
+ continue;
520
+ return exhausted(unit.id, config.retryBudget, log);
521
+ }
522
+ }
523
+ else {
524
+ // brownfield allowance — recorded, never a halt (v0-plan M3).
525
+ log.append({
526
+ t: "lane.note",
527
+ unitId: unit.id,
528
+ note: "ring 2 skipped — the driven project declares no system.toml (brownfield one-blob allowance)",
529
+ });
530
+ }
531
+ // --- review (independent verifier session, read-only) -----------
532
+ setStep("review");
533
+ const review = await runner.run(briefing, policy, "verifier", workspace, onActivity);
534
+ log.append({ t: "lane.cost", unitId: unit.id, delta: review.cost });
535
+ if (review.telemetryError)
536
+ return mutationFailure(unit.id, "verifier session telemetry", review.telemetryError, log);
537
+ await emitReconstructedTelemetry(deps, { unitId: unit.id, session, runner: runner.name, model: policy.model, kind: "verifier" }, runner, review);
538
+ // a verifier that touches a file voids its verdict (M2 backstop). A
539
+ // ClaudeRunner verifier structurally cannot write (no write tools); this
540
+ // catches a runner that reports one anyway. Isolating a degraded-tier
541
+ // verifier's writes from the implementer's on the same branch needs a
542
+ // pre/post snapshot — deferred to M3's rebase machinery.
543
+ if (review.filesTouched.length > 0) {
544
+ const retry = registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, `verifier session wrote files (${review.filesTouched.join(", ")}) — verdict voided`);
545
+ if (retry)
546
+ continue;
547
+ return exhausted(unit.id, config.retryBudget, log);
548
+ }
549
+ if (review.verdict && !review.verdict.approved) {
550
+ const retry = registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, `verifier rejected: ${review.verdict.reasons.join("; ")}`);
551
+ if (retry)
552
+ continue;
553
+ return exhausted(unit.id, config.retryBudget, log);
554
+ }
555
+ // --- re-rebase if the base moved (a sibling lane landed first — routine
556
+ // under M5). A clean re-rebase keeps the verifier's verdict (same content,
557
+ // new parent); a conflicted one is a retry. Re-run ring 1 against the new
558
+ // tip so `harness merge` never sees a sha ring 1 has not (M3).
559
+ if ((await git.headSha(config.baseBranch)) !== baseAtRebase) {
560
+ log.append({
561
+ t: "lane.note",
562
+ unitId: unit.id,
563
+ note: `base ${config.baseBranch} moved after ring 1 — re-rebasing and re-running ring 1`,
564
+ });
565
+ setStep("rebase");
566
+ const rr = await git.rebase(briefing.branch, config.baseBranch, workspace);
567
+ if (!rr.ok || !rr.tipSha) {
568
+ if (registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, `re-rebase onto moved ${config.baseBranch} conflicted:\n${rr.conflict ?? "(no detail)"}`))
569
+ continue;
570
+ return exhausted(unit.id, config.retryBudget, log);
571
+ }
572
+ rebasedTip = rr.tipSha;
573
+ baseAtRebase = await git.headSha(config.baseBranch);
574
+ setStep("verify");
575
+ const v1b = await harness.verify(briefing, verifySession, baseAtRebase, workspace);
576
+ if (v1b.status === "halt")
577
+ return verifyHalt(unit.id, v1b.evidence, log);
578
+ if (v1b.status === "fail") {
579
+ const retry = registerRetryOrExhaust(unit.id, attempt, config.retryBudget, log, v1b.evidence);
580
+ if (retry)
581
+ continue;
582
+ return exhausted(unit.id, config.retryBudget, log);
583
+ }
584
+ if (v1b.envelopeHash)
585
+ log.append({ t: "lane.envelope", unitId: unit.id, hash: v1b.envelopeHash });
586
+ }
587
+ // --- authorize merge — classify facts and verify signed doors before Git
588
+ // changes, but do not claim `unit.merged` until the forge actually lands.
589
+ // Propose-only from the route means no auto-merge even on green.
590
+ if (policy.mode === "propose-only") {
591
+ setStep("merge");
592
+ log.append({
593
+ t: "lane.note",
594
+ unitId: unit.id,
595
+ note: `route mode is propose-only — verified at ${rebasedTip}, left for an operator merge`,
596
+ });
597
+ return { outcome: "propose-only", unitId: unit.id, sha: rebasedTip };
598
+ }
599
+ setStep("merge");
600
+ const authorization = await harness.authorizeMerge(unit.id, briefing.mergeFacts, workspace);
601
+ if (authorization.status === "door-open") {
602
+ log.append({ t: "lane.door", unitId: unit.id, doorId: authorization.doorId });
603
+ const halt = {
604
+ kind: "one-way-door",
605
+ unitId: unit.id,
606
+ message: `${unit.id}: merge opened door ${authorization.doorId} (one-way / propose-only class) — blocked pending approval`,
607
+ unblock: authorization.unblock,
608
+ };
609
+ recordHalt(log, halt);
610
+ return { outcome: "halted", halt };
611
+ }
612
+ // --- land, then record truth — authorization gates the Git mutation.
613
+ // `unit.merged` is appended only after the forge returns the actual landed
614
+ // object id (which may be a GitHub merge commit rather than the branch tip).
615
+ setStep("land");
616
+ const land = deps.forge
617
+ ? await deps.forge.land({
618
+ branch: briefing.branch,
619
+ base: config.baseBranch,
620
+ unitId: unit.id,
621
+ tipSha: rebasedTip,
622
+ })
623
+ : await localLand(git, config.baseBranch, rebasedTip);
624
+ if (!land.ok) {
625
+ const halt = {
626
+ kind: "infrastructure-failure",
627
+ unitId: unit.id,
628
+ message: `${unit.id}: git land failed before unit.merged was recorded (${land.reason})`,
629
+ unblock: `resolve the ${config.baseBranch} race or protection failure, then re-run orchestrate`,
630
+ };
631
+ recordHalt(log, halt);
632
+ return { outcome: "halted", halt };
633
+ }
634
+ const recorded = await harness.recordMerge(unit.id, land.landedSha, briefing.mergeFacts, workspace);
635
+ if (recorded.status === "door-open") {
636
+ const halt = {
637
+ kind: "stranded-in-review",
638
+ unitId: unit.id,
639
+ message: `${unit.id}: Git landed ${land.landedSha} but unit.merged was not recorded`,
640
+ unblock: recorded.unblock,
641
+ };
642
+ recordHalt(log, halt);
643
+ return { outcome: "halted", halt };
644
+ }
645
+ log.append({ t: "unit.merged", unitId: unit.id, landedSha: land.landedSha });
646
+ if (workspace && deps.workspaces) {
647
+ try {
648
+ await deps.workspaces.release(workspace);
649
+ }
650
+ catch (error) {
651
+ log.append({
652
+ t: "lane.note",
653
+ unitId: unit.id,
654
+ note: `landed successfully; worktree cleanup retained ${workspace.path}: ${error.message}`,
655
+ });
656
+ }
657
+ }
658
+ return { outcome: "merged", unitId: unit.id, landedSha: land.landedSha };
659
+ }
660
+ }
661
+ /**
662
+ * Resume the already-implemented branch behind a signed, approved one-way door.
663
+ * The system CLI atomically reclaims only an in-review unit covered by that
664
+ * approval. We then rebase, re-run deterministic verification and an
665
+ * independent read-only review, record done again, merge, and land.
666
+ */
667
+ export async function resumeAfterDoor(unitId, deps) {
668
+ const { harness, git, config, log } = deps;
669
+ const snapshot = (await harness.units()).find((unit) => unit.id === unitId && unit.state === "in-review");
670
+ if (!snapshot) {
671
+ const halt = {
672
+ kind: "stranded-in-review",
673
+ unitId,
674
+ message: `${unitId} is not an in-review unit available for door resume`,
675
+ unblock: `inspect with harness next --in-review --json before retrying`,
676
+ };
677
+ recordHalt(log, halt);
678
+ return { outcome: "halted", halt };
679
+ }
680
+ const policy = await deps.routes.resolve(unitId, snapshot.component);
681
+ const runner = deps.runners.get(policy.runner);
682
+ if (!runner || !config.runnersEnabled.includes(policy.runner)) {
683
+ const halt = {
684
+ kind: "config-error",
685
+ unitId,
686
+ message: `door resume route for ${unitId} has no enabled ${policy.runner} runner`,
687
+ unblock: `enable ${policy.runner} or repair the route, then retry resume`,
688
+ };
689
+ recordHalt(log, halt);
690
+ return { outcome: "halted", halt };
691
+ }
692
+ const session = (deps.mintSession ?? defaultSession)();
693
+ const briefing = await harness.resumeAfterDoor(unitId, session);
694
+ if (!briefing) {
695
+ const halt = {
696
+ kind: "one-way-door",
697
+ unitId,
698
+ message: `${unitId} has no valid signed door approval and was not resumed`,
699
+ unblock: `approve the exact open door, then rerun orchestrate resume --unit ${unitId}`,
700
+ };
701
+ recordHalt(log, halt);
702
+ return { outcome: "halted", halt };
703
+ }
704
+ let workspace;
705
+ try {
706
+ workspace = await deps.workspaces?.acquire({
707
+ unitId,
708
+ branch: briefing.branch,
709
+ baseBranch: config.baseBranch,
710
+ });
711
+ }
712
+ catch (error) {
713
+ return mutationFailure(unitId, "door-resume worktree acquisition", error, log);
714
+ }
715
+ const runId = deps.runId ?? unitId;
716
+ const now = deps.now ?? Date.now;
717
+ let currentStep = "rebase";
718
+ const { onActivity, publishOnStep } = makeActivitySink(deps, unitId, runId, {
719
+ session,
720
+ runner: runner.name,
721
+ model: policy.model,
722
+ getStep: () => currentStep,
723
+ getAttempt: () => 1, // door-resume never retries — a fixed attempt, same as the existing lane.attempt fact below.
724
+ });
725
+ const setStep = (stepName) => {
726
+ currentStep = stepName;
727
+ log.append({ t: "lane.step", unitId, step: stepName });
728
+ try {
729
+ deps.activityLog?.writeOrchestratorStep({
730
+ runId,
731
+ unitId,
732
+ step: stepName,
733
+ attempt: 1,
734
+ session,
735
+ at: now(),
736
+ });
737
+ }
738
+ catch (e) {
739
+ log.append({
740
+ t: "lane.note",
741
+ unitId,
742
+ note: `activity log step write failed (${e.message})`,
743
+ });
744
+ }
745
+ publishOnStep();
746
+ };
747
+ log.append({
748
+ t: "lane.opened",
749
+ unitId,
750
+ session,
751
+ runner: runner.name,
752
+ model: policy.model,
753
+ retryBudget: 0,
754
+ emitter: runner.capabilities().sessionTelemetry ? "session" : "orchestrator",
755
+ });
756
+ log.append({ t: "lane.attempt", unitId, attempt: 1 });
757
+ const violation = outOfScope(await git.changedFiles(briefing.branch, config.baseBranch, workspace), briefing.scope);
758
+ if (violation.length)
759
+ return exhausted(unitId, 0, log, `post-hoc scope violation: ${violation.join(", ")}`);
760
+ setStep("rebase");
761
+ const rebased = await git.rebase(briefing.branch, config.baseBranch, workspace);
762
+ if (!rebased.ok || !rebased.tipSha)
763
+ return exhausted(unitId, 0, log, rebased.conflict ?? "door-resume rebase failed");
764
+ const base = await git.headSha(config.baseBranch);
765
+ setStep("done");
766
+ try {
767
+ await harness.ledgerAppend({ type: "unit.done", unitId, session });
768
+ }
769
+ catch (error) {
770
+ return mutationFailure(unitId, "door-resume unit.done ledger append", error, log);
771
+ }
772
+ setStep("verify");
773
+ const ring1 = await harness.verify(briefing, session, base, workspace);
774
+ if (ring1.status === "halt")
775
+ return verifyHalt(unitId, ring1.evidence, log);
776
+ if (ring1.status === "fail")
777
+ return exhausted(unitId, 0, log, ring1.evidence);
778
+ if (ring1.envelopeHash)
779
+ log.append({ t: "lane.envelope", unitId, hash: ring1.envelopeHash });
780
+ if (config.systemToml) {
781
+ setStep("verify-system");
782
+ const ring2 = await harness.verifySystem(unitId, config.systemToml, workspace);
783
+ if (ring2.status === "halt")
784
+ return verifyHalt(unitId, ring2.evidence, log);
785
+ if (ring2.status === "fail")
786
+ return exhausted(unitId, 0, log, ring2.evidence);
787
+ }
788
+ setStep("review");
789
+ const review = await runner.run(briefing, policy, "verifier", workspace, onActivity);
790
+ log.append({ t: "lane.cost", unitId, delta: review.cost });
791
+ if (review.telemetryError)
792
+ return mutationFailure(unitId, "door-resume verifier telemetry", review.telemetryError, log);
793
+ await emitReconstructedTelemetry(deps, { unitId, session, runner: runner.name, model: policy.model, kind: "verifier" }, runner, review);
794
+ if (!review.ok || review.filesTouched.length || review.verdict?.approved === false) {
795
+ const evidence = review.filesTouched.length
796
+ ? `verifier wrote files: ${review.filesTouched.join(", ")}`
797
+ : review.verdict?.reasons.join("; ") || "verifier rejected door resume";
798
+ return exhausted(unitId, 0, log, evidence);
799
+ }
800
+ if ((await git.headSha(config.baseBranch)) !== base) {
801
+ return exhausted(unitId, 0, log, `base ${config.baseBranch} moved during door resume; rerun resume to rebase and reverify`);
802
+ }
803
+ setStep("merge");
804
+ const authorization = await harness.authorizeMerge(unitId, briefing.mergeFacts, workspace);
805
+ if (authorization.status === "door-open") {
806
+ log.append({ t: "lane.door", unitId, doorId: authorization.doorId });
807
+ const halt = {
808
+ kind: "one-way-door",
809
+ unitId,
810
+ message: `${unitId} remains blocked by door ${authorization.doorId}`,
811
+ unblock: authorization.unblock,
812
+ };
813
+ recordHalt(log, halt);
814
+ return { outcome: "halted", halt };
815
+ }
816
+ setStep("land");
817
+ const land = deps.forge
818
+ ? await deps.forge.land({
819
+ branch: briefing.branch,
820
+ base: config.baseBranch,
821
+ unitId,
822
+ tipSha: rebased.tipSha,
823
+ })
824
+ : await localLand(git, config.baseBranch, rebased.tipSha);
825
+ if (!land.ok) {
826
+ const halt = {
827
+ kind: "infrastructure-failure",
828
+ unitId,
829
+ message: `${unitId}: git land failed before unit.merged was recorded (${land.reason})`,
830
+ unblock: `resolve the ${config.baseBranch} race or protection failure, then rerun resume`,
831
+ };
832
+ recordHalt(log, halt);
833
+ return { outcome: "halted", halt };
834
+ }
835
+ const recorded = await harness.recordMerge(unitId, land.landedSha, briefing.mergeFacts, workspace);
836
+ if (recorded.status === "door-open") {
837
+ const halt = {
838
+ kind: "stranded-in-review",
839
+ unitId,
840
+ message: `${unitId}: Git landed ${land.landedSha} but unit.merged was not recorded`,
841
+ unblock: recorded.unblock,
842
+ };
843
+ recordHalt(log, halt);
844
+ return { outcome: "halted", halt };
845
+ }
846
+ log.append({ t: "unit.merged", unitId, landedSha: land.landedSha });
847
+ if (workspace && deps.workspaces) {
848
+ try {
849
+ await deps.workspaces.release(workspace);
850
+ }
851
+ catch (error) {
852
+ log.append({
853
+ t: "lane.note",
854
+ unitId,
855
+ note: `landed successfully; worktree cleanup retained ${workspace.path}: ${error.message}`,
856
+ });
857
+ }
858
+ }
859
+ return { outcome: "merged", unitId, landedSha: land.landedSha };
860
+ }
861
+ function defaultSession() {
862
+ return `orch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
863
+ }
864
+ async function materializeImplementerWorkspace(git, workspace, briefing, unitId) {
865
+ const dirty = await git.workingTreeFiles(workspace);
866
+ if (dirty.length === 0)
867
+ return { ok: true };
868
+ const violation = outOfScope(dirty, briefing.scope);
869
+ if (violation.length) {
870
+ return { ok: false, evidence: `uncommitted out-of-scope paths: ${violation.join(", ")}` };
871
+ }
872
+ try {
873
+ await git.commitWorkspace(workspace, `Implement ${unitId}\n\nUnit: ${unitId}`);
874
+ return { ok: true };
875
+ }
876
+ catch (error) {
877
+ return {
878
+ ok: false,
879
+ evidence: `failed to commit implementer changes: ${error.message}`,
880
+ };
881
+ }
882
+ }
883
+ function outOfScope(files, scope) {
884
+ if (scope.length === 0)
885
+ return files;
886
+ const norm = (path) => path.replaceAll("\\", "/").replace(/^\.\//, "");
887
+ const matches = picomatch(scope.map(norm), { dot: true });
888
+ return files.map(norm).filter((file) => !matches(file));
889
+ }
890
+ /** the `forge: "local"` land — a plain fast-forward of `base` to the rebased tip. */
891
+ async function localLand(git, base, tipSha) {
892
+ const ff = await git.fastForward(base, tipSha);
893
+ return ff.ok
894
+ ? { ok: true, landedSha: tipSha }
895
+ : { ok: false, reason: ff.reason ?? "not a fast-forward" };
896
+ }
897
+ /**
898
+ * M2.5 — for a runner that cannot self-emit `session.*` (`sessionTelemetry:
899
+ * false`), reconstruct the events from the `RunResult` and append them via
900
+ * `harness ledger append`, marked `emitter: "orchestrator"`. A publish failure
901
+ * is logged, never fatal.
902
+ */
903
+ async function emitReconstructedTelemetry(deps, meta, runner, result) {
904
+ if (runner.capabilities().sessionTelemetry)
905
+ return;
906
+ const events = reconstructSessionEvents({ ...meta, result });
907
+ for (const e of events) {
908
+ try {
909
+ await deps.harness.ledgerAppend(e);
910
+ }
911
+ catch {
912
+ deps.log.append({
913
+ t: "needs.you",
914
+ kind: "budget",
915
+ unitId: meta.unitId,
916
+ message: `reconstructed telemetry append failed for ${meta.unitId} (${e.type}) — telemetry is degraded for this run`,
917
+ unblock: "none — the run continues; check the ledger-append path",
918
+ });
919
+ }
920
+ }
921
+ }
922
+ /** returns true → retry (continue the loop); false → budget exhausted. */
923
+ function registerRetryOrExhaust(unitId, attempt, budget, log, evidence) {
924
+ if (attempt > budget)
925
+ return false;
926
+ log.append({ t: "lane.retry", unitId, attempt: attempt + 1, evidence });
927
+ return true;
928
+ }
929
+ function exhausted(unitId, budget, log, evidence) {
930
+ const halt = {
931
+ kind: "retry-exhausted",
932
+ unitId,
933
+ message: `${unitId}: retry budget (${budget}) exhausted${evidence ? ` — ${evidence}` : ""}`,
934
+ unblock: `inspect the last transcript, then re-plan the unit (widen scope / split) and re-run`,
935
+ };
936
+ recordHalt(log, halt);
937
+ return { outcome: "halted", halt };
938
+ }
939
+ function verifyHalt(unitId, evidence, log) {
940
+ const halt = {
941
+ kind: "verify-halt",
942
+ unitId,
943
+ message: `${unitId}: harness verify returned halt — ${evidence}`,
944
+ unblock: `resolve the ring-2 condition (dag_cycle | stale_artifact | scope_cross), then re-run`,
945
+ };
946
+ recordHalt(log, halt);
947
+ return { outcome: "halted", halt };
948
+ }
949
+ // ---------------------------------------------------------------------------
950
+ // startupSweep() — the two crash windows staleness does not cover (M1).
951
+ // ---------------------------------------------------------------------------
952
+ export async function startupSweep(deps) {
953
+ const { harness, git, config, log } = deps;
954
+ const halts = [];
955
+ const units = await harness.units();
956
+ // 1. stranded in-review — a lane died between `unit.done` and `harness merge`.
957
+ // No CLI moves it except merge; until SH P7.6 the sweep halts, never reclaims.
958
+ for (const u of units.filter((x) => x.state === "in-review")) {
959
+ const halt = {
960
+ kind: "stranded-in-review",
961
+ unitId: u.id,
962
+ message: `${u.id} is stranded in-review (a prior lane died after unit.done, before merge)`,
963
+ unblock: `manually verify + merge ${u.id}, or wait for the SH P7.6 in-review recovery path`,
964
+ };
965
+ recordHalt(log, halt);
966
+ halts.push(halt);
967
+ }
968
+ // 2. ledger ahead of git — a merge exited 0 without the fast-forward. For
969
+ // every ledger-merged unit, its `landed` sha MUST be an ancestor of
970
+ // baseBranch (local or, after a tracking refresh, the remote), else
971
+ // dependents branch from a base missing that code.
972
+ const merged = units.filter((x) => x.state === "merged" && x.landedSha);
973
+ let trackingRef;
974
+ if (merged.length > 0) {
975
+ try {
976
+ const fetched = await git.fetchRemoteTracking(config.baseBranch);
977
+ if (fetched.ok && fetched.trackingRef)
978
+ trackingRef = fetched.trackingRef;
979
+ }
980
+ catch {
981
+ // no remote / offline / fetch error — fall back to the local ref; never
982
+ // a run failure or infrastructure-failure halt.
983
+ }
984
+ }
985
+ for (const u of merged) {
986
+ const sha = u.landedSha;
987
+ const onLocal = await git.isAncestor(sha, config.baseBranch);
988
+ const onRemote = trackingRef ? await git.isAncestor(sha, trackingRef) : false;
989
+ if (onLocal || onRemote)
990
+ continue;
991
+ const halt = {
992
+ kind: "ledger-ahead-of-git",
993
+ unitId: u.id,
994
+ message: `ledger ahead of git for ${u.id}: landed sha ${sha} is not an ancestor of ${config.baseBranch}`,
995
+ unblock: `fast-forward ${config.baseBranch} to ${sha} (or investigate the push race), then re-run`,
996
+ };
997
+ recordHalt(log, halt);
998
+ halts.push(halt);
999
+ }
1000
+ return halts;
1001
+ }
1002
+ export async function runLoop(deps, opts) {
1003
+ const { log } = deps;
1004
+ const now = deps.now ?? Date.now;
1005
+ const runId = deps.runId ?? `run-${now().toString(36)}`;
1006
+ const machineId = deps.machineId ?? "local";
1007
+ const publisher = deps.publisher ?? NULL_PUBLISHER;
1008
+ log.setClock(now);
1009
+ // D1 — best-effort run-status publish. Structurally cannot stop the loop:
1010
+ // any rejection is swallowed here (the CliRunPublisher also logs a warning).
1011
+ const feed = () => publisher.publish(log.snapshot()).catch((e) => {
1012
+ log.append({
1013
+ t: "lane.note",
1014
+ unitId: runId,
1015
+ note: `run-status publish threw (${e?.message ?? e}) — feed degraded`,
1016
+ });
1017
+ });
1018
+ const finish = async (outcome) => {
1019
+ log.append({ t: "run.ended", outcome, at: now() });
1020
+ try {
1021
+ deps.activityLog?.writeRunEnded({ runId, outcome, at: now() });
1022
+ }
1023
+ catch (e) {
1024
+ log.append({
1025
+ t: "lane.note",
1026
+ unitId: runId,
1027
+ note: `activity log run.ended write failed (${e.message})`,
1028
+ });
1029
+ }
1030
+ await feed(); // terminal publish carries `outcome` — retires the run from the overlay
1031
+ return log.snapshot();
1032
+ };
1033
+ log.append({
1034
+ t: "run.started",
1035
+ runId,
1036
+ machineId,
1037
+ systemId: opts.systemId,
1038
+ specRef: opts.specRef,
1039
+ dagSize: opts.dagSize,
1040
+ at: now(),
1041
+ });
1042
+ try {
1043
+ deps.activityLog?.writeRunStarted({
1044
+ runId,
1045
+ dagSize: opts.dagSize,
1046
+ maxParallel: deps.config.maxParallel,
1047
+ at: now(),
1048
+ });
1049
+ }
1050
+ catch (e) {
1051
+ log.append({
1052
+ t: "lane.note",
1053
+ unitId: runId,
1054
+ note: `activity log run.started write failed (${e.message})`,
1055
+ });
1056
+ }
1057
+ await feed();
1058
+ // startup sweep BEFORE any claim.
1059
+ const sweepHalts = await startupSweep(deps);
1060
+ if (sweepHalts.length > 0)
1061
+ return finish("halted");
1062
+ const startedAt = now();
1063
+ for (;;) {
1064
+ const p = await plan(deps);
1065
+ if (p.halts.length > 0)
1066
+ return finish("halted");
1067
+ if (p.lanes.length === 0) {
1068
+ // nothing ready and nothing in flight — the run is done.
1069
+ return finish("completed");
1070
+ }
1071
+ // run the batch in parallel (M5). `harness claim` CAS makes concurrent
1072
+ // claims safe; the plan() selection kept the lanes probably-disjoint. On a
1073
+ // budget trip we stop claiming NEW units but let this batch's in-flight
1074
+ // lanes finish their current step — so the check is after Promise.all.
1075
+ const results = await Promise.all(p.lanes.map((lane) => step(lane.unit, lane.policy, deps)));
1076
+ let halted = results.some((r) => r.outcome === "halted");
1077
+ const progressed = results.some((r) => r.outcome === "merged" || r.outcome === "propose-only");
1078
+ const runCost = log.snapshot().lanes.reduce((s, l) => s + l.cost, 0);
1079
+ if (deps.config.maxRunCost !== undefined && runCost >= deps.config.maxRunCost) {
1080
+ recordHalt(log, {
1081
+ kind: "budget",
1082
+ message: `maxRunCost (${deps.config.maxRunCost}) tripped at ${runCost}`,
1083
+ unblock: "raise maxRunCost in orchestration.json or accept the partial run, then re-run",
1084
+ });
1085
+ halted = true;
1086
+ }
1087
+ if (deps.config.maxRunDuration !== undefined &&
1088
+ now() - startedAt >= deps.config.maxRunDuration) {
1089
+ recordHalt(log, {
1090
+ kind: "budget",
1091
+ message: `maxRunDuration (${deps.config.maxRunDuration}ms) tripped`,
1092
+ unblock: "raise maxRunDuration in orchestration.json or accept the partial run, then re-run",
1093
+ });
1094
+ halted = true;
1095
+ }
1096
+ if (halted)
1097
+ return finish("halted");
1098
+ if (!progressed) {
1099
+ // every planned lane yielded without merging — no forward progress is
1100
+ // possible (propose-only holds, or claims are all racing). Stop cleanly.
1101
+ return finish("completed");
1102
+ }
1103
+ log.append({ t: "heartbeat", at: now() });
1104
+ await feed(); // publish on every loop transition (dashboard-plan D1)
1105
+ }
1106
+ }
1107
+ //# sourceMappingURL=index.js.map