@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.
- package/dist/acceptance.d.ts +31 -0
- package/dist/acceptance.js +69 -0
- package/dist/acceptance.js.map +1 -0
- package/dist/ask.d.ts +16 -0
- package/dist/ask.js +47 -0
- package/dist/ask.js.map +1 -0
- package/dist/backend/types.d.ts +16 -0
- package/dist/backend/types.js +17 -0
- package/dist/backend/types.js.map +1 -0
- package/dist/circuit-breaker.d.ts +27 -0
- package/dist/circuit-breaker.js +41 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/completion-lint.d.ts +29 -0
- package/dist/completion-lint.js +51 -0
- package/dist/completion-lint.js.map +1 -0
- package/dist/config-helpers.d.ts +10 -2
- package/dist/config-helpers.js +272 -30
- package/dist/config-helpers.js.map +1 -1
- package/dist/control/acp-adapter.d.ts +12 -0
- package/dist/control/acp-adapter.js +54 -0
- package/dist/control/acp-adapter.js.map +1 -0
- package/dist/control/adapter.d.ts +12 -0
- package/dist/control/adapter.js +2 -0
- package/dist/control/adapter.js.map +1 -0
- package/dist/control/capabilities.d.ts +9 -0
- package/dist/control/capabilities.js +27 -0
- package/dist/control/capabilities.js.map +1 -0
- package/dist/control/claude-sdk-adapter.d.ts +15 -0
- package/dist/control/claude-sdk-adapter.js +79 -0
- package/dist/control/claude-sdk-adapter.js.map +1 -0
- package/dist/control/command-adapter.d.ts +19 -0
- package/dist/control/command-adapter.js +57 -0
- package/dist/control/command-adapter.js.map +1 -0
- package/dist/control/dispatch.d.ts +14 -0
- package/dist/control/dispatch.js +55 -0
- package/dist/control/dispatch.js.map +1 -0
- package/dist/control/index.d.ts +11 -0
- package/dist/control/index.js +9 -0
- package/dist/control/index.js.map +1 -0
- package/dist/control/kiro-adapter.d.ts +2 -0
- package/dist/control/kiro-adapter.js +2 -0
- package/dist/control/kiro-adapter.js.map +1 -0
- package/dist/control/paths.d.ts +19 -0
- package/dist/control/paths.js +40 -0
- package/dist/control/paths.js.map +1 -0
- package/dist/control/pi-adapter.d.ts +15 -0
- package/dist/control/pi-adapter.js +76 -0
- package/dist/control/pi-adapter.js.map +1 -0
- package/dist/control/queue.d.ts +14 -0
- package/dist/control/queue.js +82 -0
- package/dist/control/queue.js.map +1 -0
- package/dist/control/render.d.ts +10 -0
- package/dist/control/render.js +53 -0
- package/dist/control/render.js.map +1 -0
- package/dist/control/types.d.ts +56 -0
- package/dist/control/types.js +9 -0
- package/dist/control/types.js.map +1 -0
- package/dist/display.d.ts +9 -2
- package/dist/display.js +37 -1
- package/dist/display.js.map +1 -1
- package/dist/emit.d.ts +27 -0
- package/dist/emit.js +327 -6
- package/dist/emit.js.map +1 -1
- package/dist/events.d.ts +36 -3
- package/dist/fanout-runner.d.ts +25 -0
- package/dist/fanout-runner.js +54 -0
- package/dist/fanout-runner.js.map +1 -0
- package/dist/file-mod-audit.d.ts +29 -0
- package/dist/file-mod-audit.js +63 -0
- package/dist/file-mod-audit.js.map +1 -0
- package/dist/git-diff.d.ts +19 -0
- package/dist/git-diff.js +74 -0
- package/dist/git-diff.js.map +1 -0
- package/dist/guards.d.ts +37 -0
- package/dist/guards.js +78 -0
- package/dist/guards.js.map +1 -0
- package/dist/hooks.d.ts +78 -0
- package/dist/hooks.js +239 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +359 -26
- package/dist/index.js.map +1 -1
- package/dist/intent.d.ts +45 -0
- package/dist/intent.js +127 -0
- package/dist/intent.js.map +1 -0
- package/dist/iteration-diff.d.ts +32 -0
- package/dist/iteration-diff.js +92 -0
- package/dist/iteration-diff.js.map +1 -0
- package/dist/iteration.d.ts +20 -0
- package/dist/iteration.js +551 -30
- package/dist/iteration.js.map +1 -1
- package/dist/metareview.d.ts +9 -1
- package/dist/metareview.js +158 -34
- package/dist/metareview.js.map +1 -1
- package/dist/notify.d.ts +22 -0
- package/dist/notify.js +110 -0
- package/dist/notify.js.map +1 -0
- package/dist/parallel.d.ts +17 -0
- package/dist/parallel.js +38 -6
- package/dist/parallel.js.map +1 -1
- package/dist/postconditions.d.ts +25 -0
- package/dist/postconditions.js +107 -0
- package/dist/postconditions.js.map +1 -0
- package/dist/postfire-verify.d.ts +31 -0
- package/dist/postfire-verify.js +64 -0
- package/dist/postfire-verify.js.map +1 -0
- package/dist/premature-quit.d.ts +25 -0
- package/dist/premature-quit.js +54 -0
- package/dist/premature-quit.js.map +1 -0
- package/dist/progress.d.ts +20 -0
- package/dist/progress.js +56 -0
- package/dist/progress.js.map +1 -0
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.js +42 -2
- package/dist/prompt.js.map +1 -1
- package/dist/provisional.d.ts +42 -0
- package/dist/provisional.js +92 -0
- package/dist/provisional.js.map +1 -0
- package/dist/registry-bridge.js +32 -1
- package/dist/registry-bridge.js.map +1 -1
- package/dist/resume.d.ts +61 -0
- package/dist/resume.js +226 -0
- package/dist/resume.js.map +1 -0
- package/dist/scratchpad.js +27 -8
- package/dist/scratchpad.js.map +1 -1
- package/dist/stage.d.ts +21 -0
- package/dist/stage.js +131 -0
- package/dist/stage.js.map +1 -0
- package/dist/stop.d.ts +32 -2
- package/dist/stop.js +169 -0
- package/dist/stop.js.map +1 -1
- package/dist/suspend-state.d.ts +32 -0
- package/dist/suspend-state.js +104 -0
- package/dist/suspend-state.js.map +1 -0
- package/dist/tamper.d.ts +21 -0
- package/dist/tamper.js +88 -0
- package/dist/tamper.js.map +1 -0
- package/dist/types.d.ts +294 -3
- package/dist/types.js +91 -1
- package/dist/types.js.map +1 -1
- package/dist/wave/finalize-wave.d.ts +15 -5
- package/dist/wave/finalize-wave.js +56 -2
- package/dist/wave/finalize-wave.js.map +1 -1
- package/dist/wave/launch-branches.d.ts +23 -2
- package/dist/wave/launch-branches.js +58 -3
- package/dist/wave/launch-branches.js.map +1 -1
- package/dist/wave/stage-branch-runner.d.ts +12 -0
- package/dist/wave/stage-branch-runner.js +130 -0
- package/dist/wave/stage-branch-runner.js.map +1 -0
- package/dist/wave/types.d.ts +40 -2
- package/dist/wave.d.ts +32 -1
- package/dist/wave.js +163 -34
- package/dist/wave.js.map +1 -1
- package/package.json +131 -3
package/dist/iteration.js
CHANGED
|
@@ -1,16 +1,67 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
4
|
-
import {
|
|
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 {
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
-
|
|
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=${
|
|
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 (
|
|
31
|
-
if (loop.
|
|
95
|
+
if (iter.backend.kind === "acp") {
|
|
96
|
+
if (loop.acpSession.current) {
|
|
32
97
|
try {
|
|
33
|
-
await terminateAcpSession(loop.
|
|
98
|
+
await terminateAcpSession(loop.acpSession.current);
|
|
34
99
|
}
|
|
35
100
|
catch {
|
|
36
101
|
/* best-effort */
|
|
37
102
|
}
|
|
38
|
-
loop.
|
|
103
|
+
loop.acpSession.current = undefined;
|
|
39
104
|
}
|
|
40
105
|
const acpOpts = {
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
45
|
-
agentName: iter.
|
|
46
|
-
modelId:
|
|
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.
|
|
50
|
-
log(loop, "debug", `new
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
673
|
+
return requiredEventsSatisfied(topics, requiredEvents);
|
|
153
674
|
}
|
|
154
675
|
function completedViaPromise(output, promise) {
|
|
155
676
|
if (!promise)
|