Skip to content

fix(flows): give flows_resume the run-lifecycle safety flows_run already had - #5286

Merged
graycyrus merged 1 commit into
tinyhumansai:mainfrom
graycyrus:fix/flows-resume-run-lifecycle
Jul 31, 2026
Merged

graycyrus merged 1 commit into
tinyhumansai:mainfrom
graycyrus:fix/flows-resume-run-lifecycle

Conversation

@graycyrus

@graycyrus graycyrus commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Gives flows_resume the run-lifecycle safety flows_run has had since B41/B42 — it had none of it, despite executing a flow's real approved side effects for up to FLOW_RUN_TIMEOUT_SECS.
  • Makes the terminal run-row write guarded, so a settled run can no longer be relabelled by a losing concurrent cancel.
  • Arms the B42 drop-guard before the first .await in run_flow_body, closing a window where a dropped future stranded a running row until the next process boot.
  • The parked-run TTL sweep now publishes FlowRunFinished — it was the one terminal path that emitted no event.

Problem

Four independent defects, all in the same lifecycle area:

1. flows_resume never registered in the run registry. The row stayed pending_approval for the whole resume and no cancellation token existed, so:

  • flows_cancel_run saw is_in_flight == false, took its "parked/stale" branch, wrote a terminal cancelled row and dropped the durable checkpoint — while the resume kept executing real outbound nodes and finally overwrote cancelled with its own status.
  • A run approved just before its TTL was expired mid-execution by sweep_expired_parked_runs, which matches on status = 'pending_approval' — a status the resume never changed.

run_flow_body's own doc comment describes exactly this race; it was fixed for the detached run path and never applied to resume.

2. store::finish_flow_run was an unconditional UPDATE … WHERE id = ?. flows_cancel_run reads the status and consults the registry as two separate observations. A run that settles in that window is not in flight, so the not-in-flight branch relabelled a fully-completed run whose real side effects had fired as cancelled.

3. flows_resume's write order was inverted vs flows_runrecord_run(...)? ran before finish_flow_run_row. A flow deleted mid-resume made the summary write fail and returned early, stranding the row at pending_approval even though the engine had completed; the TTL sweep later relabelled that completed run cancelled. flows_run does the opposite, with an explicit comment saying why.

4. The RunRowFinalizer was constructed ~150 lines after the first .await in run_flow_body, leaving the inference-readiness network probe unguarded: a client disconnect there dropped the future before any finalizer existed, and the row stayed a perpetual running spinner.

Solution

  • Guarded terminal writefinish_flow_run now carries status IN ('running','pending_approval') and returns whether it landed, mirroring the re-check expire_parked_runs/mark_run_interrupted already do. flows_cancel_run attempts that write first and treats it as the authority: a false means the run settled underneath us, so it reports the conflict and leaves the recorded outcome (and the checkpoint) alone.
  • Resume registers + claimsrun_registry::register before anything else, a new guarded store::mark_run_resuming (pending_approvalrunning) that moves the row out of the TTL sweep's predicate, and the cancel token honoured in a biased select exactly as run_flow_body does.
  • Write order fixed + finalizer added on the resume path; record_run failures are logged, never propagated with ?.
  • Finalizer armed before the first await; a third early-return path that was missing its disarm is also fixed.

Note for reviewers: two pre-existing tests changed, and it is not a regression

Adding the guard failed flows_cancel_run_of_a_completed_with_warnings_run_errors and flows_cancel_run_of_an_interrupted_run_errors. Both staged a row at an arbitrary terminal status by calling finish_flow_run twice — precisely the terminal→terminal overwrite the guard exists to block. The guard was correct; the fixtures were leaning on an unguarded production write. Staging therefore gets a #[cfg(test)] force_run_status_for_test door rather than the production path being weakened.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage ≥ 80% — 5 new tests target each changed mechanism directly; cargo test --lib openhuman::flows = 554 passed, 0 failed
  • Coverage matrix updated — N/A: behaviour-only bug fix, no feature rows added/removed/renamed
  • All affected feature IDs from the matrix are listed under ## RelatedN/A: no matrix feature rows affected
  • No new external network dependencies introduced
  • Manual smoke checklist updated if this touches release-cut surfaces — N/A: no user-facing surface change; run/resume/cancel semantics only become more correct
  • Linked issue closed via Closes #NNNN/A: found by code review, no tracking issue filed yet

Impact

  • Runtime/platform: Rust core only. No frontend, no schema migration (the new mark_run_resuming reuses existing columns).
  • Behaviour: a resumed run now shows as running for its duration rather than staying pending_approval. Verify the runs rail renders that sensibly — it is an existing status, and pending_approvals_json is not cleared by the flip.
  • Security: cancelling an already-settled run now returns an error instead of silently rewriting history. That is a deliberate, user-visible behaviour change.
  • Risk: this edits the currently-healthy flows_run path (finalizer placement + its terminal-write helper). The existing flows_run suite is the regression net and stays green.

Related

  • Closes: N/A
  • Follow-up PR(s)/TODOs: flows_resume graph-hash pin (stale-approval-after-graph-swap) is stacked on this branch; store resilience (skip corrupt rows) and the detached-run RPC also stack on it.
  • Merge order: overlaps src/openhuman/flows/ops.rs + ops_tests.rs with the authorization-boundaries PR, and store.rs with the contract-drift PR. Merge this one first; the others rebase cleanly on it.

AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: fix/flows-resume-run-lifecycle
  • Commit SHA: 0b7105fa7

Validation Run

  • pnpm --filter openhuman-app format:check — N/A, no frontend files changed
  • pnpm typecheck — N/A, no TypeScript changed
  • Focused tests: GGML_NATIVE=OFF cargo test --lib openhuman::flows554 passed, 0 failed
  • Rust fmt/check (if changed): GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml clean
  • Tauri fmt/check (if changed): N/A, app/src-tauri untouched

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: a resumed run is registered, cancellable, and immune to the parked-run TTL sweep; terminal run statuses can no longer be overwritten once settled.
  • User-visible effect: a resumed run reports running while it executes; cancelling an already-finished run now returns a clear error instead of silently relabelling it.

Parity Contract

  • Legacy behavior preserved: flows_run / flows_run_detached semantics are unchanged apart from the finalizer being armed earlier; every existing terminal-status transition (running/pending_approval → terminal) still lands.
  • Guard/fallback/dispatch parity checks: the boot orphan sweep, expire_parked_runs, and mark_run_interrupted guards are untouched and still pinned by their existing tests.

Duplicate / Superseded PR Handling

  • Duplicate PR(s): none
  • Canonical PR: this one
  • Resolution: N/A

Summary by CodeRabbit

  • Bug Fixes
    • Improved flow-run finalization to prevent orphaned or incorrectly overwritten run records.
    • Added safer handling for resumed runs, cancellations, timeouts, and concurrent state changes.
    • Prevented duplicate completion events and ensured expired parked runs are reported correctly.
    • Improved cancellation tracking when multiple execution attempts overlap.
  • Tests
    • Added coverage for run resumption, cancellation races, duplicate registrations, and parked-run expiration.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f1413138-810d-4b8d-9bc4-6fa27efd5a8a

📥 Commits

Reviewing files that changed from the base of the PR and between bb83836 and 533f8cc.

📒 Files selected for processing (5)
  • src/openhuman/flows/ops.rs
  • src/openhuman/flows/ops_tests.rs
  • src/openhuman/flows/run_registry.rs
  • src/openhuman/flows/store.rs
  • src/openhuman/flows/store_tests.rs

📝 Walkthrough

Walkthrough

Flow run lifecycle handling now uses guarded state transitions. Execution installs finalizers before awaits, resume claims pending approvals atomically, cancellation and expiry preserve concurrent terminal states, and duplicate completion writes are rejected.

Changes

Flow run lifecycle

Layer / File(s) Summary
Guarded run state transitions
src/openhuman/flows/store.rs, src/openhuman/flows/store_tests.rs
finish_flow_run now updates only live rows and reports whether it changed a row. Resume claims and parked-run expiry use guarded transitions.
Cancellation registration safety
src/openhuman/flows/run_registry.rs
Registrations now include unique IDs. Dropped guards cannot remove newer registrations.
Runtime finalization and cancellation
src/openhuman/flows/ops.rs
Execution finalizers start before awaits. Resume handling claims rows, applies timeout and cancellation handling, finalizes before summary updates, and publishes expiry events.
Lifecycle race coverage
src/openhuman/flows/ops_tests.rs
Tests cover terminal-write protection, cancellation races, resume claims, TTL races, and stale parked-run expiry.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ResumeFlow
  participant RunRegistry
  participant FlowRunStore
  participant FlowEngine
  ResumeFlow->>RunRegistry: Register run
  ResumeFlow->>FlowRunStore: Claim pending approval
  FlowRunStore-->>ResumeFlow: Return claim result
  ResumeFlow->>FlowEngine: Execute resumed flow
  ResumeFlow->>FlowRunStore: Write guarded terminal status
  FlowRunStore-->>ResumeFlow: Return update result
Loading

Possibly related PRs

Suggested labels: rust-core, bug

Suggested reviewers: senamakel

Poem

A rabbit guards the running row,
Claims paused runs before they go.
No stale guard can clear the new,
Terminal states stay settled too.
TTL winds mark the journey’s end. 🐇


Comment @coderabbitai help to get the list of available commands.

…already had (R-M1/M2/M3/M5)

`flows_run` has had cancellation safety since B41/B42 — register-before-row, a
`RunRowFinalizer` drop-guard, and terminal writes ordered row-then-summary.
`flows_resume` had none of it, despite executing the flow's real approved side
effects for up to `FLOW_RUN_TIMEOUT_SECS`. The doc comment on `run_flow_body`
describes exactly the race resume still carried.

R-M2 — `store::finish_flow_run` is now guarded on
`status IN ('running','pending_approval')` and reports whether it landed, the
same re-check `expire_parked_runs` and `mark_run_interrupted` already do. It was
an unconditional `WHERE id = ?`, so `flows_cancel_run` — which reads the status
and consults the registry as two separate observations — could relabel a run
that settled in between: a completed run whose real side effects had fired was
recorded as `cancelled`. The cancel path now attempts the guarded row write
FIRST and treats it as the authority, only recording the summary and dropping
the checkpoint once it has won.

R-M1 — resume now registers in the run registry, claims its row via a new
guarded `mark_run_resuming` (`pending_approval` -> `running`), and honours the
cancellation token in a `biased` select. Without the registry entry a
`flows_cancel_run` took the "parked/stale" branch and dropped the checkpoint out
from under an executing resume; without the status flip, a run approved just
before its TTL was expired by the parked-run sweep mid-execution.

R-M3 — resume finalizes the run row BEFORE the flow-summary write and no longer
propagates a `record_run` failure with `?`. A flow deleted mid-resume used to
return early and strand the row at `pending_approval` even though the engine had
completed, which the TTL sweep would later relabel `cancelled`. Adds a
`RunRowFinalizer` so a dropped resume future reconciles instead of stranding.

R-M5 — the drop-guard in `run_flow_body` is armed before the first `.await`
rather than ~150 lines later, closing the window where a client disconnect
during the inference-readiness network probe stranded a `running` row until the
next process boot. A third early-return path was also missing its `disarm`.

R-m4 — the parked-run TTL sweep now publishes `FlowRunFinished`; it was the one
terminal path that emitted no event, so event-driven consumers only saw the
transition on their next poll.

Two existing tests staged a row at an arbitrary terminal status by calling
`finish_flow_run` twice, which the new guard correctly refuses. Staging is a
fixture concern, so it gets a `#[cfg(test)]` forcing helper rather than a weaker
production write.

554 flows tests pass.
@graycyrus

Copy link
Copy Markdown
Contributor Author

Review follow-up: two real bugs found and fixed, both of which this PR made load-bearing.

1. expire_parked_runs reported candidates, not actual sweeps. Its SELECT and each row's guarded UPDATE are separate statements on an autocommit connection, so a concurrent mark_run_resuming can claim a row in between. The per-row WHERE status = 'pending_approval' kept that row's data safe, but the function returned the unfiltered candidate list — so the caller acted on runs it never expired: dropping the checkpoint out from under a live resume, and (new in this PR, via the R-m4 FlowRunFinished publish) emitting a terminal event for a run still executing. That false event is the worse half: the frontend de-dupes terminal events by ${flow_id}:${run_id}, so the run's real completion would later be discarded as an alias replay and a successful run would display as cancelled. Now only rows whose UPDATE reports changed > 0 are returned. Pinned by expire_parked_runs_returns_only_rows_it_actually_flipped.

2. RunGuard deregistered by key, not by identity. The registry documented duplicate registration as impossible because "thread ids are UUID-suffixed" — true while only flows_run/flows_run_detached registered, since both mint a fresh UUID per call. This PR is the first caller to register against a stable, pre-existing id (the parked run's own), and nothing serializes two concurrent resumes of the same run — a client double-submit or retry-on-timeout suffices. The loser of that race would deregister the winner's live token on its way out, after which flows_cancel_run would see is_in_flight == false for a genuinely executing run, take its parked/stale branch, and drop the checkpoint mid-execution: exactly the bug class this PR exists to eliminate. Registrations now carry a monotonic id and a guard only removes the entry it installed. Pinned by a_displaced_guard_does_not_deregister_the_live_registration.

557 flows tests pass, cargo fmt --check clean.

Also noted for follow-up, not changed here: app/src/hooks/useRunsPendingApprovalSet.ts's doc comment claims "the core has no separate DB status for 'parked at an approval gate' — a run stays `status: 'running'`", which is stale now that flow_runs.status really can be pending_approval. The hook derives display status from the approval queue so its logic looks unaffected, but the comment should be corrected.

@graycyrus
graycyrus force-pushed the fix/flows-resume-run-lifecycle branch from a28d1a3 to 533f8cc Compare July 30, 2026 19:59
@graycyrus
graycyrus marked this pull request as ready for review July 31, 2026 05:50
@graycyrus
graycyrus requested a review from a team July 31, 2026 05:50

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

graycyrus has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@graycyrus
graycyrus merged commit 6a21f3f into tinyhumansai:main Jul 31, 2026
19 of 20 checks passed
@coderabbitai coderabbitai Bot added bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. labels Jul 31, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 533f8cc812

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +53 to +56
let displaced = IN_FLIGHT
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(run_id.to_string(), token.clone());
tracing::debug!(target: "flows", run_id, "[flows] run_registry: registered in-flight run");
.insert(run_id.to_string(), (registration, token.clone()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject duplicate run registrations instead of replacing them

When two flows_resume requests for the same parked run race, both can pass the initial pending_approval read and call register(thread_id) before either flips the row; this insert lets the later request replace the earlier request's cancellation token. If the earlier request then wins mark_run_resuming and continues executing while the later request gets Ok(false) and drops its guard, that guard removes the only registry entry, so a subsequent flows_cancel_run observes is_in_flight == false for a live resume and can take the parked/stale branch, mark it cancelled, and drop its checkpoint mid-execution. The registry needs to fail/return the existing registration on duplicate rather than displacing it before the row claim is won.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant