@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.
- 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 +3 -0
- package/dist/backend/types.js +3 -0
- package/dist/backend/types.js.map +1 -1
- 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.js +141 -8
- package/dist/config-helpers.js.map +1 -1
- 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.js +14 -0
- package/dist/control/dispatch.js.map +1 -1
- package/dist/control/types.d.ts +11 -2
- 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 +321 -2
- 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/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 +181 -5
- 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 +383 -16
- package/dist/iteration.js.map +1 -1
- package/dist/metareview.d.ts +9 -1
- package/dist/metareview.js +68 -30
- package/dist/metareview.js.map +1 -1
- package/dist/notify.d.ts +9 -2
- package/dist/notify.js +16 -4
- package/dist/notify.js.map +1 -1
- package/dist/parallel.d.ts +16 -0
- package/dist/parallel.js +28 -1
- 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.js +8 -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 +25 -0
- 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 +29 -2
- package/dist/stop.js +106 -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 +247 -2
- 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 +56 -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 +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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"}
|
package/dist/iteration.d.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
15
|
-
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";
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
673
|
+
return requiredEventsSatisfied(topics, requiredEvents);
|
|
307
674
|
}
|
|
308
675
|
function completedViaPromise(output, promise) {
|
|
309
676
|
if (!promise)
|