Skip to content

Commit 7005557

Browse files
ZhiXiao-Linclaude
andauthored
test(orchestration): comprehensive unit + real-LLM coverage (#43) (#59)
Closes the coverage gaps surfaced by an adversarial coverage audit across the new orchestration layer. +21 unit tests, +4 real-LLM integration tests (all run green against .a3s/config.acl), plus a small fix and a de-flake. Unit: - combinators: empty inputs, zero concurrency_hint (\.max(1) clamp), first-stage None, resumable ignores checkpointed-but-dropped specs, and resumable re-runs all when the checkpoint is unreadable (future version). - store: FileSessionStore workflow-checkpoint round-trip, crash-atomic no-temp-leftovers, and future-version rejection through both stores. - schema coercion FAILURE path (previously untested): demote-to-failure with the marker, isolation from a sibling in a fan-out, and failed-run-skips-coercion. - #31 persisted-schema fuzz extended to AgentStepSpec / StepOutcome / WorkflowCheckpoint (round-trip stability + forward-compat unknown-field tolerance) — the cross-node-migration types were previously excluded. - Python SDK conversion helpers (py_to_step_spec / step_outcome_to_py) and the PythonPipelineStage bridge (None-stops, raise-fails-closed, snake_case ctx['previous']) — previously zero coverage. Real-LLM (#[ignore], .a3s/config.acl): pure parallel fan-out of distinct agents (order + per-branch sentinels), multi-item pipeline (no-barrier shape with >1 item), the actual RESUME path (completed step served from checkpoint, only the rest run live), and a nested/array output_schema. Fix: the resumable combinator now distinguishes Ok(None) from a load Err and logs a warning before re-running from scratch (was a silent swallow). De-flake: the pipeline real-LLM tests no longer assert on stage-2 output *content* (some models return an empty final turn for a one-word instruction); chaining is proven by reaching stage 2 + the deterministic mock test. Co-authored-by: Claude <claude@anthropic.com>
1 parent 9af8a3b commit 7005557

6 files changed

Lines changed: 916 additions & 9 deletions

File tree

core/src/orchestration/combinators.rs

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,23 @@ pub async fn execute_steps_parallel_resumable(
106106
store: Arc<dyn SessionStore>,
107107
event_tx: Option<broadcast::Sender<AgentEvent>>,
108108
) -> Vec<StepOutcome> {
109-
// Prior progress (the store's load rejects a future-version checkpoint).
109+
// Prior progress. An unreadable checkpoint — e.g. one written by a newer,
110+
// incompatible schema version, which the store rejects via
111+
// `ensure_loadable` — is treated as *no* prior progress: the workflow
112+
// re-runs from scratch rather than resuming from state it can't interpret.
113+
// That's a fail-safe (do the work), but surface it rather than swallow it.
110114
let done: HashMap<String, StepOutcome> = match store.load_workflow_checkpoint(workflow_id).await
111115
{
112116
Ok(Some(cp)) => cp.completed(),
113-
_ => HashMap::new(),
117+
Ok(None) => HashMap::new(),
118+
Err(e) => {
119+
tracing::warn!(
120+
workflow_id = %workflow_id,
121+
error = %e,
122+
"workflow checkpoint unreadable; re-running the workflow from scratch"
123+
);
124+
HashMap::new()
125+
}
114126
};
115127

116128
let pending: Vec<AgentStepSpec> = specs
@@ -486,4 +498,170 @@ mod tests {
486498
"failed step is NOT recorded → it retries on resume"
487499
);
488500
}
501+
502+
struct ZeroHintExecutor;
503+
#[async_trait]
504+
impl AgentExecutor for ZeroHintExecutor {
505+
async fn execute_step(
506+
&self,
507+
spec: AgentStepSpec,
508+
_event_tx: Option<broadcast::Sender<AgentEvent>>,
509+
) -> StepOutcome {
510+
StepOutcome {
511+
task_id: spec.task_id.clone(),
512+
session_id: format!("task-run-{}", spec.task_id),
513+
agent: spec.agent.clone(),
514+
output: "ok".to_string(),
515+
success: true,
516+
structured: None,
517+
}
518+
}
519+
fn concurrency_hint(&self) -> usize {
520+
0
521+
}
522+
}
523+
524+
#[tokio::test]
525+
async fn empty_inputs_return_empty() {
526+
let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
527+
assert!(
528+
crate::orchestration::execute_steps_parallel(Arc::clone(&exec), vec![], None)
529+
.await
530+
.is_empty()
531+
);
532+
let stages: Vec<PipelineStage<&str>> =
533+
vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
534+
Some(AgentStepSpec::new("s", "explore", "d", *item))
535+
})];
536+
assert!(execute_pipeline(exec, Vec::<&str>::new(), stages, None)
537+
.await
538+
.is_empty());
539+
}
540+
541+
#[tokio::test]
542+
async fn zero_concurrency_hint_still_makes_progress() {
543+
// The .max(1) clamp in run_ordered_parallel_with_limit keeps a 0-hint
544+
// executor serialized-but-live instead of deadlocking on 0 permits.
545+
let exec: Arc<dyn AgentExecutor> = Arc::new(ZeroHintExecutor);
546+
let specs = vec![
547+
AgentStepSpec::new("a", "explore", "d", "p"),
548+
AgentStepSpec::new("b", "explore", "d", "p"),
549+
AgentStepSpec::new("c", "explore", "d", "p"),
550+
];
551+
let out = crate::orchestration::execute_steps_parallel(exec, specs, None).await;
552+
assert_eq!(
553+
out.iter().map(|o| o.task_id.as_str()).collect::<Vec<_>>(),
554+
vec!["a", "b", "c"]
555+
);
556+
assert!(out.iter().all(|o| o.success));
557+
}
558+
559+
#[tokio::test]
560+
async fn pipeline_first_stage_none_yields_none_outcome() {
561+
let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
562+
let stages: Vec<PipelineStage<&str>> =
563+
vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
564+
if *item == "skip" {
565+
None
566+
} else {
567+
Some(AgentStepSpec::new("s", "explore", "d", *item))
568+
}
569+
})];
570+
let out = execute_pipeline(exec, vec!["skip", "run"], stages, None).await;
571+
assert!(
572+
out[0].is_none(),
573+
"a first-stage None yields a None outcome (chain never started)"
574+
);
575+
assert!(out[1].as_ref().unwrap().success);
576+
}
577+
578+
fn cached(task_id: &str, agent: &str, output: &str) -> StepOutcome {
579+
StepOutcome {
580+
task_id: task_id.to_string(),
581+
session_id: format!("task-run-{task_id}"),
582+
agent: agent.to_string(),
583+
output: output.to_string(),
584+
success: true,
585+
structured: None,
586+
}
587+
}
588+
589+
#[tokio::test]
590+
async fn resumable_reruns_all_when_checkpoint_load_errors() {
591+
use crate::store::MemorySessionStore;
592+
let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
593+
594+
// A checkpoint written by a *newer*, incompatible schema version: the
595+
// store rejects it on load. The resumable combinator must treat that as
596+
// no prior progress and re-run everything (fail-safe), not panic or
597+
// silently resume from state it can't read.
598+
let mut done = std::collections::HashMap::new();
599+
done.insert("a".to_string(), cached("a", "explore", "old"));
600+
let mut cp = WorkflowCheckpoint::from_completed("wf-err", &done, 1);
601+
cp.schema_version = crate::orchestration::WORKFLOW_CHECKPOINT_SCHEMA_VERSION + 1;
602+
store.save_workflow_checkpoint("wf-err", &cp).await.unwrap();
603+
604+
let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
605+
let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
606+
ran: Arc::clone(&ran),
607+
});
608+
let specs = vec![
609+
AgentStepSpec::new("a", "explore", "d", "pa"),
610+
AgentStepSpec::new("b", "review", "d", "pb"),
611+
];
612+
let out =
613+
execute_steps_parallel_resumable(exec, specs, "wf-err", Arc::clone(&store), None).await;
614+
615+
let mut ran_ids = ran.lock().await.clone();
616+
ran_ids.sort();
617+
assert_eq!(
618+
ran_ids,
619+
vec!["a".to_string(), "b".to_string()],
620+
"an unreadable (future-version) checkpoint is ignored → all steps re-run"
621+
);
622+
assert_eq!(out.len(), 2);
623+
assert!(out.iter().all(|o| o.success));
624+
}
625+
626+
#[tokio::test]
627+
async fn resumable_ignores_checkpointed_steps_absent_from_new_specs() {
628+
use crate::store::MemorySessionStore;
629+
let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
630+
631+
// Prior checkpoint completed {a, b}; the new run drops "a", reorders,
632+
// and adds "c". Output follows the NEW specs; "b" is reused; the stale
633+
// "a" simply doesn't appear; only "c" actually runs.
634+
let mut done = std::collections::HashMap::new();
635+
done.insert("a".to_string(), cached("a", "explore", "cached-a"));
636+
done.insert("b".to_string(), cached("b", "review", "cached-b"));
637+
store
638+
.save_workflow_checkpoint(
639+
"wf-x",
640+
&WorkflowCheckpoint::from_completed("wf-x", &done, 1),
641+
)
642+
.await
643+
.unwrap();
644+
645+
let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
646+
let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
647+
ran: Arc::clone(&ran),
648+
});
649+
let specs = vec![
650+
AgentStepSpec::new("b", "review", "d", "pb"),
651+
AgentStepSpec::new("c", "plan", "d", "pc"),
652+
];
653+
let out =
654+
execute_steps_parallel_resumable(exec, specs, "wf-x", Arc::clone(&store), None).await;
655+
656+
assert_eq!(
657+
*ran.lock().await,
658+
vec!["c".to_string()],
659+
"cached b reused, stale a dropped, only new c runs"
660+
);
661+
assert_eq!(out.len(), 2);
662+
assert_eq!(out[0].task_id, "b");
663+
assert_eq!(out[0].output, "cached-b");
664+
assert_eq!(out[1].task_id, "c");
665+
assert!(out.iter().all(|o| o.success));
666+
}
489667
}

core/src/store/tests.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,3 +808,122 @@ async fn test_file_store_checkpoint_write_is_atomic_no_temp_leftovers() {
808808
"the final checkpoint file must exist, got: {names:?}"
809809
);
810810
}
811+
812+
fn sample_workflow_checkpoint(wf_id: &str) -> crate::orchestration::WorkflowCheckpoint {
813+
use crate::orchestration::{
814+
StepOutcome, WorkflowCheckpoint, WorkflowStepRecord, WORKFLOW_CHECKPOINT_SCHEMA_VERSION,
815+
};
816+
let outcome = |id: &str, structured| StepOutcome {
817+
task_id: id.to_string(),
818+
session_id: format!("task-run-{id}"),
819+
agent: "explore".to_string(),
820+
output: format!("out-{id}"),
821+
success: true,
822+
structured,
823+
};
824+
WorkflowCheckpoint {
825+
schema_version: WORKFLOW_CHECKPOINT_SCHEMA_VERSION,
826+
workflow_id: wf_id.to_string(),
827+
steps: vec![
828+
WorkflowStepRecord {
829+
task_id: "a".into(),
830+
outcome: outcome("a", None),
831+
},
832+
WorkflowStepRecord {
833+
task_id: "b".into(),
834+
outcome: outcome("b", Some(serde_json::json!({ "k": 1 }))),
835+
},
836+
],
837+
checkpoint_ms: 1_700_000_000_000,
838+
}
839+
}
840+
841+
#[tokio::test]
842+
async fn test_file_store_workflow_checkpoint_roundtrip() {
843+
let dir = tempdir().unwrap();
844+
let store = FileSessionStore::new(dir.path()).await.unwrap();
845+
let cp = sample_workflow_checkpoint("wf-1");
846+
store.save_workflow_checkpoint("wf-1", &cp).await.unwrap();
847+
848+
let loaded = store
849+
.load_workflow_checkpoint("wf-1")
850+
.await
851+
.unwrap()
852+
.expect("present");
853+
assert_eq!(loaded, cp);
854+
assert_eq!(loaded.completed().len(), 2);
855+
856+
store.delete_workflow_checkpoint("wf-1").await.unwrap();
857+
assert!(store
858+
.load_workflow_checkpoint("wf-1")
859+
.await
860+
.unwrap()
861+
.is_none());
862+
// Idempotent on a missing file.
863+
store.delete_workflow_checkpoint("wf-1").await.unwrap();
864+
}
865+
866+
#[tokio::test]
867+
async fn test_memory_store_rejects_future_workflow_checkpoint() {
868+
let store = MemorySessionStore::new();
869+
let mut future = sample_workflow_checkpoint("wf-future");
870+
future.schema_version = crate::orchestration::WORKFLOW_CHECKPOINT_SCHEMA_VERSION + 1;
871+
store
872+
.save_workflow_checkpoint("wf-future", &future)
873+
.await
874+
.unwrap();
875+
let err = store
876+
.load_workflow_checkpoint("wf-future")
877+
.await
878+
.unwrap_err();
879+
assert!(err.to_string().contains("schema version"), "got: {err}");
880+
881+
store
882+
.save_workflow_checkpoint("wf-ok", &sample_workflow_checkpoint("wf-ok"))
883+
.await
884+
.unwrap();
885+
assert!(store
886+
.load_workflow_checkpoint("wf-ok")
887+
.await
888+
.unwrap()
889+
.is_some());
890+
}
891+
892+
#[tokio::test]
893+
async fn test_file_store_rejects_future_workflow_checkpoint() {
894+
let dir = tempdir().unwrap();
895+
let store = FileSessionStore::new(dir.path()).await.unwrap();
896+
let mut future = sample_workflow_checkpoint("wf-f");
897+
future.schema_version = crate::orchestration::WORKFLOW_CHECKPOINT_SCHEMA_VERSION + 1;
898+
store
899+
.save_workflow_checkpoint("wf-f", &future)
900+
.await
901+
.unwrap();
902+
let err = store.load_workflow_checkpoint("wf-f").await.unwrap_err();
903+
assert!(err.to_string().contains("schema version"), "got: {err}");
904+
}
905+
906+
#[tokio::test]
907+
async fn test_file_store_workflow_checkpoint_atomic_no_temp_leftovers() {
908+
let dir = tempdir().unwrap();
909+
let store = FileSessionStore::new(dir.path()).await.unwrap();
910+
store
911+
.save_workflow_checkpoint("wf-z", &sample_workflow_checkpoint("wf-z"))
912+
.await
913+
.unwrap();
914+
915+
let ckpt_dir = dir.path().join("workflow_checkpoints");
916+
let mut entries = tokio::fs::read_dir(&ckpt_dir).await.unwrap();
917+
let mut names = Vec::new();
918+
while let Some(e) = entries.next_entry().await.unwrap() {
919+
names.push(e.file_name().to_string_lossy().to_string());
920+
}
921+
assert!(
922+
names.iter().all(|n| !n.contains(".tmp")),
923+
"no temp leftovers after atomic write, got: {names:?}"
924+
);
925+
assert!(
926+
names.iter().any(|n| n == "wf-z.json"),
927+
"the final workflow checkpoint file must exist, got: {names:?}"
928+
);
929+
}

0 commit comments

Comments
 (0)