fix(TASK-017): bound run_checks runtime and fail-close its node-level death
Adversary conformance-review fixes for the TASK-017 exec-evidence stage:
- run_checks.py: total-runtime deadline (3300s = node timeout minus margin);
each command gets min(900s, remaining budget), commands past the deadline
are recorded as {cmd, skipped: deadline} entries so in-script handling
stays authoritative instead of the node timeout killing the script from
outside. POSIX hardening: per-command process group (start_new_session)
+ killpg on timeout so hung grandchildren cannot linger; non-POSIX
limitation documented in the docstring.
- graph.yaml: run_checks gains fallback: pipeline_fault; check_criterion
treats SKIPPED records as at-best PARTIAL (unproven, never failed).
- pipeline_fault.py: run_checks-stage attribution grounded in real engine
behavior (script-node fallback writes nothing into state, executor.rs
script arm) — nonempty diff_text proves facts completed, so the fault is
the verification runner; emits 'PIPELINE-FAULT: verification runner
killed'. Empty diff_text still attributes to diff resolution.
- verdict.py: renders skipped records as [SKIPPED] instead of a bogus
[FAIL] exit-? line; non-degraded verdict semantics untouched.
- tests: outer-except ENVIRONMENT degradation, deadline timeout+skip (env
seam ADVERSARY_RUN_CHECKS_DEADLINE_SECS), pipeline_fault stage
attribution (parse / facts / run_checks), check_criterion prompt cites
{{exec_results}}, verdict SKIPPED rendering; run_adversary_script_env
helper for env-carrying invocations.
- cargo fmt violations in 3d7dea9 test code fixed (fmt --check exit 0).
This commit is contained in:
5 files changed
+303
-51
No files matched your search
@@ -15,8 +15,9 @@ description: |
|
|||||||
DIVERGES (fail-closed) naming the missing spec. Caller-declared verification
|
DIVERGES (fail-closed) naming the missing spec. Caller-declared verification
|
||||||
commands are executed ONCE by a deterministic stage and recorded — criterion
|
commands are executed ONCE by a deterministic stage and recorded — criterion
|
||||||
branches cite the record and never run anything themselves. Any pipeline
|
branches cite the record and never run anything themselves. Any pipeline
|
||||||
fault (parse, diff resolution, or holistic failure) lands a PIPELINE-FAULT
|
fault (parse, diff resolution, verification runner, or holistic failure)
|
||||||
marker and fails closed into DIVERGES: degraded is never passing.
|
lands a PIPELINE-FAULT marker and fails closed into DIVERGES: degraded is
|
||||||
|
never passing.
|
||||||
|
|
||||||
version: '1.0'
|
version: '1.0'
|
||||||
|
|
||||||
@@ -140,9 +141,10 @@ nodes:
|
|||||||
run_checks:
|
run_checks:
|
||||||
id: run_checks
|
id: run_checks
|
||||||
type: script
|
type: script
|
||||||
description: Deterministic execution evidence — runs ONLY the caller-declared verification commands in the project dir and records exec_results; criterion branches cite the record and never execute anything. Undeclared commands degrade to a "none declared" marker, runner errors to an ENVIRONMENT marker; never fails the node.
|
description: Deterministic execution evidence — runs ONLY the caller-declared verification commands in the project dir and records exec_results; criterion branches cite the record and never execute anything. Undeclared commands degrade to a "none declared" marker, runner errors to an ENVIRONMENT marker; a 3300s total deadline keeps the script under the node timeout (commands past it are recorded as skipped), and if the node is killed anyway the fallback records a PIPELINE-FAULT.
|
||||||
script: scripts/run_checks.py
|
script: scripts/run_checks.py
|
||||||
timeout: 3600
|
timeout: 3600
|
||||||
|
fallback: pipeline_fault
|
||||||
next: map_criteria
|
next: map_criteria
|
||||||
|
|
||||||
map_criteria:
|
map_criteria:
|
||||||
@@ -198,6 +200,9 @@ nodes:
|
|||||||
- "none declared" or an ENVIRONMENT marker ⇒ at best PARTIAL, with an
|
- "none declared" or an ENVIRONMENT marker ⇒ at best PARTIAL, with an
|
||||||
ENVIRONMENT note in the complaint naming the exact command the caller
|
ENVIRONMENT note in the complaint naming the exact command the caller
|
||||||
should declare via verification_commands (e.g. "cargo test --all").
|
should declare via verification_commands (e.g. "cargo test --all").
|
||||||
|
- a SKIPPED record (the runner's total deadline expired before that
|
||||||
|
command ran) ⇒ at best PARTIAL for the criteria it covers, same
|
||||||
|
treatment as an ENVIRONMENT marker: unproven, never failed.
|
||||||
|
|
||||||
Output format — respond with ONLY this JSON object, no prose around it:
|
Output format — respond with ONLY this JSON object, no prose around it:
|
||||||
{"id": "<the criterion's id, echoed>",
|
{"id": "<the criterion's id, echoed>",
|
||||||
@@ -325,7 +330,7 @@ nodes:
|
|||||||
pipeline_fault:
|
pipeline_fault:
|
||||||
id: pipeline_fault
|
id: pipeline_fault
|
||||||
type: script
|
type: script
|
||||||
description: Fail-closed fault marker (reached only via fallback routes from parse/facts) — records a PIPELINE-FAULT in state and continues to verdict, which turns any fault into DIVERGES. The graph never dies without its sentinel.
|
description: Fail-closed fault marker (reached only via fallback routes from parse/facts/run_checks) — records a PIPELINE-FAULT in state and continues to verdict, which turns any fault into DIVERGES. The graph never dies without its sentinel.
|
||||||
script: scripts/pipeline_fault.py
|
script: scripts/pipeline_fault.py
|
||||||
timeout: 30
|
timeout: 30
|
||||||
next: verdict
|
next: verdict
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Fail-closed fault marker for the adversary graph.
|
"""Fail-closed fault marker for the adversary graph.
|
||||||
|
|
||||||
Reached ONLY via `fallback:` routes (parse, facts). Appends a machine-readable
|
Reached ONLY via `fallback:` routes (parse, facts, run_checks). Appends a
|
||||||
"PIPELINE-FAULT:" marker to `pipeline_faults` and continues to `verdict`,
|
machine-readable "PIPELINE-FAULT:" marker to `pipeline_faults` and continues
|
||||||
which turns any recorded fault into DIVERGES — the graph never dies without
|
to `verdict`, which turns any recorded fault into DIVERGES — the graph never
|
||||||
its sentinel, and a degraded run can never read as CONFORMS.
|
dies without its sentinel, and a degraded run can never read as CONFORMS.
|
||||||
|
|
||||||
Stage attribution: an llm-node failure writes an "LLM node …failed: <chain>"
|
Stage attribution — grounded in engine behavior: a SCRIPT node's fallback
|
||||||
string into `parse_failure` via parse's `state_updates`; anything else means
|
route writes NOTHING into state (src/graph/executor.rs script arm routes to
|
||||||
the deterministic facts (diff resolution) script failed, which leaves no
|
the fallback without recording the error), so the two script stages are
|
||||||
failure text in state by design.
|
discriminated by which stage outputs are present:
|
||||||
|
- parse (llm): its `state_updates` wrote an "LLM node …failed: <chain>"
|
||||||
|
string into `parse_failure` — attribute to parse.
|
||||||
|
- run_checks vs facts (scripts): diff_facts.py unconditionally writes a
|
||||||
|
NONEMPTY `diff_text` (even its internal-failure path writes
|
||||||
|
"(diff unavailable)"), while the graph's initial value is ''. A nonempty
|
||||||
|
`diff_text` proves facts completed, so the fault is the verification
|
||||||
|
runner (run_checks killed at the node level, e.g. by the node timeout);
|
||||||
|
an empty `diff_text` means facts itself died.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -30,11 +38,19 @@ def main():
|
|||||||
faults = [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)]
|
faults = [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)]
|
||||||
parse_failure = state.get("parse_failure")
|
parse_failure = state.get("parse_failure")
|
||||||
detail = parse_failure.strip() if isinstance(parse_failure, str) else ""
|
detail = parse_failure.strip() if isinstance(parse_failure, str) else ""
|
||||||
|
diff_text = state.get("diff_text")
|
||||||
|
facts_completed = isinstance(diff_text, str) and bool(diff_text.strip())
|
||||||
if detail.startswith("LLM node"):
|
if detail.startswith("LLM node"):
|
||||||
detail = detail.replace("\n", " ")
|
detail = detail.replace("\n", " ")
|
||||||
if len(detail) > MAX_DETAIL_CHARS:
|
if len(detail) > MAX_DETAIL_CHARS:
|
||||||
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
|
detail = detail[: MAX_DETAIL_CHARS - 1] + "…"
|
||||||
faults.append(f"PIPELINE-FAULT: parse failed — cannot review: {detail}")
|
faults.append(f"PIPELINE-FAULT: parse failed — cannot review: {detail}")
|
||||||
|
elif facts_completed:
|
||||||
|
faults.append(
|
||||||
|
"PIPELINE-FAULT: verification runner killed — the run_checks stage "
|
||||||
|
"died at the node level before recording exec_results; execution "
|
||||||
|
"criteria are unproven"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
faults.append(
|
faults.append(
|
||||||
"PIPELINE-FAULT: diff resolution failed — changed files and diff text "
|
"PIPELINE-FAULT: diff resolution failed — changed files and diff text "
|
||||||
|
|||||||
@@ -9,14 +9,32 @@ they never execute anything themselves.
|
|||||||
NO auto-detection: undeclared commands mean a "none declared" marker
|
NO auto-detection: undeclared commands mean a "none declared" marker
|
||||||
(fail-visible, not fail-guessed). Any runner error degrades into an
|
(fail-visible, not fail-guessed). Any runner error degrades into an
|
||||||
ENVIRONMENT marker — this script never fails the node.
|
ENVIRONMENT marker — this script never fails the node.
|
||||||
|
|
||||||
|
Budget: commands run sequentially, so TOTAL runtime is bounded by a deadline
|
||||||
|
(TOTAL_DEADLINE_SECS = 3300s, the run_checks node's 3600s timeout minus
|
||||||
|
margin). Without it, a handful of hanging commands (4 x 900s) would blow the
|
||||||
|
NODE timeout and the executor would kill this script from OUTSIDE — the
|
||||||
|
in-script degradation would never fire. Each command gets
|
||||||
|
min(PER_COMMAND_TIMEOUT_SECS, remaining budget); commands the deadline
|
||||||
|
leaves no budget for are recorded as {"cmd", "skipped": "deadline"} entries,
|
||||||
|
so the in-script handling stays authoritative. The env var
|
||||||
|
ADVERSARY_RUN_CHECKS_DEADLINE_SECS overrides the deadline (test seam).
|
||||||
|
|
||||||
|
Process hygiene: commands run through the shell. On POSIX each command gets
|
||||||
|
its own session (start_new_session) and a timeout SIGKILLs the whole process
|
||||||
|
group, so grandchildren of a hung command cannot linger. On non-POSIX
|
||||||
|
platforms only the direct shell child is killed — grandchildren of a
|
||||||
|
timed-out command may survive (documented limitation).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
PER_COMMAND_TIMEOUT_SECS = 900
|
PER_COMMAND_TIMEOUT_SECS = 900
|
||||||
|
TOTAL_DEADLINE_SECS = float(os.environ.get("ADVERSARY_RUN_CHECKS_DEADLINE_SECS") or 3300)
|
||||||
TAIL_LINES = 50
|
TAIL_LINES = 50
|
||||||
MAX_TAIL_CHARS = 8000
|
MAX_TAIL_CHARS = 8000
|
||||||
|
|
||||||
@@ -35,6 +53,45 @@ def tail_of(text):
|
|||||||
return tail
|
return tail
|
||||||
|
|
||||||
|
|
||||||
|
def run_one(cmd, proj, budget):
|
||||||
|
"""Run one command, hard-bounded by `budget` seconds; return its record."""
|
||||||
|
start = time.monotonic()
|
||||||
|
popen_kwargs = {"start_new_session": True} if os.name == "posix" else {}
|
||||||
|
p = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
shell=True,
|
||||||
|
cwd=proj,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
**popen_kwargs,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out_s, err_s = p.communicate(timeout=budget)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if os.name == "posix":
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(p.pid), signal.SIGKILL)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
p.kill()
|
||||||
|
p.communicate() # reap the child and drain the pipes
|
||||||
|
return {
|
||||||
|
"cmd": cmd,
|
||||||
|
"exit": -1,
|
||||||
|
"duration_s": round(time.monotonic() - start, 1),
|
||||||
|
"tail": f"TIMEOUT: no exit within {budget:.0f}s — recorded as a failing run",
|
||||||
|
}
|
||||||
|
combined = (out_s or "") + (("\n" + err_s) if err_s else "")
|
||||||
|
return {
|
||||||
|
"cmd": cmd,
|
||||||
|
"exit": p.returncode,
|
||||||
|
"duration_s": round(time.monotonic() - start, 1),
|
||||||
|
"tail": tail_of(combined),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
state = load_state()
|
state = load_state()
|
||||||
declared = state.get("verification_commands")
|
declared = state.get("verification_commands")
|
||||||
@@ -61,39 +118,14 @@ def main():
|
|||||||
proj = os.path.expanduser(
|
proj = os.path.expanduser(
|
||||||
(state.get("project_dir") or "").strip() or "."
|
(state.get("project_dir") or "").strip() or "."
|
||||||
)
|
)
|
||||||
|
deadline = time.monotonic() + TOTAL_DEADLINE_SECS
|
||||||
results = []
|
results = []
|
||||||
for cmd in cmds:
|
for cmd in cmds:
|
||||||
start = time.monotonic()
|
remaining = deadline - time.monotonic()
|
||||||
try:
|
if remaining <= 0:
|
||||||
r = subprocess.run(
|
results.append({"cmd": cmd, "skipped": "deadline"})
|
||||||
cmd,
|
continue
|
||||||
shell=True,
|
results.append(run_one(cmd, proj, min(PER_COMMAND_TIMEOUT_SECS, remaining)))
|
||||||
cwd=proj,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=PER_COMMAND_TIMEOUT_SECS,
|
|
||||||
)
|
|
||||||
combined = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"cmd": cmd,
|
|
||||||
"exit": r.returncode,
|
|
||||||
"duration_s": round(time.monotonic() - start, 1),
|
|
||||||
"tail": tail_of(combined),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"cmd": cmd,
|
|
||||||
"exit": -1,
|
|
||||||
"duration_s": round(time.monotonic() - start, 1),
|
|
||||||
"tail": (
|
|
||||||
f"TIMEOUT: no exit within {PER_COMMAND_TIMEOUT_SECS}s — "
|
|
||||||
"recorded as a failing run"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
print(json.dumps({"exec_results": results}))
|
print(json.dumps({"exec_results": results}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,15 +73,22 @@ def append_criterion_complaints(lines, verdicts, extra, start):
|
|||||||
def render_exec_results(exec_results):
|
def render_exec_results(exec_results):
|
||||||
"""Lines for the "Verification runs:" report section.
|
"""Lines for the "Verification runs:" report section.
|
||||||
|
|
||||||
exec_results is a list of {cmd, exit, duration_s, tail} records from
|
exec_results is a list of {cmd, exit, duration_s, tail} records (or
|
||||||
run_checks.py, or a string marker ("none declared —…" / "ENVIRONMENT: …"),
|
{cmd, skipped} for commands the runner's total deadline left no budget
|
||||||
or empty when the run never reached the verification stage.
|
for) from run_checks.py, or a string marker ("none declared —…" /
|
||||||
|
"ENVIRONMENT: …"), or empty when the run never reached the stage.
|
||||||
"""
|
"""
|
||||||
if isinstance(exec_results, list):
|
if isinstance(exec_results, list):
|
||||||
out = []
|
out = []
|
||||||
for r in exec_results:
|
for r in exec_results:
|
||||||
if not isinstance(r, dict):
|
if not isinstance(r, dict):
|
||||||
continue
|
continue
|
||||||
|
if r.get("skipped"):
|
||||||
|
out.append(
|
||||||
|
f"- [SKIPPED] `{r.get('cmd', '?')}` — never ran: the runner's "
|
||||||
|
"total deadline expired first; covered criteria are unproven"
|
||||||
|
)
|
||||||
|
continue
|
||||||
status = "PASS" if r.get("exit") == 0 else "FAIL"
|
status = "PASS" if r.get("exit") == 0 else "FAIL"
|
||||||
out.append(
|
out.append(
|
||||||
f"- [{status}] `{r.get('cmd', '?')}` — exit {r.get('exit', '?')}, "
|
f"- [{status}] `{r.get('cmd', '?')}` — exit {r.get('exit', '?')}, "
|
||||||
|
|||||||
@@ -8360,12 +8360,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_adversary_script(script: &str, state: &serde_json::Value) -> serde_json::Value {
|
fn run_adversary_script(script: &str, state: &serde_json::Value) -> serde_json::Value {
|
||||||
|
run_adversary_script_env(script, state, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_adversary_script_env(
|
||||||
|
script: &str,
|
||||||
|
state: &serde_json::Value,
|
||||||
|
envs: &[(&str, &str)],
|
||||||
|
) -> serde_json::Value {
|
||||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("assets/agents/adversary/scripts")
|
.join("assets/agents/adversary/scripts")
|
||||||
.join(script);
|
.join(script);
|
||||||
let out = std::process::Command::new("python3")
|
let mut cmd = std::process::Command::new("python3");
|
||||||
.arg(&path)
|
cmd.arg(&path).env("GRAPH_STATE", state.to_string());
|
||||||
.env("GRAPH_STATE", state.to_string())
|
for (k, v) in envs {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
let out = cmd
|
||||||
.output()
|
.output()
|
||||||
.unwrap_or_else(|e| panic!("failed to invoke python3 {script}: {e}"));
|
.unwrap_or_else(|e| panic!("failed to invoke python3 {script}: {e}"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -8506,7 +8517,11 @@ mod tests {
|
|||||||
let state = json!({"verification_commands": ["echo ok", "false"]});
|
let state = json!({"verification_commands": ["echo ok", "false"]});
|
||||||
let out = run_adversary_script("run_checks.py", &state);
|
let out = run_adversary_script("run_checks.py", &state);
|
||||||
let results = out["exec_results"].as_array().unwrap();
|
let results = out["exec_results"].as_array().unwrap();
|
||||||
assert_eq!(results.len(), 2, "one record per declared command: {results:?}");
|
assert_eq!(
|
||||||
|
results.len(),
|
||||||
|
2,
|
||||||
|
"one record per declared command: {results:?}"
|
||||||
|
);
|
||||||
assert_eq!(results[0]["cmd"], "echo ok");
|
assert_eq!(results[0]["cmd"], "echo ok");
|
||||||
assert_eq!(results[0]["exit"], 0, "green command must record exit 0");
|
assert_eq!(results[0]["exit"], 0, "green command must record exit 0");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -8520,6 +8535,180 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adversary_run_checks_runner_error_degrades_to_environment_marker() {
|
||||||
|
if !cmd_available("python3") {
|
||||||
|
eprintln!("skipping: python3 not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A dead project_dir makes every spawn raise — the outer except must
|
||||||
|
// still exit 0 (asserted inside the helper) and degrade the whole run
|
||||||
|
// to the ENVIRONMENT marker instead of failing the node.
|
||||||
|
let state = json!({
|
||||||
|
"verification_commands": ["echo hi"],
|
||||||
|
"project_dir": "/nonexistent/xyz"
|
||||||
|
});
|
||||||
|
let out = run_adversary_script("run_checks.py", &state);
|
||||||
|
let marker = out["exec_results"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
marker.starts_with("ENVIRONMENT"),
|
||||||
|
"a runner error must degrade to an ENVIRONMENT marker: {marker}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adversary_run_checks_deadline_skips_remaining_commands() {
|
||||||
|
if !cmd_available("python3") {
|
||||||
|
eprintln!("skipping: python3 not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deadline seam: a 1s total budget. The hung first command must be cut
|
||||||
|
// off at min(per-command, remaining)≈1s and recorded as a timeout; the
|
||||||
|
// second must be recorded as skipped — the in-script handling stays
|
||||||
|
// authoritative instead of the NODE timeout killing the script from
|
||||||
|
// outside (which would bypass the ENVIRONMENT degradation entirely).
|
||||||
|
let state = json!({"verification_commands": ["sleep 5", "echo never"]});
|
||||||
|
let out = run_adversary_script_env(
|
||||||
|
"run_checks.py",
|
||||||
|
&state,
|
||||||
|
&[("ADVERSARY_RUN_CHECKS_DEADLINE_SECS", "1")],
|
||||||
|
);
|
||||||
|
let results = out["exec_results"].as_array().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
results.len(),
|
||||||
|
2,
|
||||||
|
"every declared command gets a record: {results:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
results[0]["exit"], -1,
|
||||||
|
"a command outliving its budget must record a timeout: {results:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
results[0]["tail"].as_str().unwrap().contains("TIMEOUT"),
|
||||||
|
"the timeout must be named in the tail: {results:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
results[1]["skipped"], "deadline",
|
||||||
|
"commands past the deadline must be recorded as skipped: {results:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adversary_pipeline_fault_parse_stage_attribution() {
|
||||||
|
if !cmd_available("python3") {
|
||||||
|
eprintln!("skipping: python3 not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let out = run_adversary_script(
|
||||||
|
"pipeline_fault.py",
|
||||||
|
&json!({"parse_failure": "LLM node 'parse' failed: provider exploded"}),
|
||||||
|
);
|
||||||
|
let faults = out["pipeline_faults"].as_array().unwrap();
|
||||||
|
let fault = faults[0].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
fault.contains("PIPELINE-FAULT: parse failed — cannot review"),
|
||||||
|
"an llm-node failure string must attribute to the parse stage: {fault}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fault.contains("provider exploded"),
|
||||||
|
"the failure detail must be carried into the fault: {fault}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adversary_pipeline_fault_facts_and_run_checks_stage_attribution() {
|
||||||
|
if !cmd_available("python3") {
|
||||||
|
eprintln!("skipping: python3 not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A script node's fallback writes NOTHING into state (executor.rs
|
||||||
|
// script arm), so attribution keys off which stage outputs are
|
||||||
|
// present. facts stage: parse succeeded (schema JSON in
|
||||||
|
// parse_failure) but diff_text is still the initial '' — diff
|
||||||
|
// resolution itself died.
|
||||||
|
let out = run_adversary_script(
|
||||||
|
"pipeline_fault.py",
|
||||||
|
&json!({"parse_failure": "{\"criteria\": []}", "diff_text": ""}),
|
||||||
|
);
|
||||||
|
let faults = out["pipeline_faults"].as_array().unwrap();
|
||||||
|
assert!(
|
||||||
|
faults[0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("PIPELINE-FAULT: diff resolution failed"),
|
||||||
|
"empty diff_text must attribute to the facts stage: {faults:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// run_checks stage: facts completed (diff_facts.py unconditionally
|
||||||
|
// writes a nonempty diff_text) but the runner died at the node level
|
||||||
|
// before recording exec_results.
|
||||||
|
let out = run_adversary_script(
|
||||||
|
"pipeline_fault.py",
|
||||||
|
&json!({
|
||||||
|
"parse_failure": "{\"criteria\": []}",
|
||||||
|
"diff_text": "diff --git a/x b/x",
|
||||||
|
"exec_results": ""
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let faults = out["pipeline_faults"].as_array().unwrap();
|
||||||
|
assert!(
|
||||||
|
faults[0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("PIPELINE-FAULT: verification runner killed"),
|
||||||
|
"nonempty diff_text must attribute to the run_checks stage: {faults:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adversary_check_criterion_prompt_cites_exec_results() {
|
||||||
|
use crate::graph::{GraphParser, NodeType};
|
||||||
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/agents/adversary");
|
||||||
|
let graph = GraphParser::new(&dir)
|
||||||
|
.load_from_file(dir.join("graph.yaml"))
|
||||||
|
.expect("adversary graph.yaml must parse");
|
||||||
|
let node = graph
|
||||||
|
.nodes
|
||||||
|
.get("check_criterion")
|
||||||
|
.expect("adversary graph must have a check_criterion node");
|
||||||
|
let NodeType::Llm(llm) = &node.node_type else {
|
||||||
|
panic!("check_criterion must be an llm node");
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
llm.prompt.contains("{{exec_results}}"),
|
||||||
|
"check_criterion's prompt must cite the recorded verification runs: {}",
|
||||||
|
llm.prompt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adversary_verdict_renders_skipped_runs() {
|
||||||
|
if !cmd_available("python3") {
|
||||||
|
eprintln!("skipping: python3 not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let state = json!({
|
||||||
|
"pipeline_faults": [],
|
||||||
|
"crit_verdicts": [
|
||||||
|
{"id": "c1", "text": "does X", "status": "MET",
|
||||||
|
"evidence": "src/x.rs:1 + test src/x.rs:99", "complaint": ""}
|
||||||
|
],
|
||||||
|
"extra_complaints": [],
|
||||||
|
"observations": "",
|
||||||
|
"exec_results": [{"cmd": "cargo test --all", "skipped": "deadline"}]
|
||||||
|
});
|
||||||
|
let out = run_adversary_script("verdict.py", &state);
|
||||||
|
let report = out["adv_report"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
report.contains("- [SKIPPED] `cargo test --all`"),
|
||||||
|
"a skipped record must render as SKIPPED: {report}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!report.contains("[FAIL] `cargo test --all`"),
|
||||||
|
"a skipped record must not render as FAIL: {report}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adversary_crit_gate_contract_regression() {
|
fn adversary_crit_gate_contract_regression() {
|
||||||
if !cmd_available("python3") {
|
if !cmd_available("python3") {
|
||||||
@@ -8548,7 +8737,10 @@ mod tests {
|
|||||||
"crit_verdict": "not json at all"
|
"crit_verdict": "not json at all"
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
assert_eq!(out["_next"], "check_criterion", "first failure must retry: {out}");
|
assert_eq!(
|
||||||
|
out["_next"], "check_criterion",
|
||||||
|
"first failure must retry: {out}"
|
||||||
|
);
|
||||||
assert_eq!(out["gate_attempts"], 1);
|
assert_eq!(out["gate_attempts"], 1);
|
||||||
|
|
||||||
// Malformed output with the retry exhausted → recorded PARTIAL (unproven).
|
// Malformed output with the retry exhausted → recorded PARTIAL (unproven).
|
||||||
|
|||||||
Reference in new issue
Block a user