Skip to content

fix(core): make workflow runs durable across resumes - #23

Open
miyaontherelay wants to merge 3 commits into
mainfrom
fix/workflow-run-durability
Open

fix(core): make workflow runs durable across resumes#23
miyaontherelay wants to merge 3 commits into
mainfrom
fix/workflow-run-durability

Conversation

@miyaontherelay

@miyaontherelay miyaontherelay commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes three workflow-resume durability defects. Each was found by running the code, not reading it.

  1. packages/core/src/run.tsrunWorkflow() constructed WorkflowRunner without a db, so
    it fell back to InMemoryWorkflowDb (runner.ts:703). Run state died with the process, and
    --resume — accepted at run.ts:27, dispatched at run.ts:86 — could never succeed. It now
    builds the cwd-backed .agent-relay/workflow-runs.jsonl DB, matching builder.ts:523.

  2. packages/core/src/builder.tsWorkflowRunOptions had startFrom/previousRunId but no
    resume. The entrypoint that did persist state had no way to resume it, while the entrypoint
    that accepted resume had no persistence. Adds resume and dispatches to runner.resume(...).

  3. packages/core/src/runner.tsresume() reset only failed steps to pending. A step
    persisted as running when the process was killed stayed running forever and the run failed.
    A step cannot legitimately be running when no process owns it, so stale running is now reset
    too.

Evidence

Reproduction, using a three-step deterministic workflow (no agents, so this isolates durability
from agent behaviour), kill -9 during step two:

before after
--resume <id> Run "…" not found (no database entry or cached step outputs) resumes
completed step skipped, not re-executed
interrupted step re-executed (full 2m)
outcome run lost 3 passed, 0 failed

The "after" column was verified against a locally patched build reproducing these three changes
before this branch existed; the branch itself is verified by the test results below.

Validation

Run on macOS 26.3.1 / Node 25.8.1. npm hangs at 0% CPU on this machine, so the underlying
binaries were invoked directly via bunx. Worth noting for CI: the root scripts chain everything
through npm run --workspace=…, so on any host where npm misbehaves the entire verification path
yields no signal at all.

  • bunx tsc --noEmit (packages/core) — clean, exit 0
  • bunx tsc for github/slack/browser primitives — all exit 0
  • bunx vitest run run-persistence.test.ts resume-fallback.test.ts10 passed / 10
  • Full core suite — 801 passed, 10 failed

Pre-existing failures (not caused by this PR)

9 of those 10 are in run-script.test.ts (TypeScript strip-types preflight, plus a 30s timeout).
Baselined on main at b086a87: the same 9 fail there (9 failed | 17 passed). This PR
neither causes nor fixes them.

The 10th was a defect in this PR's own first commit, fixed in e2a2e73 — see below.

Note on the second commit

The first commit dropped the third argument from both runner.resume() calls in builder.ts. That
argument feeds reconstructRunFromCache(runId, config), the cached-step-output fallback used when
workflow-runs.jsonl is missing — precisely what resume-fallback.test.ts exists to cover. The
two call sites were "harmonised" toward the broken one.

run-persistence.test.ts also could not fail: its fixture used steps: [], which
validateWorkflow (runner.ts:3192) rejects, so parseYamlFile threw before any assertion ran.

e2a2e73 restores the argument, gives the fixture a real step, and strengthens the assertion to
require the config argument so the regression cannot recur silently.

🤖 Generated with Claude Code

Persist runWorkflow state to the JSONL database, expose builder resume options, and retry stale running steps after interruption.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Workflow execution now persists runs to JSONL storage, accepts an explicit resume ID, and resets interrupted running steps to pending so they can execute again. Tests cover database wiring, resume routing, and stale-step recovery.

Changes

Workflow resume and persistence

Layer / File(s) Summary
JSONL run persistence
packages/core/src/run.ts, packages/core/src/__tests__/run-persistence.test.ts
runWorkflow creates a JsonFileWorkflowDb under .agent-relay/workflow-runs.jsonl and passes it to WorkflowRunner; tests verify the storage path and runner wiring.
Explicit resume selection
packages/core/src/builder.ts, packages/core/src/run.ts, packages/core/src/__tests__/run-persistence.test.ts
WorkflowRunOptions accepts resume, which WorkflowBuilder.run prioritizes over RESUME_RUN_ID; resume calls pass resetRunningSteps: true, and tests verify resume is invoked instead of execution.
Interrupted step recovery
packages/core/src/types.ts, packages/core/src/runner.ts, packages/core/src/__tests__/resume-fallback.test.ts
ResumeOptions controls requeueing of running steps; resume reconstruction changes failed and enabled running steps to pending, clears error metadata, resets retry counts, persists updates, and re-executes stale steps.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant WorkflowBuilder
  participant JsonFileWorkflowDb
  participant WorkflowRunner
  participant WorkflowStep

  Caller->>WorkflowBuilder: run with resume ID
  WorkflowBuilder->>JsonFileWorkflowDb: load persisted run
  WorkflowBuilder->>WorkflowRunner: resume run with resetRunningSteps
  WorkflowRunner->>JsonFileWorkflowDb: reset running steps to pending
  WorkflowRunner->>WorkflowStep: re-execute stale steps
  WorkflowStep-->>WorkflowRunner: complete steps
  WorkflowRunner-->>WorkflowBuilder: return resumed run
Loading

Suggested reviewers: willwashburn, khaliqgant

Poem

A bunny found a run half-done,
With sleepy steps beneath the sun.
It cleared the dust and queued them bright,
Then hopped through every step just right.
JSONL kept the trail in sight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: making workflow runs durable across resumes.
Description check ✅ Passed The description directly matches the changeset and explains the resume durability fixes in detail.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/workflow-run-durability

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

The initial commit dropped the third argument from builder.ts's two
runner.resume() calls. That argument feeds reconstructRunFromCache(runId,
config), the cached-step-output fallback used when workflow-runs.jsonl is
absent -- exactly what resume-fallback.test.ts covers. Restore it.

run-persistence.test.ts also could not fail: its fixture used 'steps: []',
which validateWorkflow rejects, so parseYamlFile threw before any assertion
ran. Give it a real deterministic step, and strengthen the builder-resume
assertion to require the config argument so this regression cannot recur.
@khaliqgant
khaliqgant marked this pull request as ready for review July 28, 2026 20:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/runner.ts (1)

3688-3700: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear persisted retry counts when resetting a step.

retryCount is retained across the reset, so a step that succeeds on its first resumed attempt can still report old retries. Reset it in memory and in the DB patch.

Proposed fix
         state.row.status = 'pending';
         state.row.error = undefined;
         state.row.completionReason = undefined;
+        state.row.retryCount = 0;
         await this.db.updateStep(state.row.id, {
           status: 'pending',
           error: undefined,
           completionReason: undefined,
+          retryCount: 0,
           updatedAt: new Date().toISOString(),
         });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/runner.ts` around lines 3688 - 3700, Update the
failed/running step reset loop to clear retryCount alongside status, error, and
completionReason. Reset state.row.retryCount in memory and include retryCount
reset in the updateStep database patch, preserving the existing pending-reset
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/run.ts`:
- Around line 56-61: Update the resume flow in run.ts to pass the parsed config
to WorkflowRunner.resume(), ensuring cache-only reconstruction via
reconstructRunFromCache() receives the required configuration. Preserve the
existing runner setup and resume behavior otherwise.

---

Outside diff comments:
In `@packages/core/src/runner.ts`:
- Around line 3688-3700: Update the failed/running step reset loop to clear
retryCount alongside status, error, and completionReason. Reset
state.row.retryCount in memory and include retryCount reset in the updateStep
database patch, preserving the existing pending-reset behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b3541e5-0f59-4bbc-9b53-1f2412a1a45f

📥 Commits

Reviewing files that changed from the base of the PR and between b086a87 and e2a2e73.

📒 Files selected for processing (5)
  • packages/core/src/__tests__/resume-fallback.test.ts
  • packages/core/src/__tests__/run-persistence.test.ts
  • packages/core/src/builder.ts
  • packages/core/src/run.ts
  • packages/core/src/runner.ts

Comment thread packages/core/src/run.ts

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

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/core/src/runner.ts Outdated
// a running step after a run is resumed.
for (const [, state] of stepStates) {
if (state.row.status === 'failed') {
if (state.row.status === 'failed' || state.row.status === 'running') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify ownership before requeueing running steps

When another process calls --resume while the original runner is still alive, resume() accepts the run because its status is running, and this branch unconditionally changes every active step to pending. The resumed scheduler then executes those steps again while the original process may still be executing them, duplicating non-idempotent commands or agent side effects and racing JSONL updates. Only requeue a running step after confirming that its owning runner is no longer active, or reject resumes of actively leased runs.

Useful? React with 👍 / 👎.

- run.ts: pass the parsed config to resume(). Without it, cache-only resume
  fails when workflow-runs.jsonl is unavailable, because resume() needs the
  config for reconstructRunFromCache(). This bug predates the PR; builder.ts
  was already passing it.

- runner.ts: clear retryCount when resetting a step, so a step that succeeds
  on its first resumed attempt no longer reports stale retries.

- runner.ts: gate the running-step reset behind ResumeOptions.resetRunningSteps
  (default false). Runs carry no lease or heartbeat, so a live owner cannot be
  detected; unconditionally requeueing running steps let a second resume
  re-execute them alongside the original process and duplicate non-idempotent
  side effects. The user-facing resume paths (run.ts, builder.ts) opt in,
  because --resume explicitly means the previous process is gone. A real
  ownership lease is the proper fix and is out of scope here.
@miyaontherelay

Copy link
Copy Markdown
Contributor Author

Review feedback addressed in e7d8a63. All three findings were valid.

1. run.ts omitted config on resume — fixed

Correct catch, and the bug predates this PR: builder.ts was already passing config, run.ts never did, so cache-only resume was broken on the CLI path whenever workflow-runs.jsonl was unavailable.

2. retryCount retained across reset — fixed

Now cleared in memory and in the updateStep patch, so a step that succeeds on its first resumed attempt no longer reports stale retries.

3. Requeueing running steps could race a live owner — addressed, with a caveat

This one was right and it was my error. My justification in the original commit — "a step cannot legitimately be running when no process owns it" — asserted something the code never checks.

I looked for a way to verify ownership and there isn't one:

  • WorkflowRunRow has no lease, PID, or heartbeat field
  • updateStep only fires on state transitions, so a genuinely-running long step and a crashed one are indistinguishable by updatedAt — a staleness heuristic would requeue live work

So rather than fake a liveness check, the reset is now opt-in:

export interface ResumeOptions {
  /** Requeue steps left in `running` when the run stopped. Off by default. */
  resetRunningSteps?: boolean;
}
  • Library default: off. runner.resume() no longer silently requeues another process's in-flight steps.
  • User-facing paths opt in. run.ts and builder.ts pass true, because --resume explicitly means the previous process is gone.

That keeps crash recovery — the point of the PR — while removing the surprising default.

Not fixed here: the absence of a real ownership lease. Two concurrent --resume invocations on the same run can still collide. That needs a lease or heartbeat on WorkflowRunRow plus liveness checks on resume, which is a larger change than this PR should carry. Tracked as a follow-up.

Verification

bunx tsc --noEmit (packages/core)   clean
run-persistence + resume-fallback   10 passed / 10
full core suite                     802 passed, 9 failed

The 9 failures are the pre-existing run-script.test.ts TypeScript strip-types cases, which fail identically on main at b086a87 (baselined: 9 failed | 17 passed). This PR neither causes nor fixes them.

Test contract updates in this commit:

  • run-persistence.test.ts — builder now passes a 4th arg; assertion asserts resetRunningSteps: true so the opt-in can't silently regress
  • resume-fallback.test.ts — the stale-running test opts in explicitly; the other three resume tests deliberately still exercise the default (opt-out) path

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/__tests__/resume-fallback.test.ts (1)

284-287: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the retry-count and metadata reset explicitly.

The fixture starts with retryCount: 0, error: undefined, and completionReason: undefined, so this test does not prove that stale values are cleared. Seed the running step with non-zero/stale metadata and include retryCount: 0 in the expected update patch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/__tests__/resume-fallback.test.ts` around lines 284 - 287,
Update the resume fallback test around the db.updateStep expectation to seed the
running step with a non-zero retryCount and stale error/completionReason values,
then explicitly expect retryCount: 0 alongside error: undefined and
completionReason: undefined in the update patch. Keep the existing step
identifier and other assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/core/src/__tests__/resume-fallback.test.ts`:
- Around line 284-287: Update the resume fallback test around the db.updateStep
expectation to seed the running step with a non-zero retryCount and stale
error/completionReason values, then explicitly expect retryCount: 0 alongside
error: undefined and completionReason: undefined in the update patch. Keep the
existing step identifier and other assertions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e755d53c-e06d-4081-a78d-e2e70ee0a051

📥 Commits

Reviewing files that changed from the base of the PR and between e2a2e73 and e7d8a63.

📒 Files selected for processing (6)
  • packages/core/src/__tests__/resume-fallback.test.ts
  • packages/core/src/__tests__/run-persistence.test.ts
  • packages/core/src/builder.ts
  • packages/core/src/run.ts
  • packages/core/src/runner.ts
  • packages/core/src/types.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/core/src/builder.ts
  • packages/core/src/run.ts
  • packages/core/src/tests/run-persistence.test.ts

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant