@mobrienv/autoloop-harness 0.7.4 → 0.9.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 (154) 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 +16 -0
  8. package/dist/backend/types.js +17 -0
  9. package/dist/backend/types.js.map +1 -0
  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.d.ts +10 -2
  17. package/dist/config-helpers.js +272 -30
  18. package/dist/config-helpers.js.map +1 -1
  19. package/dist/control/acp-adapter.d.ts +12 -0
  20. package/dist/control/acp-adapter.js +54 -0
  21. package/dist/control/acp-adapter.js.map +1 -0
  22. package/dist/control/adapter.d.ts +12 -0
  23. package/dist/control/adapter.js +2 -0
  24. package/dist/control/adapter.js.map +1 -0
  25. package/dist/control/capabilities.d.ts +9 -0
  26. package/dist/control/capabilities.js +27 -0
  27. package/dist/control/capabilities.js.map +1 -0
  28. package/dist/control/claude-sdk-adapter.d.ts +15 -0
  29. package/dist/control/claude-sdk-adapter.js +79 -0
  30. package/dist/control/claude-sdk-adapter.js.map +1 -0
  31. package/dist/control/command-adapter.d.ts +19 -0
  32. package/dist/control/command-adapter.js +57 -0
  33. package/dist/control/command-adapter.js.map +1 -0
  34. package/dist/control/dispatch.d.ts +14 -0
  35. package/dist/control/dispatch.js +55 -0
  36. package/dist/control/dispatch.js.map +1 -0
  37. package/dist/control/index.d.ts +11 -0
  38. package/dist/control/index.js +9 -0
  39. package/dist/control/index.js.map +1 -0
  40. package/dist/control/kiro-adapter.d.ts +2 -0
  41. package/dist/control/kiro-adapter.js +2 -0
  42. package/dist/control/kiro-adapter.js.map +1 -0
  43. package/dist/control/paths.d.ts +19 -0
  44. package/dist/control/paths.js +40 -0
  45. package/dist/control/paths.js.map +1 -0
  46. package/dist/control/pi-adapter.d.ts +15 -0
  47. package/dist/control/pi-adapter.js +76 -0
  48. package/dist/control/pi-adapter.js.map +1 -0
  49. package/dist/control/queue.d.ts +14 -0
  50. package/dist/control/queue.js +82 -0
  51. package/dist/control/queue.js.map +1 -0
  52. package/dist/control/render.d.ts +10 -0
  53. package/dist/control/render.js +53 -0
  54. package/dist/control/render.js.map +1 -0
  55. package/dist/control/types.d.ts +56 -0
  56. package/dist/control/types.js +9 -0
  57. package/dist/control/types.js.map +1 -0
  58. package/dist/display.d.ts +9 -2
  59. package/dist/display.js +37 -1
  60. package/dist/display.js.map +1 -1
  61. package/dist/emit.d.ts +27 -0
  62. package/dist/emit.js +327 -6
  63. package/dist/emit.js.map +1 -1
  64. package/dist/events.d.ts +36 -3
  65. package/dist/fanout-runner.d.ts +25 -0
  66. package/dist/fanout-runner.js +54 -0
  67. package/dist/fanout-runner.js.map +1 -0
  68. package/dist/file-mod-audit.d.ts +29 -0
  69. package/dist/file-mod-audit.js +63 -0
  70. package/dist/file-mod-audit.js.map +1 -0
  71. package/dist/git-diff.d.ts +19 -0
  72. package/dist/git-diff.js +74 -0
  73. package/dist/git-diff.js.map +1 -0
  74. package/dist/guards.d.ts +37 -0
  75. package/dist/guards.js +78 -0
  76. package/dist/guards.js.map +1 -0
  77. package/dist/hooks.d.ts +78 -0
  78. package/dist/hooks.js +239 -0
  79. package/dist/hooks.js.map +1 -0
  80. package/dist/index.d.ts +15 -2
  81. package/dist/index.js +359 -26
  82. package/dist/index.js.map +1 -1
  83. package/dist/intent.d.ts +45 -0
  84. package/dist/intent.js +127 -0
  85. package/dist/intent.js.map +1 -0
  86. package/dist/iteration-diff.d.ts +32 -0
  87. package/dist/iteration-diff.js +92 -0
  88. package/dist/iteration-diff.js.map +1 -0
  89. package/dist/iteration.d.ts +20 -0
  90. package/dist/iteration.js +551 -30
  91. package/dist/iteration.js.map +1 -1
  92. package/dist/metareview.d.ts +9 -1
  93. package/dist/metareview.js +158 -34
  94. package/dist/metareview.js.map +1 -1
  95. package/dist/notify.d.ts +22 -0
  96. package/dist/notify.js +110 -0
  97. package/dist/notify.js.map +1 -0
  98. package/dist/parallel.d.ts +17 -0
  99. package/dist/parallel.js +38 -6
  100. package/dist/parallel.js.map +1 -1
  101. package/dist/postconditions.d.ts +25 -0
  102. package/dist/postconditions.js +107 -0
  103. package/dist/postconditions.js.map +1 -0
  104. package/dist/postfire-verify.d.ts +31 -0
  105. package/dist/postfire-verify.js +64 -0
  106. package/dist/postfire-verify.js.map +1 -0
  107. package/dist/premature-quit.d.ts +25 -0
  108. package/dist/premature-quit.js +54 -0
  109. package/dist/premature-quit.js.map +1 -0
  110. package/dist/progress.d.ts +20 -0
  111. package/dist/progress.js +56 -0
  112. package/dist/progress.js.map +1 -0
  113. package/dist/prompt.d.ts +4 -0
  114. package/dist/prompt.js +42 -2
  115. package/dist/prompt.js.map +1 -1
  116. package/dist/provisional.d.ts +42 -0
  117. package/dist/provisional.js +92 -0
  118. package/dist/provisional.js.map +1 -0
  119. package/dist/registry-bridge.js +32 -1
  120. package/dist/registry-bridge.js.map +1 -1
  121. package/dist/resume.d.ts +61 -0
  122. package/dist/resume.js +226 -0
  123. package/dist/resume.js.map +1 -0
  124. package/dist/scratchpad.js +27 -8
  125. package/dist/scratchpad.js.map +1 -1
  126. package/dist/stage.d.ts +21 -0
  127. package/dist/stage.js +131 -0
  128. package/dist/stage.js.map +1 -0
  129. package/dist/stop.d.ts +32 -2
  130. package/dist/stop.js +169 -0
  131. package/dist/stop.js.map +1 -1
  132. package/dist/suspend-state.d.ts +32 -0
  133. package/dist/suspend-state.js +104 -0
  134. package/dist/suspend-state.js.map +1 -0
  135. package/dist/tamper.d.ts +21 -0
  136. package/dist/tamper.js +88 -0
  137. package/dist/tamper.js.map +1 -0
  138. package/dist/types.d.ts +294 -3
  139. package/dist/types.js +91 -1
  140. package/dist/types.js.map +1 -1
  141. package/dist/wave/finalize-wave.d.ts +15 -5
  142. package/dist/wave/finalize-wave.js +56 -2
  143. package/dist/wave/finalize-wave.js.map +1 -1
  144. package/dist/wave/launch-branches.d.ts +23 -2
  145. package/dist/wave/launch-branches.js +58 -3
  146. package/dist/wave/launch-branches.js.map +1 -1
  147. package/dist/wave/stage-branch-runner.d.ts +12 -0
  148. package/dist/wave/stage-branch-runner.js +130 -0
  149. package/dist/wave/stage-branch-runner.js.map +1 -0
  150. package/dist/wave/types.d.ts +40 -2
  151. package/dist/wave.d.ts +32 -1
  152. package/dist/wave.js +163 -34
  153. package/dist/wave.js.map +1 -1
  154. package/package.json +131 -3
package/dist/iteration.js CHANGED
@@ -1,16 +1,67 @@
1
- import { runKiroIteration } from "@mobrienv/autoloop-backends";
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { classifyBackendError, runAcpIteration, runClaudeSdkIteration, runPiIteration, } from "@mobrienv/autoloop-backends";
2
4
  import { initAcpSession, terminateAcpSession, } from "@mobrienv/autoloop-backends/acp-client";
3
- import { listText } from "@mobrienv/autoloop-core";
4
- import { extractField, extractIteration, extractTopic, readRunLines, } from "@mobrienv/autoloop-core/journal";
5
+ import { providerLaunchArgs } from "@mobrienv/autoloop-backends/acp-providers";
6
+ import { getClaudeSdkUsage, initClaudeSdkSession, terminateClaudeSdkSession, } from "@mobrienv/autoloop-backends/claude-sdk-client";
7
+ import { getPiSessionStats, initPiSession, resetPiSession, terminatePiSession, } from "@mobrienv/autoloop-backends/pi-rpc-client";
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";
5
15
  import { log } from "./display.js";
6
16
  import { appendInvalidEvent, invalidEvent, parallelTriggerTopic, systemTopic, } from "./emit.js";
7
- import { appendBackendFinish, appendBackendStart, appendIterationFinish, appendIterationStart, buildBackendCommand, runProcess, } from "./parallel.js";
17
+ import { runFileModAudit } from "./file-mod-audit.js";
18
+ import { loopStartMs } from "./guards.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";
8
24
  import { buildIterationContext } from "./prompt.js";
25
+ import { consumeHumanAck, enterProvisional, holdProvisional, releaseProvisional, resolveProvisional, } from "./provisional.js";
9
26
  import { registryProgress } from "./registry-bridge.js";
10
- import { completeLoop, stopBackendFailed, stopBackendTimeout } from "./stop.js";
11
- 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";
12
32
  export async function runIteration(loop, iteration, iterate) {
13
- const iter = buildIterationContext(loop, iteration);
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
+ }
45
+ let iter = buildIterationContext(loop, iteration);
46
+ // Clamp the iteration timeout to the remaining loop wall-clock budget so a
47
+ // long iteration never overshoots event_loop.max_runtime. Applied before
48
+ // appendBackendStart so the journaled timeout_ms is the effective value.
49
+ const maxRuntimeMs = loop.limits.maxRuntimeMs ?? 0;
50
+ let clampedByLoopBudget = false;
51
+ let loopStartedMs = null;
52
+ if (maxRuntimeMs > 0) {
53
+ loopStartedMs = loopStartMs(readRunLines(loop.paths.journalFile, loop.runtime.runId));
54
+ if (loopStartedMs !== null) {
55
+ const remainingMs = Math.max(1, maxRuntimeMs - (Date.now() - loopStartedMs));
56
+ if (remainingMs < iter.backend.timeoutMs) {
57
+ iter = {
58
+ ...iter,
59
+ backend: { ...iter.backend, timeoutMs: remainingMs },
60
+ };
61
+ clampedByLoopBudget = true;
62
+ }
63
+ }
64
+ }
14
65
  loop.onEvent?.({
15
66
  type: "iteration.banner",
16
67
  iteration: iter.iteration,
@@ -23,39 +74,96 @@ export async function runIteration(loop, iteration, iterate) {
23
74
  appendIterationStart(loop, iter);
24
75
  log(loop, "debug", `iteration ${iteration} start`);
25
76
  appendBackendStart(loop, iter);
26
- log(loop, "debug", `backend start command=${loop.backend.command}`);
77
+ log(loop, "debug", `backend start command=${iter.backend.command}`);
27
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
+ }
28
93
  // Fresh ACP session per iteration — ensures each role (researcher, critic,
29
94
  // etc.) starts with a clean context window for truly independent review.
30
- if (loop.backend.kind === "kiro") {
31
- if (loop.kiroSession) {
95
+ if (iter.backend.kind === "acp") {
96
+ if (loop.acpSession.current) {
32
97
  try {
33
- await terminateAcpSession(loop.kiroSession);
98
+ await terminateAcpSession(loop.acpSession.current);
34
99
  }
35
100
  catch {
36
101
  /* best-effort */
37
102
  }
38
- loop.kiroSession = undefined;
103
+ loop.acpSession.current = undefined;
39
104
  }
40
105
  const acpOpts = {
41
- command: loop.backend.command,
42
- args: loop.backend.args,
106
+ provider: iter.backend.provider,
107
+ command: iter.backend.command,
108
+ args: providerLaunchArgs(iter.backend.provider, iter.backend.profile, iter.backend.args),
43
109
  cwd: loop.paths.workDir,
44
- trustAllTools: loop.store.kiro_trust_all_tools !== false,
45
- agentName: iter.roleAgent || loop.store.kiro_agent || undefined,
46
- modelId: loop.store.kiro_model || undefined,
110
+ trustAllTools: iter.backend.trustAllTools,
111
+ agentName: iter.backend.agent || undefined,
112
+ modelId: iter.backend.model || undefined,
47
113
  verbose: loop.runtime.logLevel === "debug",
48
114
  };
49
- loop.kiroSession = await initAcpSession(acpOpts);
50
- log(loop, "debug", `new kiro session for iteration ${iteration} agent="${acpOpts.agentName ?? "default"}"`);
115
+ loop.acpSession.current = await initAcpSession(acpOpts);
116
+ log(loop, "debug", `new ACP session for iteration ${iteration} provider="${iter.backend.provider || "generic"}" agent="${acpOpts.agentName ?? "default"}"`);
117
+ }
118
+ // Pi reuses one live RPC process across iterations; each iteration starts a
119
+ // fresh conversation (`new_session`) so roles get a clean context window.
120
+ if (iter.backend.kind === "pi") {
121
+ await ensurePiSession(loop, iter, iteration);
51
122
  }
52
- const { output, exitCode, timedOut } = loop.backend.kind === "kiro" && loop.kiroSession
53
- ? await runKiroIteration(loop.kiroSession, iter.prompt, loop.backend.timeoutMs)
54
- : runProcess(buildBackendCommand(loop, iter), loop.backend.timeoutMs, loop.backend.kind);
123
+ // Fresh Claude Agent SDK session per iteration one query is one
124
+ // conversation, so a new query gives each role a clean context window
125
+ // while keeping the streaming-input channel for live interrupt/steer.
126
+ if (iter.backend.kind === "claude-sdk") {
127
+ if (loop.claudeSdkSession.current) {
128
+ try {
129
+ await terminateClaudeSdkSession(loop.claudeSdkSession.current);
130
+ }
131
+ catch {
132
+ /* best-effort */
133
+ }
134
+ loop.claudeSdkSession.current = undefined;
135
+ }
136
+ loop.claudeSdkSession.current = await initClaudeSdkSession({
137
+ command: iter.backend.command && iter.backend.command !== "claude"
138
+ ? iter.backend.command
139
+ : undefined,
140
+ model: iter.backend.model || undefined,
141
+ cwd: loop.paths.workDir,
142
+ trustAllTools: iter.backend.trustAllTools,
143
+ disallowedTools: iter.backend.disallowedTools,
144
+ verbose: loop.runtime.logLevel === "debug",
145
+ });
146
+ log(loop, "debug", `new claude-sdk session for iteration ${iteration} model="${iter.backend.model || "default"}"`);
147
+ }
148
+ const { output, exitCode, timedOut } = await runBackendIteration(loop, iter);
55
149
  const elapsedS = Math.floor(Date.now() / 1000) - startEpoch;
56
150
  appendBackendFinish(loop, iter, output, exitCode, timedOut);
57
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
+ }
58
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);
59
167
  log(loop, "debug", `iteration ${iteration} finish exit_code=${exitCode}`);
60
168
  loop.onEvent?.({
61
169
  type: "iteration.footer",
@@ -63,12 +171,227 @@ export async function runIteration(loop, iteration, iterate) {
63
171
  elapsedS,
64
172
  });
65
173
  loop.onEvent?.({ type: "backend.output", output });
174
+ if (timedOut && clampedByLoopBudget && loopStartedMs !== null) {
175
+ // The loop budget, not the per-iteration limit, was the binding
176
+ // constraint on the timeout that fired — journal max_runtime.
177
+ return stopMaxRuntime(loop, iteration, Date.now() - loopStartedMs, maxRuntimeMs, output);
178
+ }
66
179
  if (timedOut)
67
180
  return stopBackendTimeout(loop, iteration, output);
68
- if (exitCode !== 0)
69
- return stopBackendFailed(loop, iteration, output);
181
+ if (exitCode !== 0) {
182
+ return handleBackendFailure(loop, iteration, output, iterate);
183
+ }
70
184
  return finishIteration(loop, iter, output, iterate);
71
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
+ }
219
+ async function runBackendIteration(loop, iter) {
220
+ if (iter.backend.kind === "acp" && loop.acpSession.current) {
221
+ return runAcpIteration(loop.acpSession.current, iter.prompt, iter.backend.timeoutMs);
222
+ }
223
+ if (iter.backend.kind === "pi" && loop.piSession.current) {
224
+ const result = await runPiIteration(loop.piSession.current, iter.prompt, iter.backend.timeoutMs, join(loop.paths.stateDir, `pi-stream.${iter.iteration}.jsonl`));
225
+ await recordPiUsage(loop, iter);
226
+ return result;
227
+ }
228
+ if (iter.backend.kind === "claude-sdk" && loop.claudeSdkSession.current) {
229
+ const result = await runClaudeSdkIteration(loop.claudeSdkSession.current, iter.prompt, iter.backend.timeoutMs, join(loop.paths.stateDir, `claude-stream.${iter.iteration}.jsonl`));
230
+ recordClaudeSdkUsage(loop, iter);
231
+ return result;
232
+ }
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)));
312
+ }
313
+ /**
314
+ * Journal per-iteration token/cost totals from pi's get_session_stats.
315
+ * Best-effort: telemetry never fails or stalls the iteration.
316
+ */
317
+ async function recordPiUsage(loop, iter) {
318
+ const session = loop.piSession.current;
319
+ if (!session)
320
+ return;
321
+ const stats = await getPiSessionStats(session);
322
+ if (!stats)
323
+ return;
324
+ appendEvent(loop.paths.journalFile, loop.runtime.runId, String(iter.iteration), "backend.usage", jsonFieldRaw("input_tokens", String(stats.inputTokens)) +
325
+ ", " +
326
+ jsonFieldRaw("output_tokens", String(stats.outputTokens)) +
327
+ ", " +
328
+ jsonFieldRaw("cache_read_tokens", String(stats.cacheReadTokens)) +
329
+ ", " +
330
+ jsonFieldRaw("cache_write_tokens", String(stats.cacheWriteTokens)) +
331
+ ", " +
332
+ jsonFieldRaw("total_tokens", String(stats.totalTokens)) +
333
+ ", " +
334
+ jsonFieldRaw("cost_usd", String(stats.costUsd)) +
335
+ (stats.contextPercent === undefined
336
+ ? ""
337
+ : `, ${jsonFieldRaw("context_percent", String(stats.contextPercent))}`));
338
+ }
339
+ /**
340
+ * Journal per-iteration token/cost totals from the claude-sdk result message.
341
+ * Same event shape as the pi backend so cost-budget guards and usage
342
+ * reporting work unchanged. Best-effort: telemetry never fails the iteration.
343
+ */
344
+ function recordClaudeSdkUsage(loop, iter) {
345
+ const session = loop.claudeSdkSession.current;
346
+ if (!session)
347
+ return;
348
+ const stats = getClaudeSdkUsage(session);
349
+ if (!stats)
350
+ return;
351
+ appendEvent(loop.paths.journalFile, loop.runtime.runId, String(iter.iteration), "backend.usage", jsonFieldRaw("input_tokens", String(stats.inputTokens)) +
352
+ ", " +
353
+ jsonFieldRaw("output_tokens", String(stats.outputTokens)) +
354
+ ", " +
355
+ jsonFieldRaw("cache_read_tokens", String(stats.cacheReadTokens)) +
356
+ ", " +
357
+ jsonFieldRaw("cache_write_tokens", String(stats.cacheWriteTokens)) +
358
+ ", " +
359
+ jsonFieldRaw("total_tokens", String(stats.totalTokens)) +
360
+ ", " +
361
+ jsonFieldRaw("cost_usd", String(stats.costUsd)));
362
+ }
363
+ /**
364
+ * Make sure a live pi RPC session with a fresh conversation is available.
365
+ * Prefers a `new_session` reset on the running process; respawns when the
366
+ * process is gone or refuses the reset.
367
+ */
368
+ async function ensurePiSession(loop, iter, iteration) {
369
+ const existing = loop.piSession.current;
370
+ if (existing) {
371
+ try {
372
+ await resetPiSession(existing);
373
+ log(loop, "debug", `pi session reset for iteration ${iteration}`);
374
+ return;
375
+ }
376
+ catch {
377
+ loop.piSession.current = undefined;
378
+ try {
379
+ await terminatePiSession(existing);
380
+ }
381
+ catch {
382
+ /* best-effort */
383
+ }
384
+ }
385
+ }
386
+ loop.piSession.current = await initPiSession({
387
+ command: iter.backend.command,
388
+ args: iter.backend.args,
389
+ cwd: loop.paths.workDir,
390
+ modelId: iter.backend.model || undefined,
391
+ verbose: loop.runtime.logLevel === "debug",
392
+ });
393
+ log(loop, "debug", `new pi RPC session for iteration ${iteration} model="${iter.backend.model || "default"}"`);
394
+ }
72
395
  export async function finishIteration(loop, iter, output, iterate) {
73
396
  const runLines = readRunLines(loop.paths.journalFile, loop.runtime.runId);
74
397
  const allTopics = runLines.map(extractTopic).filter((t) => t !== "");
@@ -84,12 +407,52 @@ export async function finishIteration(loop, iter, output, iterate) {
84
407
  emittedTopic,
85
408
  outcome,
86
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
+ }
87
417
  if (invalidEvent(emitted.topic, iter.allowedEvents, loop.parallel.enabled, loop.completion.event)) {
88
418
  return rejectInvalidAndContinue(loop, iter, emitted.topic, iterate, progress);
89
419
  }
90
420
  if (parallelTriggerTopic(emitted.topic)) {
91
421
  return finishParallelIteration(loop, iter, emitted.topic, emitted.payload, iterate, progress);
92
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);
93
456
  const resolved = resolveOutcome({
94
457
  emittedTopic: emitted.topic,
95
458
  allTopics,
@@ -98,12 +461,119 @@ export async function finishIteration(loop, iter, output, iterate) {
98
461
  completionEvent: loop.completion.event,
99
462
  requiredEvents: loop.completion.requiredEvents,
100
463
  completionPromise: loop.completion.promise,
464
+ hasBlockingTasks,
465
+ mustBeLast: loop.completion.mustBeLast,
466
+ completionOrderOk: completionEmittedLast(runLines, loop.completion.event),
101
467
  });
102
468
  progress(emitted.topic, resolved.outcome);
103
- if (resolved.action === "complete_event")
104
- return completeLoop(loop, iter.iteration, "completion_event");
105
- if (resolved.action === "complete_promise")
106
- 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");
107
577
  return iterate(loop, iter.iteration + 1);
108
578
  }
109
579
  async function rejectInvalidAndContinue(loop, iter, emittedTopic, iterate, progress) {
@@ -121,14 +591,38 @@ async function finishParallelIteration(loop, iter, emittedTopic, emittedPayload,
121
591
  progress(emittedTopic, `parallel:stop:${result.reason}`);
122
592
  return stopAfterParallelWave(loop, iter, result.reason, result.waveId);
123
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
+ }
124
611
  export function resolveOutcome(ctx) {
125
- if (completedViaEvent(ctx.allTopics, ctx.completionEvent, ctx.requiredEvents)) {
612
+ if (completedViaEvent(ctx.allTopics, ctx.completionEvent, ctx.requiredEvents) &&
613
+ (!ctx.mustBeLast || ctx.completionOrderOk)) {
126
614
  return { action: "complete_event", outcome: "complete:completion_event" };
127
615
  }
128
616
  if (shouldContinueFromAcceptedEvent(ctx.emittedTopic, ctx.completionEvent)) {
129
617
  return { action: "continue_routed", outcome: "continue:routed_event" };
130
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.
131
623
  if (!ctx.hadInvalidEvents &&
624
+ !ctx.hasBlockingTasks &&
625
+ requiredEventsSatisfied(ctx.allTopics, ctx.requiredEvents) &&
132
626
  completedViaPromise(ctx.output, ctx.completionPromise)) {
133
627
  return {
134
628
  action: "complete_promise",
@@ -146,10 +640,37 @@ function latestAgentEventRecord(lines) {
146
640
  }
147
641
  return { topic: "", payload: "" };
148
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
+ }
149
670
  function completedViaEvent(topics, completionEvent, requiredEvents) {
150
671
  if (!topics.includes(completionEvent))
151
672
  return false;
152
- return requiredEvents.every((e) => topics.includes(e));
673
+ return requiredEventsSatisfied(topics, requiredEvents);
153
674
  }
154
675
  function completedViaPromise(output, promise) {
155
676
  if (!promise)