@mobrienv/autoloop-harness 0.8.0 → 0.9.2

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 (116) hide show
  1. package/dist/acceptance.d.ts +31 -0
  2. package/dist/acceptance.js +69 -0
  3. package/dist/acceptance.js.map +1 -0
  4. package/dist/ask.d.ts +16 -0
  5. package/dist/ask.js +47 -0
  6. package/dist/ask.js.map +1 -0
  7. package/dist/backend/types.d.ts +3 -0
  8. package/dist/backend/types.js +3 -0
  9. package/dist/backend/types.js.map +1 -1
  10. package/dist/circuit-breaker.d.ts +27 -0
  11. package/dist/circuit-breaker.js +41 -0
  12. package/dist/circuit-breaker.js.map +1 -0
  13. package/dist/completion-lint.d.ts +29 -0
  14. package/dist/completion-lint.js +51 -0
  15. package/dist/completion-lint.js.map +1 -0
  16. package/dist/config-helpers.js +141 -8
  17. package/dist/config-helpers.js.map +1 -1
  18. package/dist/control/command-adapter.d.ts +19 -0
  19. package/dist/control/command-adapter.js +57 -0
  20. package/dist/control/command-adapter.js.map +1 -0
  21. package/dist/control/dispatch.js +14 -0
  22. package/dist/control/dispatch.js.map +1 -1
  23. package/dist/control/types.d.ts +11 -2
  24. package/dist/display.d.ts +9 -2
  25. package/dist/display.js +37 -1
  26. package/dist/display.js.map +1 -1
  27. package/dist/emit.d.ts +27 -0
  28. package/dist/emit.js +321 -2
  29. package/dist/emit.js.map +1 -1
  30. package/dist/events.d.ts +36 -3
  31. package/dist/fanout-runner.d.ts +25 -0
  32. package/dist/fanout-runner.js +54 -0
  33. package/dist/fanout-runner.js.map +1 -0
  34. package/dist/file-mod-audit.d.ts +29 -0
  35. package/dist/file-mod-audit.js +63 -0
  36. package/dist/file-mod-audit.js.map +1 -0
  37. package/dist/git-diff.d.ts +19 -0
  38. package/dist/git-diff.js +74 -0
  39. package/dist/git-diff.js.map +1 -0
  40. package/dist/hooks.d.ts +78 -0
  41. package/dist/hooks.js +239 -0
  42. package/dist/hooks.js.map +1 -0
  43. package/dist/index.d.ts +15 -2
  44. package/dist/index.js +181 -5
  45. package/dist/index.js.map +1 -1
  46. package/dist/intent.d.ts +45 -0
  47. package/dist/intent.js +127 -0
  48. package/dist/intent.js.map +1 -0
  49. package/dist/iteration-diff.d.ts +32 -0
  50. package/dist/iteration-diff.js +92 -0
  51. package/dist/iteration-diff.js.map +1 -0
  52. package/dist/iteration.d.ts +20 -0
  53. package/dist/iteration.js +383 -16
  54. package/dist/iteration.js.map +1 -1
  55. package/dist/metareview.d.ts +9 -1
  56. package/dist/metareview.js +68 -30
  57. package/dist/metareview.js.map +1 -1
  58. package/dist/notify.d.ts +9 -2
  59. package/dist/notify.js +16 -4
  60. package/dist/notify.js.map +1 -1
  61. package/dist/parallel.d.ts +16 -0
  62. package/dist/parallel.js +28 -1
  63. package/dist/parallel.js.map +1 -1
  64. package/dist/postconditions.d.ts +25 -0
  65. package/dist/postconditions.js +107 -0
  66. package/dist/postconditions.js.map +1 -0
  67. package/dist/postfire-verify.d.ts +31 -0
  68. package/dist/postfire-verify.js +64 -0
  69. package/dist/postfire-verify.js.map +1 -0
  70. package/dist/premature-quit.d.ts +25 -0
  71. package/dist/premature-quit.js +54 -0
  72. package/dist/premature-quit.js.map +1 -0
  73. package/dist/progress.d.ts +20 -0
  74. package/dist/progress.js +56 -0
  75. package/dist/progress.js.map +1 -0
  76. package/dist/prompt.js +8 -2
  77. package/dist/prompt.js.map +1 -1
  78. package/dist/provisional.d.ts +42 -0
  79. package/dist/provisional.js +92 -0
  80. package/dist/provisional.js.map +1 -0
  81. package/dist/registry-bridge.js +25 -0
  82. package/dist/registry-bridge.js.map +1 -1
  83. package/dist/resume.d.ts +61 -0
  84. package/dist/resume.js +226 -0
  85. package/dist/resume.js.map +1 -0
  86. package/dist/scratchpad.js +27 -8
  87. package/dist/scratchpad.js.map +1 -1
  88. package/dist/stage.d.ts +21 -0
  89. package/dist/stage.js +131 -0
  90. package/dist/stage.js.map +1 -0
  91. package/dist/stop.d.ts +29 -2
  92. package/dist/stop.js +106 -0
  93. package/dist/stop.js.map +1 -1
  94. package/dist/suspend-state.d.ts +32 -0
  95. package/dist/suspend-state.js +104 -0
  96. package/dist/suspend-state.js.map +1 -0
  97. package/dist/tamper.d.ts +21 -0
  98. package/dist/tamper.js +88 -0
  99. package/dist/tamper.js.map +1 -0
  100. package/dist/types.d.ts +247 -2
  101. package/dist/types.js +91 -1
  102. package/dist/types.js.map +1 -1
  103. package/dist/wave/finalize-wave.d.ts +15 -5
  104. package/dist/wave/finalize-wave.js +56 -2
  105. package/dist/wave/finalize-wave.js.map +1 -1
  106. package/dist/wave/launch-branches.d.ts +23 -2
  107. package/dist/wave/launch-branches.js +56 -3
  108. package/dist/wave/launch-branches.js.map +1 -1
  109. package/dist/wave/stage-branch-runner.d.ts +12 -0
  110. package/dist/wave/stage-branch-runner.js +130 -0
  111. package/dist/wave/stage-branch-runner.js.map +1 -0
  112. package/dist/wave/types.d.ts +40 -2
  113. package/dist/wave.d.ts +32 -1
  114. package/dist/wave.js +163 -34
  115. package/dist/wave.js.map +1 -1
  116. package/package.json +83 -3
@@ -0,0 +1,92 @@
1
+ import { extractField, extractIteration, extractTopic, } from "@mobrienv/autoloop-core/journal";
2
+ function linesOf(runLines, iter) {
3
+ const want = String(iter);
4
+ return runLines.filter((l) => extractIteration(l) === want);
5
+ }
6
+ function fieldFor(lines, topic, field) {
7
+ // Last matching line wins (the final value journaled for that iteration).
8
+ for (let i = lines.length - 1; i >= 0; i--) {
9
+ if (extractTopic(lines[i]) === topic)
10
+ return extractField(lines[i], field);
11
+ }
12
+ return "";
13
+ }
14
+ function topicsOf(lines) {
15
+ return new Set(lines.map((l) => extractTopic(l)).filter(Boolean));
16
+ }
17
+ function costOf(lines) {
18
+ let sum = 0;
19
+ for (const l of lines) {
20
+ if (extractTopic(l) !== "backend.usage")
21
+ continue;
22
+ const v = Number(extractField(l, "cost_usd"));
23
+ if (Number.isFinite(v))
24
+ sum += v;
25
+ }
26
+ return Math.round(sum * 1e6) / 1e6;
27
+ }
28
+ /** Multiset line diff: counts of lines present in b-not-a (added) / a-not-b (removed). */
29
+ function textDelta(a, b) {
30
+ const aLines = a ? a.split("\n") : [];
31
+ const bLines = b ? b.split("\n") : [];
32
+ const count = (xs) => {
33
+ const m = new Map();
34
+ for (const x of xs)
35
+ m.set(x, (m.get(x) ?? 0) + 1);
36
+ return m;
37
+ };
38
+ const am = count(aLines);
39
+ const bm = count(bLines);
40
+ let added = 0;
41
+ let removed = 0;
42
+ for (const [line, n] of bm)
43
+ added += Math.max(0, n - (am.get(line) ?? 0));
44
+ for (const [line, n] of am)
45
+ removed += Math.max(0, n - (bm.get(line) ?? 0));
46
+ return {
47
+ aChars: a.length,
48
+ bChars: b.length,
49
+ charDelta: b.length - a.length,
50
+ linesAdded: added,
51
+ linesRemoved: removed,
52
+ changed: a !== b,
53
+ };
54
+ }
55
+ /**
56
+ * Structural diff between two journaled iterations of a run: prompt (assembled
57
+ * context), backend output, cost, and the emitted-event topic set. Pure over
58
+ * the run's journal lines — the durable journal is the trustworthy source.
59
+ */
60
+ export function diffIterations(runLines, iterA, iterB) {
61
+ const a = linesOf(runLines, iterA);
62
+ const b = linesOf(runLines, iterB);
63
+ const aTopics = topicsOf(a);
64
+ const bTopics = topicsOf(b);
65
+ const aUsd = costOf(a);
66
+ const bUsd = costOf(b);
67
+ return {
68
+ iterA,
69
+ iterB,
70
+ prompt: textDelta(fieldFor(a, "iteration.start", "prompt"), fieldFor(b, "iteration.start", "prompt")),
71
+ output: textDelta(fieldFor(a, "iteration.finish", "output"), fieldFor(b, "iteration.finish", "output")),
72
+ cost: { aUsd, bUsd, deltaUsd: Math.round((bUsd - aUsd) * 1e6) / 1e6 },
73
+ events: {
74
+ onlyInA: [...aTopics].filter((t) => !bTopics.has(t)).sort(),
75
+ onlyInB: [...bTopics].filter((t) => !aTopics.has(t)).sort(),
76
+ },
77
+ };
78
+ }
79
+ /** Render a structural iteration diff as a human-readable report. */
80
+ export function renderIterationDiff(runId, d) {
81
+ const t = (label, x) => `${label}: ${x.changed ? "changed" : "identical"} (${x.aChars}→${x.bChars} chars, Δ${x.charDelta >= 0 ? "+" : ""}${x.charDelta}; +${x.linesAdded}/-${x.linesRemoved} lines)`;
82
+ return [
83
+ `## Diff ${runId}: iter ${d.iterA} → ${d.iterB}`,
84
+ "",
85
+ t("prompt/context", d.prompt),
86
+ t("output", d.output),
87
+ `cost: $${d.cost.aUsd} → $${d.cost.bUsd} (Δ${d.cost.deltaUsd >= 0 ? "+" : ""}${d.cost.deltaUsd})`,
88
+ `events only in ${d.iterA}: ${d.events.onlyInA.join(", ") || "(none)"}`,
89
+ `events only in ${d.iterB}: ${d.events.onlyInB.join(", ") || "(none)"}`,
90
+ ].join("\n");
91
+ }
92
+ //# sourceMappingURL=iteration-diff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iteration-diff.js","sourceRoot":"","sources":["../src/iteration-diff.ts"],"names":[],"mappings":"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"}
@@ -10,7 +10,27 @@ export declare function resolveOutcome(ctx: {
10
10
  completionEvent: string;
11
11
  requiredEvents: string[];
12
12
  completionPromise: string;
13
+ hasBlockingTasks: boolean;
14
+ /**
15
+ * Ralph-parity ordering policy. When true, a completion claim is rejected
16
+ * (falls through to the routed/promise/continue branches) unless
17
+ * `completionOrderOk` confirms no other event followed completionEvent in
18
+ * its own turn. Optional/false = order-insensitive set-membership check
19
+ * (unchanged default behavior).
20
+ */
21
+ mustBeLast?: boolean;
22
+ /** Precomputed by the caller from full journal ordering (see completionEmittedLast). */
23
+ completionOrderOk?: boolean;
13
24
  }): {
14
25
  action: string;
15
26
  outcome: string;
16
27
  };
28
+ /**
29
+ * True iff `completionEvent`, wherever it was last emitted in the run, was
30
+ * the last non-system topic emitted in that same turn (iteration). Used to
31
+ * enforce `completion.must_be_last`: ralph rejects completion if other
32
+ * events follow it within the same turn, rather than autoloop's default
33
+ * order-insensitive set-membership check. Returns true (not applicable) if
34
+ * the completion event has not been emitted at all.
35
+ */
36
+ export declare function completionEmittedLast(runLines: string[], completionEvent: string): boolean;
package/dist/iteration.js CHANGED
@@ -1,19 +1,47 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
1
2
  import { join } from "node:path";
2
- import { runAcpIteration, runClaudeSdkIteration, runPiIteration, } from "@mobrienv/autoloop-backends";
3
+ import { classifyBackendError, runAcpIteration, runClaudeSdkIteration, runPiIteration, } from "@mobrienv/autoloop-backends";
3
4
  import { initAcpSession, terminateAcpSession, } from "@mobrienv/autoloop-backends/acp-client";
5
+ import { providerLaunchArgs } from "@mobrienv/autoloop-backends/acp-providers";
4
6
  import { getClaudeSdkUsage, initClaudeSdkSession, terminateClaudeSdkSession, } from "@mobrienv/autoloop-backends/claude-sdk-client";
5
7
  import { getPiSessionStats, initPiSession, resetPiSession, terminatePiSession, } from "@mobrienv/autoloop-backends/pi-rpc-client";
6
- import { jsonFieldRaw, listText } from "@mobrienv/autoloop-core";
7
- import { appendEvent, extractField, extractIteration, extractTopic, readRunLines, } from "@mobrienv/autoloop-core/journal";
8
+ import { jsonField, jsonFieldRaw, listText } from "@mobrienv/autoloop-core";
9
+ import { appendEvent, appendOperatorEvent, extractField, extractIteration, extractTopic, readRunLines, } from "@mobrienv/autoloop-core/journal";
10
+ import { materializeOpenFrom } from "@mobrienv/autoloop-core/tasks";
11
+ import * as topology from "@mobrienv/autoloop-core/topology";
12
+ import { reinjectAcceptanceFailure, runAcceptanceGate } from "./acceptance.js";
13
+ import { awaitHumanResponse } from "./ask.js";
14
+ import { backoffDelayMs, circuitDecision, countTransientPauses, } from "./circuit-breaker.js";
8
15
  import { log } from "./display.js";
9
16
  import { appendInvalidEvent, invalidEvent, parallelTriggerTopic, systemTopic, } from "./emit.js";
17
+ import { runFileModAudit } from "./file-mod-audit.js";
10
18
  import { loopStartMs } from "./guards.js";
11
- import { appendBackendFinish, appendBackendStart, appendIterationFinish, appendIterationStart, buildBackendCommand, runProcess, } from "./parallel.js";
19
+ import { buildHookEnv, captureGitSha, runPhaseHooks } from "./hooks.js";
20
+ import { reinjectIntentFailure, runIntentCriteria } from "./intent.js";
21
+ import { appendBackendFinish, appendBackendStart, appendIterationFinish, appendIterationStart, buildBackendCommand, commandUsageFilePath, runProcessAsync, } from "./parallel.js";
22
+ import { reinjectPostconditionFailure, runPostconditionGuards, } from "./postconditions.js";
23
+ import { runProgressMetric } from "./progress.js";
12
24
  import { buildIterationContext } from "./prompt.js";
25
+ import { consumeHumanAck, enterProvisional, holdProvisional, releaseProvisional, resolveProvisional, } from "./provisional.js";
13
26
  import { registryProgress } from "./registry-bridge.js";
14
- import { completeLoop, stopBackendFailed, stopBackendTimeout, stopMaxRuntime, } from "./stop.js";
15
- import { continueAfterParallelJoin, executeParallelWave, stopAfterParallelWave, } from "./wave.js";
27
+ import { finishStageIteration } from "./stage.js";
28
+ import { completeLoop, stopBackendErrorClass, stopBackendFailed, stopBackendTimeout, stopMaxRuntime, stopSuspended, } from "./stop.js";
29
+ import { readSuspendState, resumeRequested } from "./suspend-state.js";
30
+ import { reinjectTamperFailure, runTamperScreen } from "./tamper.js";
31
+ import { continueAfterParallelJoin, executeDeclarativeWave, executeParallelWave, stopAfterParallelWave, } from "./wave.js";
16
32
  export async function runIteration(loop, iteration, iterate) {
33
+ // Out-of-process pre_emit/post_emit `suspend` hooks (run in the `emit`
34
+ // subprocess — see emit.ts:runEmitPhaseHooks) can't block the harness loop
35
+ // directly; they write durable suspend state instead. Detect it here, at
36
+ // the next iteration boundary, and halt the loop rather than silently
37
+ // continuing past a pending suspend.
38
+ const pendingEmitSuspend = readSuspendState(loop.paths.stateDir);
39
+ if (pendingEmitSuspend &&
40
+ (pendingEmitSuspend.phase === "pre_emit" ||
41
+ pendingEmitSuspend.phase === "post_emit") &&
42
+ !resumeRequested(loop.paths.stateDir)) {
43
+ return stopSuspended(loop, iteration, pendingEmitSuspend.reason);
44
+ }
17
45
  let iter = buildIterationContext(loop, iteration);
18
46
  // Clamp the iteration timeout to the remaining loop wall-clock budget so a
19
47
  // long iteration never overshoots event_loop.max_runtime. Applied before
@@ -48,6 +76,20 @@ export async function runIteration(loop, iteration, iterate) {
48
76
  appendBackendStart(loop, iter);
49
77
  log(loop, "debug", `backend start command=${iter.backend.command}`);
50
78
  const startEpoch = Math.floor(Date.now() / 1000);
79
+ const gitShaBefore = captureGitSha(loop.paths.workDir);
80
+ const preIterResult = await runPhaseHooks(loop, "pre_iteration", buildHookEnv(loop, { iteration, gitShaBefore }), { iteration, gitShaBefore });
81
+ if (preIterResult.blocked) {
82
+ throw new Error(`Aborting run: ${preIterResult.blockedMessage ?? "pre_iteration hook blocked"}`);
83
+ }
84
+ if (preIterResult.suspended) {
85
+ return stopSuspended(loop, iteration, preIterResult.blockedMessage ?? "pre_iteration hook suspended the run");
86
+ }
87
+ // Mutation seam: applied after buildIterationContext but before the backend
88
+ // command is built, so a mutated prompt survives the loop-budget timeout
89
+ // clamp's `iter = {...iter, backend:{...}}` reassignment above.
90
+ if (preIterResult.mutatedPrompt !== undefined) {
91
+ iter = { ...iter, prompt: preIterResult.mutatedPrompt };
92
+ }
51
93
  // Fresh ACP session per iteration — ensures each role (researcher, critic,
52
94
  // etc.) starts with a clean context window for truly independent review.
53
95
  if (iter.backend.kind === "acp") {
@@ -63,7 +105,7 @@ export async function runIteration(loop, iteration, iterate) {
63
105
  const acpOpts = {
64
106
  provider: iter.backend.provider,
65
107
  command: iter.backend.command,
66
- args: iter.backend.args,
108
+ args: providerLaunchArgs(iter.backend.provider, iter.backend.profile, iter.backend.args),
67
109
  cwd: loop.paths.workDir,
68
110
  trustAllTools: iter.backend.trustAllTools,
69
111
  agentName: iter.backend.agent || undefined,
@@ -98,6 +140,7 @@ export async function runIteration(loop, iteration, iterate) {
98
140
  model: iter.backend.model || undefined,
99
141
  cwd: loop.paths.workDir,
100
142
  trustAllTools: iter.backend.trustAllTools,
143
+ disallowedTools: iter.backend.disallowedTools,
101
144
  verbose: loop.runtime.logLevel === "debug",
102
145
  });
103
146
  log(loop, "debug", `new claude-sdk session for iteration ${iteration} model="${iter.backend.model || "default"}"`);
@@ -106,7 +149,21 @@ export async function runIteration(loop, iteration, iterate) {
106
149
  const elapsedS = Math.floor(Date.now() / 1000) - startEpoch;
107
150
  appendBackendFinish(loop, iter, output, exitCode, timedOut);
108
151
  appendIterationFinish(loop, iter, output, exitCode, timedOut, elapsedS);
152
+ const gitShaAfter = captureGitSha(loop.paths.workDir);
153
+ const postIterResult = await runPhaseHooks(loop, "post_iteration", buildHookEnv(loop, { iteration, gitShaBefore, gitShaAfter }), { iteration, gitShaBefore, gitShaAfter });
154
+ if (postIterResult.blocked) {
155
+ throw new Error(`Aborting run: ${postIterResult.blockedMessage ?? "post_iteration hook blocked"}`);
156
+ }
157
+ if (postIterResult.suspended) {
158
+ return stopSuspended(loop, iteration, postIterResult.blockedMessage ?? "post_iteration hook suspended the run");
159
+ }
109
160
  registryProgress(loop, iteration);
161
+ // Capture the preset-declared progress scalar each iteration (drift signal).
162
+ runProgressMetric(loop, iteration);
163
+ // Emit-boundary file-mod audit (opt-in): flag file writes by a role with
164
+ // disallowed_tools/read_only. Purely observational — journals/emits a typed
165
+ // event for parent orchestrators; never alters control flow itself.
166
+ runFileModAudit(loop, iter, iteration);
110
167
  log(loop, "debug", `iteration ${iteration} finish exit_code=${exitCode}`);
111
168
  loop.onEvent?.({
112
169
  type: "iteration.footer",
@@ -121,10 +178,44 @@ export async function runIteration(loop, iteration, iterate) {
121
178
  }
122
179
  if (timedOut)
123
180
  return stopBackendTimeout(loop, iteration, output);
124
- if (exitCode !== 0)
125
- return stopBackendFailed(loop, iteration, output);
181
+ if (exitCode !== 0) {
182
+ return handleBackendFailure(loop, iteration, output, iterate);
183
+ }
126
184
  return finishIteration(loop, iter, output, iterate);
127
185
  }
186
+ /**
187
+ * Classify a non-zero backend exit. A typed transient/rate-limit/auth/quota
188
+ * error is handled by the circuit breaker — retryable classes pause-and-retry
189
+ * (rather than fast-failing into a laundered verdict or generic death) until
190
+ * the breaker opens; non-retryable classes stop with a typed reason. Anything
191
+ * unclassified is a plain backend failure.
192
+ */
193
+ async function handleBackendFailure(loop, iteration, output, iterate) {
194
+ const errorClass = classifyBackendError(output);
195
+ if (errorClass === "none")
196
+ return stopBackendFailed(loop, iteration, output);
197
+ const runLines = readRunLines(loop.paths.journalFile, loop.runtime.runId);
198
+ const pauses = countTransientPauses(runLines);
199
+ const decision = circuitDecision(errorClass, pauses, loop.limits.transientMaxPauses ?? 3);
200
+ if (decision.action === "stop") {
201
+ return stopBackendErrorClass(loop, iteration, decision.reason, output);
202
+ }
203
+ // Pause-and-retry with exponential backoff: a transient blip becomes a retry
204
+ // instead of run-death. The Nth retry waits base*2^(N-1), capped.
205
+ const attempt = pauses + 1;
206
+ const pauseMs = backoffDelayMs(attempt, loop.limits.transientPauseMs ?? 5000, loop.limits.transientBackoffCapMs ?? 30000);
207
+ appendEvent(loop.paths.journalFile, loop.runtime.runId, String(iteration), "backend.transient", jsonField("error_class", errorClass) +
208
+ ", " +
209
+ jsonField("pause_count", String(attempt)) +
210
+ ", " +
211
+ jsonFieldRaw("backoff_ms", String(pauseMs)) +
212
+ ", " +
213
+ jsonField("output_tail", output.slice(-500)));
214
+ log(loop, "warn", `transient backend error (${errorClass}); retry ${attempt}/${loop.limits.transientMaxPauses ?? 3} after ${pauseMs}ms backoff`);
215
+ if (pauseMs > 0)
216
+ await new Promise((r) => setTimeout(r, pauseMs));
217
+ return iterate(loop, iteration + 1);
218
+ }
128
219
  async function runBackendIteration(loop, iter) {
129
220
  if (iter.backend.kind === "acp" && loop.acpSession.current) {
130
221
  return runAcpIteration(loop.acpSession.current, iter.prompt, iter.backend.timeoutMs);
@@ -139,7 +230,85 @@ async function runBackendIteration(loop, iter) {
139
230
  recordClaudeSdkUsage(loop, iter);
140
231
  return result;
141
232
  }
142
- return runProcess(buildBackendCommand(loop, iter), iter.backend.timeoutMs, iter.backend.kind);
233
+ // Plain `command` backend (and any other non-session kind falling through
234
+ // here): async-spawned so the harness can register a live PID for
235
+ // interrupt signaling (commandControlAdapter) without blocking the event
236
+ // loop the way execSync does. Session is cleared as soon as the process
237
+ // exits, before usage telemetry is recorded.
238
+ const usesUsageFile = iter.backend.kind === "command" && Boolean(iter.backend.usageFrom);
239
+ if (usesUsageFile) {
240
+ // Clear any stale usage file left by a prior iteration before invoking,
241
+ // so a command that forgets to (re)write it is treated as zero cost
242
+ // rather than reporting stale numbers.
243
+ removeUsageFileIfExists(commandUsageFilePath(loop, iter.iteration));
244
+ }
245
+ const result = await runProcessAsync(buildBackendCommand(loop, iter), iter.backend.timeoutMs, iter.backend.kind, (pid) => {
246
+ loop.commandSession.current = { pid };
247
+ });
248
+ loop.commandSession.current = undefined;
249
+ if (iter.backend.kind === "command") {
250
+ recordCommandUsage(loop, iter);
251
+ }
252
+ return result;
253
+ }
254
+ function removeUsageFileIfExists(path) {
255
+ try {
256
+ if (existsSync(path))
257
+ unlinkSync(path);
258
+ }
259
+ catch {
260
+ /* best-effort */
261
+ }
262
+ }
263
+ /**
264
+ * Journal per-iteration token/cost totals for the `command` backend, read
265
+ * from the opt-in `usage_from = "file"` side-file convention: the wrapped
266
+ * command may write a JSON object to `$AUTOLOOP_USAGE_FILE` before exiting.
267
+ * Best-effort and graceful: missing, empty, or unparsable files (or
268
+ * `usage_from` left unset) mean zero cost and no event — never an error.
269
+ */
270
+ function recordCommandUsage(loop, iter) {
271
+ if (iter.backend.usageFrom !== "file")
272
+ return;
273
+ const path = commandUsageFilePath(loop, iter.iteration);
274
+ if (!existsSync(path))
275
+ return;
276
+ let parsed;
277
+ try {
278
+ const raw = readFileSync(path, "utf-8").trim();
279
+ if (!raw)
280
+ return;
281
+ parsed = JSON.parse(raw);
282
+ }
283
+ catch {
284
+ return;
285
+ }
286
+ finally {
287
+ removeUsageFileIfExists(path);
288
+ }
289
+ const num = (key) => {
290
+ const v = parsed[key];
291
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
292
+ };
293
+ const inputTokens = num("input_tokens");
294
+ const outputTokens = num("output_tokens");
295
+ const cacheReadTokens = num("cache_read_tokens");
296
+ const cacheWriteTokens = num("cache_write_tokens");
297
+ const totalTokens = parsed.total_tokens !== undefined
298
+ ? num("total_tokens")
299
+ : inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
300
+ const costUsd = num("cost_usd");
301
+ appendEvent(loop.paths.journalFile, loop.runtime.runId, String(iter.iteration), "backend.usage", jsonFieldRaw("input_tokens", String(inputTokens)) +
302
+ ", " +
303
+ jsonFieldRaw("output_tokens", String(outputTokens)) +
304
+ ", " +
305
+ jsonFieldRaw("cache_read_tokens", String(cacheReadTokens)) +
306
+ ", " +
307
+ jsonFieldRaw("cache_write_tokens", String(cacheWriteTokens)) +
308
+ ", " +
309
+ jsonFieldRaw("total_tokens", String(totalTokens)) +
310
+ ", " +
311
+ jsonFieldRaw("cost_usd", String(costUsd)));
143
312
  }
144
313
  /**
145
314
  * Journal per-iteration token/cost totals from pi's get_session_stats.
@@ -238,12 +407,52 @@ export async function finishIteration(loop, iter, output, iterate) {
238
407
  emittedTopic,
239
408
  outcome,
240
409
  });
410
+ // Human-in-the-loop: a `human.ask` event pauses the loop until an operator
411
+ // responds (or the timeout elapses); the answer is injected into the next
412
+ // iteration as guidance. Handled before routing so the ask topic itself is
413
+ // never treated as a routing/invalid event.
414
+ if (loop.ask?.enabled && emitted.topic === loop.ask.event) {
415
+ return finishAskIteration(loop, iter, emitted.payload, iterate, progress);
416
+ }
241
417
  if (invalidEvent(emitted.topic, iter.allowedEvents, loop.parallel.enabled, loop.completion.event)) {
242
418
  return rejectInvalidAndContinue(loop, iter, emitted.topic, iterate, progress);
243
419
  }
244
420
  if (parallelTriggerTopic(emitted.topic)) {
245
421
  return finishParallelIteration(loop, iter, emitted.topic, emitted.payload, iterate, progress);
246
422
  }
423
+ // Fan-out `[[stage]]` dispatch: a stage's `trigger` is a plain declared
424
+ // event (already passed the invalid-event check above like any other
425
+ // event), but the harness intercepts it here instead of ordinary role
426
+ // dispatch — launch the stage's branches, reduce, and route at the
427
+ // reducer's onPass/onFail event, exactly as a `.parallel` wave intercepts
428
+ // its dispatch topic above.
429
+ const stage = topology.stageForTrigger(loop.topology, emitted.topic);
430
+ if (stage) {
431
+ progress(emitted.topic, "stage:dispatched");
432
+ return finishStageIteration(loop, iter, stage, emitted.topic, iterate);
433
+ }
434
+ // Declarative concurrency (ralph v3 style): if the just-emitted event
435
+ // routes (via [handoff]) to a role that declares `concurrency > 0`, the
436
+ // harness auto-launches N concurrent branches for that role — no agent
437
+ // `.parallel` emit required. This takes precedence over ordinary routing
438
+ // for that event; agent-triggered `.parallel` (handled above) is
439
+ // unaffected and remains fully backward compatible. Synthetic wave/joined
440
+ // topics (`wave.*`, `*.parallel.joined`) never re-enter this path since
441
+ // `emitted.topic` is always an agent-emitted, non-system event here.
442
+ if (loop.parallel.enabled) {
443
+ const concurrentRoles = topology.concurrentRolesForEvent(loop.topology, emitted.topic);
444
+ if (concurrentRoles.length > 0) {
445
+ // Execution is synchronous (one wave resolves per iteration); when an
446
+ // event routes to more than one concurrency role, the first declared
447
+ // role's wave runs this iteration — the routing event still applies
448
+ // (via the resume topic) on subsequent iterations for any others.
449
+ return finishDeclarativeIteration(loop, iter, emitted.topic, concurrentRoles[0], iterate, progress);
450
+ }
451
+ }
452
+ // Open-task gate for the completion-promise path. Mirror the event-path gate
453
+ // in emit.ts (same store via loop.paths.tasksFile, soft tasks are advisory)
454
+ // so a stdout promise can't bypass the requirement the emitted event honors.
455
+ const hasBlockingTasks = materializeOpenFrom(loop.paths.tasksFile).some((t) => t.soft !== true);
247
456
  const resolved = resolveOutcome({
248
457
  emittedTopic: emitted.topic,
249
458
  allTopics,
@@ -252,12 +461,119 @@ export async function finishIteration(loop, iter, output, iterate) {
252
461
  completionEvent: loop.completion.event,
253
462
  requiredEvents: loop.completion.requiredEvents,
254
463
  completionPromise: loop.completion.promise,
464
+ hasBlockingTasks,
465
+ mustBeLast: loop.completion.mustBeLast,
466
+ completionOrderOk: completionEmittedLast(runLines, loop.completion.event),
255
467
  });
256
468
  progress(emitted.topic, resolved.outcome);
257
- if (resolved.action === "complete_event")
258
- return completeLoop(loop, iter.iteration, "completion_event");
259
- if (resolved.action === "complete_promise")
260
- return completeLoop(loop, iter.iteration, "completion_promise");
469
+ if (resolved.action === "complete_event" ||
470
+ resolved.action === "complete_promise") {
471
+ const reason = resolved.action === "complete_event"
472
+ ? "completion_event"
473
+ : "completion_promise";
474
+ // Provisional-done hold: a self-asserted done-claim parks in
475
+ // `awaiting_acceptance` before any irreversible action and is released only
476
+ // when the deterministic gates pass (or an operator acknowledges).
477
+ enterProvisional(loop, iter.iteration, reason);
478
+ // Out-of-band acceptance gate: the harness runs deterministic verify
479
+ // commands on the done-claim.
480
+ const gate = runAcceptanceGate(loop, iter.iteration);
481
+ // Required-absence guards: catch reward-hacks (leftover TODO, skipped
482
+ // tests, secrets, dirty tree) the verify commands and LLM gates may miss.
483
+ // Only run when the acceptance gate passed (the run is already held
484
+ // otherwise).
485
+ const guards = gate.passed
486
+ ? runPostconditionGuards(loop, iter.iteration)
487
+ : { ran: false, passed: false, violations: [] };
488
+ // Anti-reward-hack screen: under bypassPermissions the maker can edit the
489
+ // very tests that gate it, so a test-backed "done" is screened for test
490
+ // tampering before release.
491
+ const tamper = gate.passed
492
+ ? runTamperScreen(loop, iter.iteration)
493
+ : { ran: false, passed: false, violations: [] };
494
+ // Intent-binding: the build must satisfy the stated acceptance criteria,
495
+ // not just pass its tests.
496
+ const intent = gate.passed
497
+ ? runIntentCriteria(loop, iter.iteration)
498
+ : { ran: false, passed: false, failures: [] };
499
+ const humanAck = consumeHumanAck(loop);
500
+ const state = resolveProvisional({
501
+ acceptancePassed: gate.passed,
502
+ postconditionsPassed: guards.passed && tamper.passed && intent.passed,
503
+ humanAck,
504
+ });
505
+ if (state === "accepted") {
506
+ releaseProvisional(loop, iter.iteration, humanAck);
507
+ return completeLoop(loop, iter.iteration, reason);
508
+ }
509
+ // Held: re-inject the most specific failure and route back to rework.
510
+ let cause = "acceptance";
511
+ if (!gate.passed) {
512
+ reinjectAcceptanceFailure(loop, iter.iteration, gate);
513
+ }
514
+ else if (!guards.passed) {
515
+ reinjectPostconditionFailure(loop, iter.iteration, guards);
516
+ cause = "postcondition";
517
+ }
518
+ else if (!tamper.passed) {
519
+ reinjectTamperFailure(loop, iter.iteration, tamper);
520
+ cause = "tamper";
521
+ }
522
+ else {
523
+ reinjectIntentFailure(loop, iter.iteration, intent);
524
+ cause = "intent";
525
+ }
526
+ holdProvisional(loop, iter.iteration, cause);
527
+ progress(emitted.topic, "hold:awaiting_acceptance");
528
+ return iterate(loop, iter.iteration + 1);
529
+ }
530
+ return iterate(loop, iter.iteration + 1);
531
+ }
532
+ /**
533
+ * Block the loop on a `human.ask` until an operator responds (via the `respond`
534
+ * control verb) or the timeout elapses, then continue. The answer is injected
535
+ * into the next iteration's prompt via the existing operator-guidance path.
536
+ */
537
+ async function finishAskIteration(loop, iter, question, iterate, progress) {
538
+ const runId = loop.runtime.runId;
539
+ const iteration = String(iter.iteration);
540
+ const questionId = `ask_${runId}_${iter.iteration}`;
541
+ appendEvent(loop.paths.journalFile, runId, iteration, "ask.pending", `${jsonField("question_id", questionId)}, ${jsonField("question", question)}`);
542
+ loop.onEvent?.({
543
+ type: "ask.pending",
544
+ runId,
545
+ iteration: iter.iteration,
546
+ questionId,
547
+ question,
548
+ });
549
+ progress(loop.ask.event, "ask:waiting");
550
+ const answer = await awaitHumanResponse({
551
+ stateDir: loop.paths.stateDir,
552
+ runId,
553
+ questionId,
554
+ timeoutMs: loop.ask.timeoutMs,
555
+ pollMs: loop.ask.pollMs,
556
+ signal: loop.signal,
557
+ });
558
+ if (answer === null) {
559
+ if (loop.signal?.aborted) {
560
+ return { iterations: iter.iteration, stopReason: "interrupted", runId };
561
+ }
562
+ appendEvent(loop.paths.journalFile, runId, iteration, "ask.timeout", jsonField("question_id", questionId));
563
+ progress(loop.ask.event, "ask:timeout");
564
+ return iterate(loop, iter.iteration + 1);
565
+ }
566
+ appendEvent(loop.paths.journalFile, runId, iteration, "ask.answered", `${jsonField("question_id", questionId)}, ${jsonField("answer", answer)}`);
567
+ // Inject the answer into the next prompt via the operator-guidance path.
568
+ appendOperatorEvent(loop.paths.journalFile, runId, iteration, "operator.guidance", `Human response to "${question}": ${answer}`);
569
+ loop.onEvent?.({
570
+ type: "ask.answered",
571
+ runId,
572
+ iteration: iter.iteration,
573
+ questionId,
574
+ answer,
575
+ });
576
+ progress(loop.ask.event, "ask:answered");
261
577
  return iterate(loop, iter.iteration + 1);
262
578
  }
263
579
  async function rejectInvalidAndContinue(loop, iter, emittedTopic, iterate, progress) {
@@ -275,14 +591,38 @@ async function finishParallelIteration(loop, iter, emittedTopic, emittedPayload,
275
591
  progress(emittedTopic, `parallel:stop:${result.reason}`);
276
592
  return stopAfterParallelWave(loop, iter, result.reason, result.waveId);
277
593
  }
594
+ /**
595
+ * Declarative-wave counterpart of `finishParallelIteration`: the routing
596
+ * event itself (not an agent `.parallel` emit) triggers N concurrent
597
+ * branches for `role`, per its topology `concurrency` declaration. Join/stop
598
+ * handling mirrors the agent-triggered path so downstream resume routing
599
+ * (`continueAfterParallelJoin`/`stopAfterParallelWave`) behaves identically.
600
+ */
601
+ async function finishDeclarativeIteration(loop, iter, routingEvent, role, iterate, progress) {
602
+ const result = executeDeclarativeWave(loop, iter, role, routingEvent);
603
+ const syntheticTopic = `${routingEvent}.parallel`;
604
+ if (result.reason === "parallel_wave_complete") {
605
+ progress(syntheticTopic, "parallel:joined");
606
+ return continueAfterParallelJoin(loop, iter, result.waveId, syntheticTopic, result.elapsedMs, iterate);
607
+ }
608
+ progress(syntheticTopic, `parallel:stop:${result.reason}`);
609
+ return stopAfterParallelWave(loop, iter, result.reason, result.waveId);
610
+ }
278
611
  export function resolveOutcome(ctx) {
279
- if (completedViaEvent(ctx.allTopics, ctx.completionEvent, ctx.requiredEvents)) {
612
+ if (completedViaEvent(ctx.allTopics, ctx.completionEvent, ctx.requiredEvents) &&
613
+ (!ctx.mustBeLast || ctx.completionOrderOk)) {
280
614
  return { action: "complete_event", outcome: "complete:completion_event" };
281
615
  }
282
616
  if (shouldContinueFromAcceptedEvent(ctx.emittedTopic, ctx.completionEvent)) {
283
617
  return { action: "continue_routed", outcome: "continue:routed_event" };
284
618
  }
619
+ // The stdout promise is a fallback, not a self-grade: a substring match alone
620
+ // must never finish a run. It is accepted only when the run also clears the
621
+ // same gates the completion event must clear — no invalid events this turn,
622
+ // every required event seen, and no open blocking tasks.
285
623
  if (!ctx.hadInvalidEvents &&
624
+ !ctx.hasBlockingTasks &&
625
+ requiredEventsSatisfied(ctx.allTopics, ctx.requiredEvents) &&
286
626
  completedViaPromise(ctx.output, ctx.completionPromise)) {
287
627
  return {
288
628
  action: "complete_promise",
@@ -300,10 +640,37 @@ function latestAgentEventRecord(lines) {
300
640
  }
301
641
  return { topic: "", payload: "" };
302
642
  }
643
+ /**
644
+ * True iff `completionEvent`, wherever it was last emitted in the run, was
645
+ * the last non-system topic emitted in that same turn (iteration). Used to
646
+ * enforce `completion.must_be_last`: ralph rejects completion if other
647
+ * events follow it within the same turn, rather than autoloop's default
648
+ * order-insensitive set-membership check. Returns true (not applicable) if
649
+ * the completion event has not been emitted at all.
650
+ */
651
+ export function completionEmittedLast(runLines, completionEvent) {
652
+ let completionIteration = null;
653
+ for (const line of runLines) {
654
+ if (extractTopic(line) === completionEvent) {
655
+ completionIteration = extractIteration(line);
656
+ }
657
+ }
658
+ if (completionIteration === null)
659
+ return true;
660
+ const turnTopics = runLines
661
+ .filter((line) => extractIteration(line) === completionIteration)
662
+ .map(extractTopic)
663
+ .filter((topic) => topic !== "" && !systemTopic(topic));
664
+ return turnTopics.lastIndexOf(completionEvent) === turnTopics.length - 1;
665
+ }
666
+ /** Every required-evidence event has been seen in the run so far. */
667
+ function requiredEventsSatisfied(topics, requiredEvents) {
668
+ return requiredEvents.every((e) => topics.includes(e));
669
+ }
303
670
  function completedViaEvent(topics, completionEvent, requiredEvents) {
304
671
  if (!topics.includes(completionEvent))
305
672
  return false;
306
- return requiredEvents.every((e) => topics.includes(e));
673
+ return requiredEventsSatisfied(topics, requiredEvents);
307
674
  }
308
675
  function completedViaPromise(output, promise) {
309
676
  if (!promise)