fix(workflows): close review gaps — pull round-trip, dup-names, reclaim budget
Adversarial review of the v1.2 Workflows track surfaced two HIGH footguns and several correctness/hardening gaps. Fixes: HIGH - pull round-trip: `pic pull` dropped `[[workflows]]`, so a later `apply --prune` silently deleted them. The list endpoint now returns the full `definition`; pull rebuilds the manifest block (inverse of workflow_to_wire). - case-colliding names: two workflows differing only by case collided in the reconcile diff (keyed by lower(name)), silently dropping one. Rejected up front in validate_bundle_for. MEDIUM - reclaim retry budget: a crashed attempt (no outcome) consumed the retry budget. reclaim_stale_steps now decrements `attempt` (floored) and clears `next_attempt_at`, so a crash no longer counts as a failed try. - on_error/backoff: typed the manifest fields against the shared enums so a bad value fails at TOML parse with a clear message, not an opaque 500. - dedupe depends_on: a repeated dependency inflated the Kahn in-degree past the single decrement, reporting a spurious cycle for a valid DAG. Count distinct. - canceled child: a canceled sub-workflow resolved the parent step with a message naming the cause instead of a generic "failed"; documented that a run-level cancel op is not yet supported. LOW - list_run_steps is now app-scoped at the query (JOIN workflow_runs) rather than relying on caller pre-verification. - partial failure is surfaced: a run that succeeds with on_error=continue failures records them in the run's `error` field. - admin-started runs log the root_execution_id against the principal. - documented the deliberate when(missing→false) vs template(missing→fail) asymmetry; corrected the claim's atomicity comment. Tests: unit (dedupe-deps), DB-gated reclaim-budget assertion, and two new CLI journeys (duplicate-name rejection, pull→plan clean round-trip). fmt + clippy -D warnings clean; 450 lib + 14 orchestrator DB + 5 workflow journeys pass; schema snapshot unchanged (no migration). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -180,7 +180,7 @@ async fn seeds_roots_ready_and_completes_linear_chain() {
|
||||
.expect("start");
|
||||
|
||||
// Root `a` starts ready; b, c pending.
|
||||
let steps = list_run_steps(&pool, run).await.unwrap();
|
||||
let steps = list_run_steps(&pool, app, run).await.unwrap();
|
||||
let by = |n: &str| steps.iter().find(|s| s.step_name == n).unwrap().status;
|
||||
assert_eq!(by("a"), StepStatus::Ready);
|
||||
assert_eq!(by("b"), StepStatus::Pending);
|
||||
@@ -327,7 +327,7 @@ async fn on_error_fail_fails_the_run() {
|
||||
let r = get_run(&pool, app, run).await.unwrap().unwrap();
|
||||
assert_eq!(r.status, RunStatus::Failed);
|
||||
assert!(r.error.unwrap().contains('a'));
|
||||
let steps = list_run_steps(&pool, run).await.unwrap();
|
||||
let steps = list_run_steps(&pool, app, run).await.unwrap();
|
||||
let b = steps.iter().find(|s| s.step_name == "b").unwrap();
|
||||
assert_eq!(b.status, StepStatus::Pending); // never promoted
|
||||
}
|
||||
@@ -392,7 +392,7 @@ async fn double_complete_is_idempotent() {
|
||||
.unwrap();
|
||||
assert_eq!(second, AdvanceResult::Stale);
|
||||
|
||||
let steps = list_run_steps(&pool, run).await.unwrap();
|
||||
let steps = list_run_steps(&pool, app, run).await.unwrap();
|
||||
assert_eq!(steps[0].output, Some(json!("first")), "first write wins");
|
||||
let r = get_run(&pool, app, run).await.unwrap().unwrap();
|
||||
assert_eq!(r.status, RunStatus::Succeeded);
|
||||
@@ -433,6 +433,9 @@ async fn stale_lease_is_reclaimed() {
|
||||
.unwrap()
|
||||
.expect("re-claim after reclaim");
|
||||
assert_eq!(c2.step_name, "a");
|
||||
// The crashed attempt produced no outcome, so it must not consume the retry
|
||||
// budget: after claim(→1), reclaim(→0), re-claim(→1) the attempt is 1, not 2.
|
||||
assert_eq!(c2.attempt, 1, "reclaim must not burn the retry budget");
|
||||
complete_step_and_advance(&pool, &c2, ok(json!("done")))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -757,7 +760,7 @@ async fn when_false_skips_step() {
|
||||
|
||||
let r = get_run(&pool, app, run).await.unwrap().unwrap();
|
||||
assert_eq!(r.status, RunStatus::Succeeded);
|
||||
let steps = list_run_steps(&pool, run).await.unwrap();
|
||||
let steps = list_run_steps(&pool, app, run).await.unwrap();
|
||||
let by = |n: &str| steps.iter().find(|s| s.step_name == n).unwrap().status;
|
||||
assert_eq!(by("a"), StepStatus::Succeeded);
|
||||
assert_eq!(by("b"), StepStatus::Skipped);
|
||||
@@ -830,7 +833,7 @@ async fn missing_input_ref_fails_the_step() {
|
||||
"missing input ref fails the run"
|
||||
);
|
||||
assert!(executor.body_for("fn_b").is_none(), "b never executes");
|
||||
let steps = list_run_steps(&pool, run).await.unwrap();
|
||||
let steps = list_run_steps(&pool, app, run).await.unwrap();
|
||||
let bstep = steps.iter().find(|s| s.step_name == "b").unwrap();
|
||||
assert_eq!(bstep.status, StepStatus::Failed);
|
||||
assert!(bstep.error.as_deref().unwrap_or("").contains("input"));
|
||||
@@ -887,7 +890,7 @@ async fn nested_sub_workflow_runs_and_output_flows_to_parent() {
|
||||
// The child's output flowed into the parent step, then into `after`'s input.
|
||||
assert_eq!(executor.body_for("fn_after"), Some(json!({ "got": 42 })));
|
||||
// The parent step "call" carries the child run's output.
|
||||
let steps = list_run_steps(&pool, run).await.unwrap();
|
||||
let steps = list_run_steps(&pool, app, run).await.unwrap();
|
||||
let call = steps.iter().find(|s| s.step_name == "call").unwrap();
|
||||
assert_eq!(call.status, StepStatus::Succeeded);
|
||||
assert_eq!(call.output, Some(json!({ "leaf": { "answer": 42 } })));
|
||||
@@ -971,7 +974,7 @@ async fn workflow_service_start_seeds_a_run() {
|
||||
.expect("start");
|
||||
let r = get_run(&pool, app, run_id).await.unwrap().unwrap();
|
||||
assert_eq!(r.input, json!({ "k": "v" }));
|
||||
let steps = list_run_steps(&pool, run_id).await.unwrap();
|
||||
let steps = list_run_steps(&pool, app, run_id).await.unwrap();
|
||||
assert_eq!(steps.len(), 1);
|
||||
assert_eq!(steps[0].step_name, "only");
|
||||
assert_eq!(steps[0].status, StepStatus::Ready); // a root
|
||||
|
||||
Reference in New Issue
Block a user