Skip to content

Commit aefcb1e

Browse files
author
chenbo
committed
Unify harness decision arbitration
1 parent 5de9d76 commit aefcb1e

7 files changed

Lines changed: 452 additions & 99 deletions

File tree

docs/concepts/loop-engineering.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,9 @@ Each loop writes a durable iteration directory:
183183
decision.json
184184
```
185185

186-
`iteration.json` is the stable directory index. `executor.result.json` records the executor command, exit code, hashes, changed files, and normalized event summary. `trace.json` is a schema wrapper around the normalized execution trace and trusted evidence summary. `guard.findings.json` normalizes policy, hallucination, and regression findings into one `GuardFinding` schema. `guard.gates.json` turns Guard findings into blocking conditions and required actions. `decision.json` records the selected action with priority, confidence, blocking status, input signals, and convergence state.
186+
`iteration.json` is the stable directory index. `executor.result.json` records the executor command, exit code, hashes, changed files, and normalized event summary. `trace.json` is a schema wrapper around the normalized execution trace and trusted evidence summary. `guard.findings.json` normalizes policy, hallucination, and regression findings into one `GuardFinding` schema. `guard.gates.json` turns Guard findings into blocking conditions and required actions. `decision.json` records the selected action with priority, confidence, blocking status, input signals, convergence state, and decision arbitration details.
187+
188+
Decision arbitration converts executor failures, forbidden policy findings, every blocking Guard Gate, blocking Loop decisions, missing required policy evidence, and risk signals into normalized candidates. Candidates are sorted by the shared action priority (`rollback`, `block`, `repack`, `repair`, `run-tests`, `human-review`, `finalize`), then confidence and stable candidate ID. The highest-priority candidate supplies the action; all remaining candidates stay visible as supporting blockers and contribute deduplicated commands and artifacts. Reordering Guard Gates therefore cannot change the selected decision.
187189

188190
`executor.events.jsonl` stores normalized `AgentEvent` records. OpenCode currently supports `opencode run --format json` stdout, optional transcript files, and generic stdout/stderr fallback; later executor adapters can produce the same event model for MiMoCode, Codex, Claude Code, and Cursor.
189191

docs/developer/guard-gate-schema.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,10 @@ interface GuardGate {
3737
}
3838
```
3939

40-
Gates feed the orchestrator decision router, which writes a decision report such as `finalize`, `repair`, `repack`, `block`, `rollback`, or `require-human-review`.
40+
Gates, executor failures, policy findings, Loop decisions, and risk signals are normalized into `HarnessDecisionCandidate` records before arbitration. Candidate ordering is deterministic and uses the shared action priority:
41+
42+
```txt
43+
rollback > block > repack > repair > run-tests > human-review > finalize
44+
```
45+
46+
The highest-priority candidate becomes the selected action. Remaining candidates are retained as supporting candidates; their reasons, required commands, and artifacts are merged into the final decision with stable deduplication. `decision.json` and the orchestrator Markdown report record the selected candidate, selected priority, and supporting candidates, so the result does not depend on Guard Gate array order.

src/harness/control-plane/decision-engine.ts

Lines changed: 209 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { GuardGateAction, GuardGateReport } from "../../outputs/guard-gates.js";
2-
import type { ArtifactRef, HarnessDecision, HarnessDecisionAction } from "../types.js";
2+
import type { ArtifactRef, HarnessDecision, HarnessDecisionAction, HarnessDecisionCandidate } from "../types.js";
33
import { createHarnessDecision } from "../types.js";
44
import type { LoopControllerReport } from "./loop-controller.js";
55
import type { PolicyEngineReport } from "../verification-plane/policy-engine.js";
@@ -28,116 +28,148 @@ export interface DecisionEngineInput {
2828
}
2929

3030
export function decideHarnessAction(input: DecisionEngineInput): HarnessDecision {
31+
return arbitrateDecisionCandidates(collectDecisionCandidates(input));
32+
}
33+
34+
export function collectDecisionCandidates(input: DecisionEngineInput): HarnessDecisionCandidate[] {
35+
const artifacts = input.artifacts ?? [];
36+
const candidates: HarnessDecisionCandidate[] = [];
37+
3138
if (input.executorResult.exitCode !== 0) {
32-
return decision({
33-
action: "block",
34-
blocking: true,
35-
confidence: 0.94,
36-
reasons: [
37-
"The selected executor failed before the harness could trust the result.",
38-
`executor exit code: ${input.executorResult.exitCode ?? "unknown"}`,
39-
input.executorResult.stderr ? "executor stderr captured" : "executor stderr empty"
40-
],
41-
artifacts: input.artifacts ?? []
42-
});
39+
candidates.push(
40+
candidate({
41+
id: "executor.failure",
42+
source: "executor",
43+
action: "block",
44+
confidence: 0.94,
45+
reasons: [
46+
"The selected executor failed before the harness could trust the result.",
47+
`executor exit code: ${input.executorResult.exitCode ?? "unknown"}`,
48+
input.executorResult.stderr ? "executor stderr captured" : "executor stderr empty"
49+
],
50+
artifacts
51+
})
52+
);
4353
}
4454

4555
if (input.policy.summary.forbidden > 0) {
46-
return decision({
47-
action: input.checkpointMode === "git-worktree" ? "rollback" : "block",
48-
blocking: true,
49-
confidence: 0.96,
50-
reasons: [
51-
"Forbidden policy findings were detected in the diff.",
52-
`forbidden findings: ${input.policy.summary.forbidden}`,
53-
`policy fail-on: ${input.policy.failOn}`
54-
],
55-
artifacts: input.artifacts ?? []
56-
});
56+
candidates.push(
57+
candidate({
58+
id: "policy.forbidden",
59+
source: "policy",
60+
action: input.checkpointMode === "git-worktree" ? "rollback" : "block",
61+
confidence: 0.96,
62+
reasons: [
63+
"Forbidden policy findings were detected in the diff.",
64+
`forbidden findings: ${input.policy.summary.forbidden}`,
65+
`policy fail-on: ${input.policy.failOn}`
66+
],
67+
artifacts
68+
})
69+
);
5770
}
5871

59-
const blockingGate = input.guardGates.gates.find((gate) => gate.status === "blocked");
60-
if (blockingGate) {
61-
return decision({
62-
action: decisionForGate(blockingGate.action, input.checkpointMode),
63-
blocking: true,
64-
confidence: 0.93,
65-
reasons: [
66-
`${blockingGate.guard} guard blocked: ${blockingGate.condition}.`,
67-
`guard: ${blockingGate.guard}`,
68-
`condition: ${blockingGate.condition}`,
69-
...blockingGate.evidence.slice(0, 5)
70-
],
71-
requiredCommands: commandForGate(blockingGate.action),
72-
artifacts: input.artifacts ?? []
73-
});
72+
for (const gate of input.guardGates.gates.filter((item) => item.status === "blocked")) {
73+
candidates.push(
74+
candidate({
75+
id: `guard-gate.${gate.id}`,
76+
source: "guard-gate",
77+
action: decisionForGate(gate.action, input.checkpointMode),
78+
confidence: 0.93,
79+
reasons: [`${gate.guard} guard blocked: ${gate.condition}.`, `guard: ${gate.guard}`, `condition: ${gate.condition}`, ...gate.evidence.slice(0, 5)],
80+
requiredCommands: commandForGate(gate.action),
81+
artifacts
82+
})
83+
);
7484
}
7585

76-
const needsContext = input.loop.decisions.find((item) => item.action === "rebuild-context" || item.action === "replan" || item.action === "expand-context");
77-
if (needsContext) {
78-
return decision({
79-
action: "repack",
80-
blocking: true,
81-
confidence: needsContext.confidence,
82-
reasons: ["The next loop needs refreshed or expanded context before continuing.", needsContext.reason, ...needsContext.signals],
83-
requiredCommands: needsContext.command ? [needsContext.command] : [],
84-
artifacts: input.artifacts ?? []
85-
});
86+
for (const loopDecision of input.loop.decisions.filter((item) => item.blocking)) {
87+
const action = actionForLoopDecision(loopDecision.action);
88+
if (!action) continue;
89+
candidates.push(
90+
candidate({
91+
id: `loop.${loopDecision.action}`,
92+
source: "loop",
93+
action,
94+
confidence: loopDecision.confidence,
95+
reasons: [loopReasonPrefix(action), loopDecision.reason, ...loopDecision.signals],
96+
requiredCommands: loopDecision.command ? [loopDecision.command] : [],
97+
artifacts
98+
})
99+
);
86100
}
87101

88-
const needsRepair = input.loop.decisions.find((item) => item.action === "repair-contracts" || item.action === "add-or-update-tests");
89-
const needsTests = input.loop.decisions.find((item) => item.action === "run-tests");
90-
if (needsTests) {
91-
return decision({
92-
action: "run-tests",
93-
blocking: true,
94-
confidence: needsTests.confidence,
95-
reasons: [needsTests.reason, ...needsTests.signals],
96-
requiredCommands: needsTests.command ? [needsTests.command] : [],
97-
artifacts: input.artifacts ?? []
98-
});
102+
if (input.policy.summary.requiredMissing > 0) {
103+
candidates.push(
104+
candidate({
105+
id: "policy.required-missing",
106+
source: "policy",
107+
action: "repair",
108+
confidence: 0.88,
109+
reasons: ["Required policy evidence is missing.", `required missing: ${input.policy.summary.requiredMissing}`],
110+
requiredCommands: requiredCommandsFromPolicy(input.policy),
111+
artifacts
112+
})
113+
);
99114
}
100115

101-
if (needsRepair || input.policy.summary.requiredMissing > 0) {
102-
return decision({
103-
action: "repair",
104-
blocking: true,
105-
confidence: needsRepair?.confidence ?? 0.88,
106-
reasons: [
107-
needsRepair?.reason ?? "Required policy evidence is missing.",
108-
...(needsRepair?.signals ?? [`required missing: ${input.policy.summary.requiredMissing}`])
109-
],
110-
requiredCommands: needsRepair?.command ? [needsRepair.command] : requiredCommandsFromPolicy(input.policy),
111-
artifacts: input.artifacts ?? []
112-
});
116+
if (input.loop.risk === "High" || input.policy.summary.risks > 0) {
117+
candidates.push(
118+
candidate({
119+
id: "risk.human-review",
120+
source: "risk",
121+
action: "human-review",
122+
confidence: 0.82,
123+
reasons: [
124+
"The diff has high-impact or risk policy signals even though hard gates passed.",
125+
`impact risk: ${input.loop.risk}`,
126+
`policy risks: ${input.policy.summary.risks}`
127+
],
128+
artifacts
129+
})
130+
);
113131
}
114132

115-
if (input.loop.risk === "High" || input.policy.summary.risks > 0) {
116-
return decision({
117-
action: "human-review",
118-
blocking: true,
119-
confidence: 0.82,
120-
reasons: [
121-
"The diff has high-impact or risk policy signals even though hard gates passed.",
122-
`impact risk: ${input.loop.risk}`,
123-
`policy risks: ${input.policy.summary.risks}`
124-
],
125-
requiredCommands: [],
126-
artifacts: input.artifacts ?? []
127-
});
133+
if (!candidates.length) {
134+
candidates.push(
135+
candidate({
136+
id: "fallback.finalize",
137+
source: "fallback",
138+
action: "finalize",
139+
blocking: false,
140+
confidence: input.changedFiles.length ? 0.8 : 0.72,
141+
reasons: [
142+
"No blocking policy, context, impact, or verification signals remain.",
143+
`changed files: ${input.changedFiles.length}`,
144+
`loop status: ${input.loop.status}`,
145+
"policy: passed"
146+
],
147+
artifacts
148+
})
149+
);
128150
}
129151

152+
return candidates.map(normalizeCandidate).sort(compareCandidates);
153+
}
154+
155+
export function arbitrateDecisionCandidates(candidates: HarnessDecisionCandidate[]): HarnessDecision {
156+
if (!candidates.length) throw new Error("Decision arbitration requires at least one candidate.");
157+
const sorted = candidates.map(normalizeCandidate).sort(compareCandidates);
158+
const selected = sorted[0];
159+
if (!selected) throw new Error("Decision arbitration did not select a candidate.");
160+
const supporting = sorted.slice(1);
130161
return decision({
131-
action: "finalize",
132-
blocking: false,
133-
confidence: input.changedFiles.length ? 0.8 : 0.72,
134-
reasons: [
135-
"No blocking policy, context, impact, or verification signals remain.",
136-
`changed files: ${input.changedFiles.length}`,
137-
`loop status: ${input.loop.status}`,
138-
"policy: passed"
139-
],
140-
artifacts: input.artifacts ?? []
162+
action: selected.action,
163+
blocking: selected.blocking,
164+
confidence: selected.confidence,
165+
reasons: [...selected.reasons, ...supporting.map(supportingReason)],
166+
requiredCommands: sorted.flatMap((item) => item.requiredCommands),
167+
artifacts: sorted.flatMap((item) => item.artifacts),
168+
arbitration: {
169+
selectedCandidate: selected,
170+
selectedPriority: selected.priority,
171+
supportingCandidates: supporting
172+
}
141173
});
142174
}
143175

@@ -153,7 +185,8 @@ export function maxLoopHarnessDecision(maxLoops: number, lastDecision: HarnessDe
153185
...lastDecision.reasons
154186
],
155187
requiredCommands: lastDecision.requiredCommands,
156-
artifacts: lastDecision.artifacts
188+
artifacts: lastDecision.artifacts,
189+
arbitration: lastDecision.arbitration
157190
});
158191
}
159192

@@ -170,7 +203,8 @@ export function noProgressHarnessDecision(fingerprint: string, lastDecision: Har
170203
...lastDecision.reasons
171204
],
172205
requiredCommands: lastDecision.requiredCommands,
173-
artifacts: lastDecision.artifacts
206+
artifacts: lastDecision.artifacts,
207+
arbitration: lastDecision.arbitration
174208
});
175209
}
176210

@@ -183,7 +217,86 @@ function decision(
183217
confidence: input.confidence,
184218
reasons: input.reasons,
185219
requiredCommands: input.requiredCommands ?? [],
220+
artifacts: input.artifacts ?? [],
221+
...(input.arbitration ? { arbitration: input.arbitration } : {})
222+
});
223+
}
224+
225+
function candidate(
226+
input: Omit<HarnessDecisionCandidate, "priority" | "blocking" | "requiredCommands" | "artifacts"> & {
227+
blocking?: boolean;
228+
requiredCommands?: string[];
229+
artifacts?: ArtifactRef[];
230+
}
231+
): HarnessDecisionCandidate {
232+
return {
233+
id: input.id,
234+
source: input.source,
235+
action: input.action,
236+
priority: HARNESS_DECISION_PRIORITY[input.action],
237+
blocking: input.blocking ?? input.action !== "finalize",
238+
confidence: input.confidence,
239+
reasons: input.reasons,
240+
requiredCommands: input.requiredCommands ?? [],
186241
artifacts: input.artifacts ?? []
242+
};
243+
}
244+
245+
function normalizeCandidate(input: HarnessDecisionCandidate): HarnessDecisionCandidate {
246+
return {
247+
...input,
248+
priority: HARNESS_DECISION_PRIORITY[input.action],
249+
confidence: Math.round(Math.max(0, Math.min(1, input.confidence)) * 100) / 100,
250+
reasons: dedupeStrings(input.reasons),
251+
requiredCommands: dedupeStrings(input.requiredCommands).sort((a, b) => a.localeCompare(b)),
252+
artifacts: dedupeArtifacts(input.artifacts)
253+
};
254+
}
255+
256+
function compareCandidates(a: HarnessDecisionCandidate, b: HarnessDecisionCandidate): number {
257+
return (
258+
b.priority - a.priority ||
259+
Number(b.blocking) - Number(a.blocking) ||
260+
b.confidence - a.confidence ||
261+
a.id.localeCompare(b.id) ||
262+
candidateSignature(a).localeCompare(candidateSignature(b))
263+
);
264+
}
265+
266+
function supportingReason(candidate: HarnessDecisionCandidate): string {
267+
return `Supporting blocker [${candidate.id}] action=${candidate.action} priority=${candidate.priority}: ${candidate.reasons[0] ?? "no reason provided"}`;
268+
}
269+
270+
function actionForLoopDecision(action: LoopControllerReport["decisions"][number]["action"]): HarnessDecisionAction | null {
271+
if (action === "rebuild-context" || action === "replan" || action === "expand-context") return "repack";
272+
if (action === "repair-contracts" || action === "add-or-update-tests") return "repair";
273+
if (action === "run-tests") return "run-tests";
274+
return null;
275+
}
276+
277+
function loopReasonPrefix(action: HarnessDecisionAction): string {
278+
if (action === "repack") return "The next loop needs refreshed or expanded context before continuing.";
279+
if (action === "repair") return "The next loop must repair code, contracts, or tests before continuing.";
280+
return "The next loop must run required verification commands before continuing.";
281+
}
282+
283+
function dedupeStrings(items: string[]): string[] {
284+
return [...new Set(items.filter(Boolean))];
285+
}
286+
287+
function dedupeArtifacts(items: ArtifactRef[]): ArtifactRef[] {
288+
const byKey = new Map<string, ArtifactRef>();
289+
for (const item of items) byKey.set(`${item.kind ?? "other"}:${item.path}`, item);
290+
return [...byKey.values()].sort((a, b) => `${a.kind ?? "other"}:${a.path}`.localeCompare(`${b.kind ?? "other"}:${b.path}`));
291+
}
292+
293+
function candidateSignature(candidate: HarnessDecisionCandidate): string {
294+
return JSON.stringify({
295+
action: candidate.action,
296+
artifacts: candidate.artifacts,
297+
reasons: candidate.reasons,
298+
requiredCommands: candidate.requiredCommands,
299+
source: candidate.source
187300
});
188301
}
189302

0 commit comments

Comments
 (0)