fix(missions): merge phase config instead of replacing it; conditions are per-phase

Two defects in the goal-condition work, both found while tracing a
research->coding mission end to end.

1. Setting a condition silently dropped the recipe's phase config.

phases_for_create treated a caller-supplied config as a wholesale replacement.
The wizard sends {done_when, max_iterations} as the entire config, so every
other recipe key was discarded. Harmless for research_and_code, where nothing
reads `produces` or `default_topology` -- but a conditioned security_hardening
phase lost its `tools` list, which security_scan.rs DOES read, so the scan
would run with nothing configured and report clean. A green security scan that
scanned nothing is the worst possible failure mode for that feature.

The recipe is now the base and the caller's keys override individually.
Shallow merge is deliberate: phase config is a flat settings bag, and a caller
sending `tools: [...]` means to replace the list, not union it. A non-object
override still replaces outright rather than silently picking a side.

2. One condition was applied to every phase.

The wizard had a single mission-level "Done when" that got copied onto all
phases. For research->coding that is actively wrong: "cargo test reported 0
failures" cannot hold while the research phase is running, so research would
burn all its passes and give up before coding ever started. Conditions are now
per phase, keyed by order_idx, with a per-kind placeholder that demonstrates
the rule that actually governs whether a condition works -- it must be
provable from what the agents wrote, because the checker cannot run commands.

Phases with no condition are sent unchanged, so they keep the recipe's
settings and finish in one pass exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-30 13:36:50 -07:00
co-authored by Claude Opus 5
parent fe57ce4ed1
commit 5cccd5f58b
2 changed files with 222 additions and 80 deletions
+79 -16
View File
@@ -148,14 +148,20 @@ pub async fn list(
)) ))
} }
/// Resolve the phase list for a new mission, filling in each phase's `config` /// Resolve the phase list for a new mission, merging each phase's `config` over
/// from the workflow recipe when the caller didn't supply one. /// the workflow recipe's.
/// ///
/// `mission_phases.config` is where per-phase settings live (`done_when`, /// `mission_phases.config` is where per-phase settings live (`done_when`,
/// `max_iterations`, `harness`, `tools`). The client's phase list historically /// `max_iterations`, `harness`, `tools`). The client's phase list historically
/// carried only `{kind, order_idx}`, so every wizard-created mission landed /// carried only `{kind, order_idx}`, so every wizard-created mission landed
/// with a null config and every recipe setting was silently inert. Callers can /// with a null config and every recipe setting was silently inert.
/// still override by sending a non-null config per phase. ///
/// The recipe is the **base** and the caller's keys override individually —
/// not wholesale. A caller that sends `{done_when: "..."}` is adding a
/// completion condition, not declaring that the phase has no other settings.
/// Replacing here meant a conditioned `security_hardening` phase lost its
/// `tools` list, which `security_scan.rs` reads, so the scan would silently
/// run with no tools configured.
fn phases_for_create( fn phases_for_create(
recipe: Option<&crate::workflow_registry::WorkflowRecipe>, recipe: Option<&crate::workflow_registry::WorkflowRecipe>,
requested: Vec<PhaseSpec>, requested: Vec<PhaseSpec>,
@@ -176,14 +182,12 @@ fn phases_for_create(
.unwrap_or_default(); .unwrap_or_default();
} }
// Phases requested: honour them, but backfill config from the matching // Phases requested: honour the shape, and merge the caller's config over
// recipe phase (by kind + order_idx, falling back to kind alone) so a // the matching recipe phase's (matched by kind + order_idx, then kind).
// client that only knows the shape still gets the recipe's settings.
requested requested
.into_iter() .into_iter()
.map(|p| { .map(|p| {
let config = if p.config.is_null() { let base = recipe
recipe
.and_then(|r| { .and_then(|r| {
r.phases r.phases
.iter() .iter()
@@ -191,19 +195,38 @@ fn phases_for_create(
.or_else(|| r.phases.iter().find(|rp| rp.kind == p.kind)) .or_else(|| r.phases.iter().find(|rp| rp.kind == p.kind))
}) })
.map(|rp| rp.config.clone()) .map(|rp| rp.config.clone())
.unwrap_or(Value::Null) .unwrap_or(Value::Null);
} else {
p.config
};
NewMissionPhase { NewMissionPhase {
kind: p.kind, kind: p.kind,
order_idx: p.order_idx, order_idx: p.order_idx,
config, config: merge_config(base, p.config),
} }
}) })
.collect() .collect()
} }
/// Shallow-merge `over` onto `base`, key by key.
///
/// Shallow is deliberate: phase config is a flat settings bag, and a caller
/// that sends `tools: [...]` means to replace the list, not union it.
fn merge_config(base: Value, over: Value) -> Value {
match (base, over) {
(Value::Object(mut b), Value::Object(o)) => {
for (k, v) in o {
b.insert(k, v);
}
Value::Object(b)
}
// Nothing to merge onto, or nothing to merge in.
(base, Value::Null) => base,
(Value::Null, over) => over,
// A non-object override replaces outright — there is no sane merge of
// e.g. an array onto an object, and silently picking one would hide
// the caller's mistake.
(_, over) => over,
}
}
/// `GET /api/workflows` — the workflow recipe catalog. /// `GET /api/workflows` — the workflow recipe catalog.
/// ///
/// Serves `templates/workflows/*.toml` so the client can drop its inline /// Serves `templates/workflows/*.toml` so the client can drop its inline
@@ -1067,9 +1090,9 @@ mod tests {
assert_eq!(phases[1].kind, "coding"); assert_eq!(phases[1].kind, "coding");
} }
/// An explicit config always wins over the recipe's. /// An explicit key wins over the recipe's value for that key.
#[test] #[test]
fn explicit_phase_config_is_not_overwritten() { fn explicit_phase_config_overrides_the_recipe_key() {
let requested = vec![PhaseSpec { let requested = vec![PhaseSpec {
kind: "coding".into(), kind: "coding".into(),
order_idx: 1, order_idx: 1,
@@ -1082,6 +1105,46 @@ mod tests {
); );
} }
/// ...but overriding one key must NOT drop the rest of the recipe's
/// config. Sending `{done_when}` means "also apply this condition", not
/// "this phase has no other settings".
///
/// The case that motivated this: a `security_hardening` phase with a
/// completion condition lost its `tools` list, which `security_scan.rs`
/// reads — so the scan ran with nothing configured and reported clean.
#[test]
fn adding_a_condition_preserves_the_rest_of_the_recipe_config() {
let requested = vec![PhaseSpec {
kind: "coding".into(),
order_idx: 1,
config: serde_json::json!({"done_when": "tests pass", "max_iterations": 3}),
}];
let phases = phases_for_create(Some(&test_recipe()), requested);
let c = &phases[0].config;
assert_eq!(
c.get("done_when").and_then(|v| v.as_str()),
Some("tests pass"),
"the caller's condition must land"
);
assert_eq!(
c.get("commit_policy").and_then(|v| v.as_str()),
Some("on_green_tests"),
"recipe keys the caller didn't mention must survive"
);
assert_eq!(
c.get("loop").and_then(|v| v.as_str()),
Some("until_no_more_int_items")
);
}
#[test]
fn merge_config_handles_null_on_either_side() {
let base = serde_json::json!({"a": 1});
assert_eq!(merge_config(base.clone(), Value::Null), base);
assert_eq!(merge_config(Value::Null, base.clone()), base);
assert_eq!(merge_config(Value::Null, Value::Null), Value::Null);
}
/// An unknown template must not fabricate phases or panic. /// An unknown template must not fabricate phases or panic.
#[test] #[test]
fn unknown_template_yields_no_phases() { fn unknown_template_yields_no_phases() {
@@ -20,6 +20,7 @@ import {
listWorkflows, listWorkflows,
recipeToPreset, recipeToPreset,
TEMPLATE_PRESETS, TEMPLATE_PRESETS,
type PhaseKind,
type Schedule, type Schedule,
type TemplateKind, type TemplateKind,
type TemplatePreset, type TemplatePreset,
@@ -33,6 +34,27 @@ import { RepoPicker, type PickedRepo } from "./RepoPicker";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const PHASE_LABEL: Record<PhaseKind, string> = {
research: "Research",
coding: "Coding",
benchmark: "Benchmark",
security_scan: "Security scan",
};
/// Per-phase examples, written to demonstrate the rule that matters: the
/// condition has to be provable from what the agents themselves wrote, since
/// the checker cannot run commands or read the filesystem.
const PHASE_CONDITION_PLACEHOLDER: Record<PhaseKind, string> = {
research:
"e.g. a Markdown brief was written under /mission/repo/research and it lists at least one INT-XX item",
coding:
"e.g. every INT-XX item has a COMPLETED marker and the test run reported 0 failures",
benchmark:
"e.g. both a baseline and an after measurement were reported, with numbers for each",
security_scan:
"e.g. every finding was triaged, each with either a patch or a stated reason for accepting it",
};
type Step = 1 | 2 | 3 | 4 | 5; type Step = 1 | 2 | 3 | 4 | 5;
export function MissionWizard({ export function MissionWizard({
@@ -60,10 +82,28 @@ export function MissionWizard({
} }
})(); })();
}, []); }, []);
// Completion condition. Empty = the phase completes when its runs finish, // Completion conditions, keyed by phase order_idx.
//
// Per phase, not per mission: a research→coding workflow needs different
// conditions at each stage, and applying one to both is actively wrong —
// "cargo test reported 0 failures" can never hold while the research phase
// is running, so research would burn every pass before giving up.
//
// An absent or blank entry means that phase completes when its runs finish,
// which is the behaviour missions had before conditions existed. // which is the behaviour missions had before conditions existed.
const [doneWhen, setDoneWhen] = useState(""); const [conditions, setConditions] = useState<
const [maxIterations, setMaxIterations] = useState(3); Record<number, { doneWhen: string; maxIterations: number }>
>({});
const conditionFor = (orderIdx: number) =>
conditions[orderIdx] ?? { doneWhen: "", maxIterations: 3 };
const setCondition = (
orderIdx: number,
patch: Partial<{ doneWhen: string; maxIterations: number }>,
) =>
setConditions((c) => ({
...c,
[orderIdx]: { ...conditionFor(orderIdx), ...patch },
}));
const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot"); const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot");
const [cron, setCron] = useState("0 */6 * * *"); const [cron, setCron] = useState("0 */6 * * *");
const [runtimeKind, setRuntimeKind] = useState<"zeroclaw" | "local_herdr">("zeroclaw"); const [runtimeKind, setRuntimeKind] = useState<"zeroclaw" | "local_herdr">("zeroclaw");
@@ -161,18 +201,22 @@ export function MissionWizard({
repo_id: repo?.repo_id, repo_id: repo?.repo_id,
schedule, schedule,
description: description.trim() || undefined, description: description.trim() || undefined,
// The condition rides in each phase's config; the server promotes it // Conditions ride in each phase's config; the server merges them over
// into the done_when / max_iterations columns and clamps the cap. // the recipe's config, promotes done_when / max_iterations into
phases: doneWhen.trim() // columns, and clamps the cap. Phases without a condition are sent
? preset.phases.map((p) => ({ // unchanged so they keep the recipe's settings and finish in one pass.
phases: preset.phases.map((p) => {
const c = conditionFor(p.order_idx);
if (!c.doneWhen.trim()) return p;
return {
...p, ...p,
config: { config: {
...(p.config ?? {}), ...(p.config ?? {}),
done_when: doneWhen.trim(), done_when: c.doneWhen.trim(),
max_iterations: maxIterations, max_iterations: c.maxIterations,
}, },
})) };
: preset.phases, }),
runtime_kind: runtimeKind, runtime_kind: runtimeKind,
target_node_id: target_node_id:
runtimeKind === "local_herdr" ? targetNodeId : undefined, runtimeKind === "local_herdr" ? targetNodeId : undefined,
@@ -355,55 +399,90 @@ export function MissionWizard({
placeholder="What should the mission accomplish? The template's agents will use this as their driving prompt." placeholder="What should the mission accomplish? The template's agents will use this as their driving prompt."
style={{ ...fieldStyle, resize: "vertical", fontFamily: "inherit" }} style={{ ...fieldStyle, resize: "vertical", fontFamily: "inherit" }}
/> />
<label style={labelStyle} htmlFor="mission-done-when"> <span style={labelStyle}>
Done when <span style={{ color: "#6a6a72" }}>(optional)</span> Completion conditions{" "}
</label> <span style={{ color: "#6a6a72" }}>(optional)</span>
</span>
<p style={hintStyle}> <p style={hintStyle}>
A completion condition. After each pass a model checks it and, Set per phase. After each pass a model checks the condition and,
if it doesn&apos;t hold, the phase runs again with the reason as if it doesn&apos;t hold, that phase runs again with the reason as
guidance. Leave empty to finish after one pass. guidance. Leave a phase empty to finish it in one pass.
</p> </p>
<p style={{ ...hintStyle, color: "#e8b465" }}> <p style={{ ...hintStyle, color: "#e8b465" }}>
The checker can&apos;t run commands — it only reads what the The checker can&apos;t run commands — it only reads what the
agents wrote. Phrase the condition so their own output proves agents wrote. Phrase each condition so their own output proves
it: &ldquo;cargo test was run and reported 0 failures&rdquo; it: &ldquo;cargo test was run and reported 0 failures&rdquo;
works; &ldquo;the code is well factored&rdquo; does not. works; &ldquo;the code is well factored&rdquo; does not.
</p> </p>
{preset.phases.map((p) => {
const c = conditionFor(p.order_idx);
return (
<div
key={p.order_idx}
style={{
display: "flex",
flexDirection: "column",
gap: 6,
padding: "10px 12px",
borderRadius: 10,
border: "1px solid #1c1c22",
background: "#0d0d10",
}}
>
<label
style={{ ...labelStyle, marginBottom: 0 }}
htmlFor={`done-when-${p.order_idx}`}
>
{PHASE_LABEL[p.kind] ?? p.kind}
</label>
<textarea <textarea
id="mission-done-when" id={`done-when-${p.order_idx}`}
value={doneWhen} value={c.doneWhen}
onChange={(e) => setDoneWhen(e.target.value)} onChange={(e) =>
setCondition(p.order_idx, { doneWhen: e.target.value })
}
rows={2} rows={2}
placeholder="e.g. a Markdown brief exists under /mission/repo/research and every INT-XX item is marked COMPLETED" placeholder={PHASE_CONDITION_PLACEHOLDER[p.kind] ?? ""}
style={{ ...fieldStyle, resize: "vertical", fontFamily: "inherit" }} style={{
...fieldStyle,
resize: "vertical",
fontFamily: "inherit",
}}
/> />
{doneWhen.trim() && ( {c.doneWhen.trim() && (
<> <div
<label style={labelStyle} htmlFor="mission-max-iterations"> style={{ display: "flex", alignItems: "center", gap: 8 }}
Maximum passes >
<label
style={{ ...hintStyle, margin: 0 }}
htmlFor={`max-iter-${p.order_idx}`}
>
Max passes
</label> </label>
<p style={hintStyle}>
Each pass is a full team run, so this bounds the cost if the
condition is never met.
</p>
<input <input
id="mission-max-iterations" id={`max-iter-${p.order_idx}`}
type="number" type="number"
min={1} min={1}
max={20} max={20}
value={maxIterations} value={c.maxIterations}
onChange={(e) => onChange={(e) =>
setMaxIterations( setCondition(p.order_idx, {
Math.max( maxIterations: Math.max(
1, 1,
Math.min(20, Number(e.target.value) || 1), Math.min(20, Number(e.target.value) || 1),
), ),
) })
} }
style={{ ...fieldStyle, width: 100 }} style={{ ...fieldStyle, width: 80 }}
/> />
</> <span style={{ ...hintStyle, margin: 0 }}>
each pass is a full team run
</span>
</div>
)} )}
</div>
);
})}
{preset.requiresRepo && ( {preset.requiresRepo && (
<> <>
<span style={labelStyle}>Repository</span> <span style={labelStyle}>Repository</span>