Skip to content

Commit ee24058

Browse files
committed
feat(testing): make run-plan dependency aware
1 parent ea7e3e0 commit ee24058

5 files changed

Lines changed: 227 additions & 25 deletions

File tree

docs/testing/dstack-test-methodology.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,15 @@ Review the plan for change coverage, regression breadth, compatibility matrices,
8282

8383
### 3.5 Execute
8484

85-
An AI executor must:
85+
The `run-plan` orchestration agent must first read the guide, index, and every
86+
case specification. It processes cases in index order, starts an independent
87+
case-agent session for each runnable case, and reads the completed result before
88+
deciding about later cases. It may mark a later case `SKIPPED` without launching
89+
it only when a recorded earlier non-PASS result demonstrably makes the later
90+
case's prerequisite false or its result meaningless. Similarity, expected cost,
91+
or a mere possibility of failure is not sufficient. Independent cases continue.
92+
93+
Each case executor must:
8694

8795
1. read the plan `README.md` and `index.json`;
8896
2. execute cases in index order unless the guide explicitly permits parallelism;

docs/testing/test-report-output-spec.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ This document defines the normative format for AI sessions, case summaries, run
88
<a id="report-principles"></a>
99
## 1. Principles
1010

11-
1. Each case runs in an independent Codex or Claude session.
11+
1. `run-plan` uses an AI scheduling session before each case, and each case it
12+
executes runs in an independent Codex or Claude session.
1213
2. The agent's native JSONL is the primary source for commands, tool calls, and raw output.
1314
3. The agent writes one shallow `result.json`; it does not copy command output into that file.
1415
4. The runner generates `runner.json`, timestamps, exit code, checksums, and run aggregates.
@@ -48,6 +49,11 @@ dstack-test run-case \
4849

4950
The runner supplies the plan guide, `case.md`, output location, status rules, and result schema in the prompt. The agent must read the guide before the case and must not modify plan specifications.
5051

52+
For a dependency-driven skip, the orchestrator creates a synthetic one-event
53+
case session containing the reason and causal earlier case IDs. It does not
54+
pretend that the skipped case was executed. The full decision process remains
55+
available under the run-level `decisions/` directory.
56+
5157
<a id="report-layout"></a>
5258
## 4. Result layout
5359

tools/dstack-test/README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,16 @@ tools/dstack-test/dstack-test run-plan \
4242
-- "Follow the environment restrictions in README.md"
4343
```
4444

45-
Every case receives an independent agent session. `run-plan` continues after an
46-
agent/runner failure and finalizes an `INCOMPLETE` run when necessary.
45+
Before each case, `run-plan` starts a scheduling-agent session that reads the
46+
guide, index, all case specifications, and prior results. The runner—not the
47+
scheduling agent—then invokes `run-case` in a fresh session when the decision is
48+
`RUN`. When a
49+
prior non-PASS result demonstrably invalidates a later prerequisite, it records
50+
that later case as `SKIPPED` with the causal case IDs instead of starting an
51+
unnecessary case session. It must not skip independent or merely expensive
52+
cases. The complete orchestration session is stored at
53+
`<plan>/results/<run-id>/decisions/`. Keeping scheduling and case execution in
54+
separate processes avoids nested agent CLIs and preserves trust boundaries.
4755

4856
## Finalize and validate
4957

tools/dstack-test/dstack-test

Lines changed: 187 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,100 @@ def run_case(
319319
return {"case": case.id, "status": result["status"], "result_dir": str(result_dir)}
320320

321321

322+
def skip_case(
323+
case: render.CaseEntry,
324+
run_id: str,
325+
reason: str,
326+
blocked_by: list[str],
327+
model: str | None = None,
328+
) -> dict[str, Any]:
329+
result_dir = case.path / "results" / run_id
330+
if result_dir.exists() and any(result_dir.iterdir()):
331+
raise DstackTestError(f"result directory is not empty: {result_dir}")
332+
result_dir.mkdir(parents=True, exist_ok=True)
333+
now = utc_now()
334+
event = {
335+
"type": "orchestrator.skip",
336+
"case_id": case.id,
337+
"reason": reason,
338+
"blocked_by": blocked_by,
339+
}
340+
(result_dir / "session.jsonl").write_text(
341+
json.dumps(event, ensure_ascii=False) + "\n", encoding="utf-8"
342+
)
343+
result = {
344+
"schema_version": "1.0",
345+
"case_id": case.id,
346+
"status": "SKIPPED",
347+
"summary": reason,
348+
"steps": [],
349+
"artifacts": [],
350+
"remarks": "Skipped by the run-plan orchestrator after evaluating prior results.",
351+
}
352+
atomic_json(result_dir / "result.json", result)
353+
atomic_json(
354+
result_dir / "runner.json",
355+
{
356+
"schema_version": "1.0",
357+
"run_id": run_id,
358+
"case_id": case.id,
359+
"agent": {
360+
"type": "run-plan-scheduler",
361+
"model": model or "unknown",
362+
},
363+
"session": {
364+
"format": "orchestrator-decision-jsonl",
365+
"path": "session.jsonl",
366+
"events": 1,
367+
},
368+
"result_path": "result.json",
369+
"started_at": now,
370+
"finished_at": now,
371+
"duration_ms": 0,
372+
"exit_code": 0,
373+
"result_valid": True,
374+
"result_error": None,
375+
},
376+
)
377+
write_sha256s(result_dir)
378+
return {"case": case.id, "status": "SKIPPED", "result_dir": str(result_dir)}
379+
380+
381+
def orchestration_prompt(
382+
plan: render.Plan,
383+
case: render.CaseEntry,
384+
prior: list[dict[str, Any]],
385+
decision_path: Path,
386+
extra_prompt: str,
387+
) -> str:
388+
prior_json = json.dumps(prior, ensure_ascii=False, indent=2)
389+
return f"""You are the dstack test-plan scheduling agent. Decide whether the next case should be executed; do not execute the case yourself.
390+
391+
Read the plan guide at {plan.guide_path}, index at {plan.root / "index.json"}, every case specification, and the prior results below. The next case in authoritative order is {case.id} at {case.spec_path}.
392+
393+
Prior completed results:
394+
{prior_json}
395+
396+
Decision rules:
397+
- Choose RUN unless a recorded prior non-PASS result demonstrably makes this case's prerequisite false or makes its result meaningless.
398+
- Choose SKIP only for such a proven dependency consequence. Similarity, cost, likely failure, or an independently checkable prerequisite is not sufficient.
399+
- A SKIP decision must name one or more causal earlier case IDs in blocked_by and explain the exact dependency.
400+
- Never reinterpret a product failure as a scheduling failure. Independent cases continue after FAIL, BLOCKED, or SKIPPED.
401+
- Do not modify the plan or any case result.
402+
403+
Atomically write exactly this shallow JSON object to {decision_path}:
404+
{{
405+
"decision": "RUN|SKIP",
406+
"reason": "concise evidence-based reason",
407+
"blocked_by": ["prior-case-id"]
408+
}}
409+
For RUN, blocked_by must be empty. Finish after writing the decision.
410+
411+
Additional caller constraints:
412+
{extra_prompt or "None"}
413+
"""
414+
415+
322416
def write_sha256s(root: Path) -> None:
323417
entries: list[str] = []
324418
for path in sorted(
@@ -609,37 +703,109 @@ def main(argv: list[str] | None = None) -> int:
609703
args.overwrite,
610704
)
611705
elif args.subcommand == "run-plan":
612-
values = []
613-
total = len(plan.cases)
614-
for index, case in enumerate(plan.cases, 1):
615-
print(f"[{index}/{total}] starting {case.id}", file=sys.stderr, flush=True)
616-
try:
617-
case_value = run_case(
706+
run_dir = plan.root / "results" / args.run_id
707+
existing = run_dir.exists() or any(
708+
(case.path / "results" / args.run_id).exists() for case in plan.cases
709+
)
710+
if existing and not args.overwrite:
711+
raise DstackTestError(
712+
f"run {args.run_id!r} already exists; pass --overwrite to replace it"
713+
)
714+
if args.overwrite:
715+
shutil.rmtree(run_dir, ignore_errors=True)
716+
for case in plan.cases:
717+
shutil.rmtree(case.path / "results" / args.run_id, ignore_errors=True)
718+
decisions_dir = run_dir / "decisions"
719+
decisions_dir.mkdir(parents=True, exist_ok=True)
720+
effective_model = resolve_model(args.agent, args.model)
721+
prior: list[dict[str, Any]] = []
722+
for number, case in enumerate(plan.cases, 1):
723+
print(
724+
f"[{number}/{len(plan.cases)}] scheduling {case.id}",
725+
file=sys.stderr,
726+
flush=True,
727+
)
728+
decision_path = decisions_dir / f"{case.id}.json"
729+
prompt = orchestration_prompt(
730+
plan, case, prior, decision_path, prompt_text(args)
731+
)
732+
(decisions_dir / f"{case.id}.prompt.md").write_text(
733+
prompt, encoding="utf-8"
734+
)
735+
command = agent_command(
736+
args.agent,
737+
effective_model,
738+
args.workdir.resolve(),
739+
prompt,
740+
args.agent_arg,
741+
)
742+
with (
743+
(decisions_dir / f"{case.id}.session.jsonl").open("wb") as stdout,
744+
(decisions_dir / f"{case.id}.stderr.log").open("wb") as stderr,
745+
):
746+
completed = subprocess.run(
747+
command, cwd=args.workdir, stdout=stdout, stderr=stderr, check=False
748+
)
749+
validate_session(decisions_dir / f"{case.id}.session.jsonl")
750+
if completed.returncode != 0:
751+
raise DstackTestError(
752+
f"scheduler failed for {case.id} with exit code {completed.returncode}"
753+
)
754+
decision = render.load_json(decision_path)
755+
action = decision.get("decision")
756+
reason = decision.get("reason")
757+
blocked_by = decision.get("blocked_by", [])
758+
if (
759+
action not in ("RUN", "SKIP")
760+
or not isinstance(reason, str)
761+
or not reason.strip()
762+
or not isinstance(blocked_by, list)
763+
):
764+
raise DstackTestError(f"invalid scheduling decision for {case.id}")
765+
prior_by_id = {item["case_id"]: item for item in prior}
766+
if action == "SKIP":
767+
if not blocked_by or any(
768+
ref not in prior_by_id or prior_by_id[ref]["status"] == "PASS"
769+
for ref in blocked_by
770+
):
771+
raise DstackTestError(
772+
f"invalid blocked_by references for {case.id}"
773+
)
774+
skip_case(case, args.run_id, reason, blocked_by, effective_model)
775+
result = {"case_id": case.id, "status": "SKIPPED", "summary": reason}
776+
else:
777+
if blocked_by:
778+
raise DstackTestError(
779+
f"RUN decision has blocked_by entries for {case.id}"
780+
)
781+
run_case(
618782
plan,
619783
case,
620784
args.run_id,
621785
args.agent,
622-
args.model,
786+
effective_model,
623787
args.workdir.resolve(),
624788
prompt_text(args),
625789
args.agent_arg,
626-
args.overwrite,
627-
)
628-
values.append(case_value)
629-
print(
630-
f"[{index}/{total}] finished {case.id}: {case_value['status']}",
631-
file=sys.stderr,
632-
flush=True,
790+
False,
633791
)
634-
except DstackTestError as error:
635-
values.append({"case": case.id, "runner_error": str(error)})
636-
print(
637-
f"[{index}/{total}] runner error for {case.id}: {error}",
638-
file=sys.stderr,
639-
flush=True,
792+
result = validate_summary(
793+
case, case.path / "results" / args.run_id / "result.json"
640794
)
795+
prior.append(
796+
{
797+
"case_id": case.id,
798+
"status": result["status"],
799+
"summary": result["summary"],
800+
}
801+
)
802+
print(
803+
f"[{number}/{len(plan.cases)}] {case.id}: {result['status']}",
804+
file=sys.stderr,
805+
flush=True,
806+
)
641807
run = finalize_run(plan, args.run_id, context_value(args.context))
642-
value = {"cases": values, "run": run}
808+
value = {"decisions": prior, "run": run}
643809
elif args.subcommand == "finalize":
644810
value = finalize_run(plan, args.run_id, context_value(args.context))
645811
elif args.subcommand == "validate":

tools/dstack-test/tests/test_dstack_test.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ def test_codex_model_is_read_from_config(self) -> None:
5656
dstack_test.resolve_model("codex", None), "test-codex-model"
5757
)
5858

59+
def test_orchestrator_can_record_dependency_skip(self) -> None:
60+
with tempfile.TemporaryDirectory() as temporary:
61+
plan_path = self.copy_fixture(Path(temporary))
62+
plan = render.load_plan(plan_path)
63+
case = plan.cases[0]
64+
value = dstack_test.skip_case(
65+
case, "run-skip", "prerequisite case failed", ["tc-prereq-001"]
66+
)
67+
self.assertEqual(value["status"], "SKIPPED")
68+
result_dir = case.path / "results" / "run-skip"
69+
result = dstack_test.validate_summary(case, result_dir / "result.json")
70+
self.assertEqual(result["status"], "SKIPPED")
71+
self.assertIn("tc-prereq-001", (result_dir / "session.jsonl").read_text())
72+
5973
def test_validate_and_render_fixture(self) -> None:
6074
plan = render.load_plan(FIXTURE)
6175
valid = dstack_test.validate_run(plan, "run-demo")

0 commit comments

Comments
 (0)