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:
MechaCat02
2026-07-12 18:56:44 +02:00
parent bd9c3fa4f0
commit eaf5ace30f
10 changed files with 331 additions and 37 deletions

View File

@@ -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