Skip to content

feat(runner): move structured workflows onto a leased, durable run rail - #291

Merged
chrisleekr merged 3 commits into
mainfrom
feat/workflow-rail-outbox
Sep 2, 2026
Merged

feat(runner): move structured workflows onto a leased, durable run rail#291
chrisleekr merged 3 commits into
mainfrom
feat/workflow-rail-outbox

Conversation

@chrisleekr

Copy link
Copy Markdown
Owner

Stack 2 of 3 · base #290 · followed by the isolated runner

Review after #290 merges, or diff against feat/foundation-config-migration.

What this does

Structured workflow-run jobs rode the shared-daemon protocol: the job payload carried a workflowRun ref, any daemon in the fleet claimed it, and src/daemon/workflow-executor.ts ran it in-process. Nothing in that path was durable. A daemon that died mid-run left a workflow_runs row stuck in running with no owner and no deadline, and an enqueue that succeeded while its row insert failed produced a job with no run behind it.

This replaces that rail with leased attempts in Postgres, plus a dispatch outbox so the enqueue and the row commit together.

flowchart TD
    Hook["webhook event handler<br/>issue-comment, issues, pull-request"]:::edge
    Outbox["dispatch-outbox.ts<br/>row + queue job commit together"]:::store
    Runs["runs-store.ts<br/>attempt id, lease, deadline"]:::store
    Mig["migration 017<br/>lease columns + workflow_attempt_commands"]:::store
    Recon["completion-reconciler.ts<br/>result then projection, replay safe"]:::store
    Expiry["workflow-expiry-notifier.ts<br/>tells the author when an attempt expires"]:::store
    Gone["src/daemon/workflow-executor.ts<br/>DELETED with the workflowRun payload field"]:::stop
    Next["stack 3: isolated runner Pod<br/>claims the lease and executes"]:::next

    Hook --> Outbox --> Runs
    Mig --> Runs
    Runs --> Recon --> Expiry
    Runs -.->|"leased attempt,<br/>no executor yet"| Next
    Gone -.->|"old rail removed here"| Next
classDef edge fill:#ecf0f1,color:#2c3e50
classDef store fill:#2c3e50,color:#ffffff
classDef stop fill:#c0392b,color:#ffffff
classDef next fill:#8e44ad,color:#ffffff
Loading

Read this before merging

workflow-run jobs have no executor between this PR and stack 3. This PR deletes the daemon workflow rail; stack 3 adds the runner that replaces it. Jobs enqueue and lease but nothing claims them in between. The two are written to merge together.

That is not an accident of the split, it is the shape of the change: runs-store.ts replaces the five mark* functions the daemon executor called. Keeping both rails alive across the boundary would mean shipping a hybrid store that exists in no final state and that no reviewer could check against anything.

Why migration 017 is here and not in stack 1

It creates workflow_attempt_commands. Every DB-backed test resets its schema from an inline DROP TABLE list, and those lists predate the table, so on any test that resets, the migration replays into relation "workflow_attempt_commands" already exists. The migration is only safe alongside the test updates that know about it. I found this by running the suite against a real Postgres, not by reading the diff.

What is in here

Area Change
Schema 017_workflow_run_leases: attempt/lease/deadline columns on workflow_runs, offer_id + result columns on executions, the workflow_attempt_commands receipt table, repo_memory.content_sha256
Run store runs-store.ts swaps the mark* API for leased attempts; dispatch-outbox.ts commits the row and the queue job together; completion-reconciler.ts stores the terminal result before projections so a crash between them replays instead of losing it
Wire workflowRun leaves the shared job payload; scoped messages take their namespaced form (scoped-job:offer, scoped-job:completion); PROTOCOL_VERSION 2.0.0
Orchestrator connection-handler, job-dispatcher, ws-connection split, workflow-expiry-notifier, installation-token, repo-knowledge content-hash de-duplication
Webhook The four event handlers follow the runs-store API
Removed src/daemon/workflow-executor.ts and its test

Verification

Gate Result
typecheck · lint · format pass, 0 errors
10 check:* gates pass
test 191 / 191 files pass, against live Postgres 17 + Valkey so no suite is silently skipped

🤖 Generated with Claude Code

https://claude.ai/code/session_01KUPpJPtxAaHWrBsjytRGyM

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 103 files, which is 3 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 4e0a1869-25e0-40df-b249-ee10179419ab

📥 Commits

Reviewing files that changed from the base of the PR and between e5c6545 and a7f5231.

📒 Files selected for processing (103)
  • docs/build/architecture.md
  • docs/operate/observability.md
  • docs/operate/runbooks/scheduled-actions.md
  • docs/use/workflows/review.md
  • src/core/pipeline.ts
  • src/core/tracking-comment.ts
  • src/daemon/job-executor.ts
  • src/daemon/main.ts
  • src/daemon/process-boundary.ts
  • src/daemon/scoped-rebase-executor.ts
  • src/daemon/workflow-executor.ts
  • src/daemon/ws-client.ts
  • src/db/migrations/017_workflow_run_leases.sql
  • src/orchestrator/connection-handler.ts
  • src/orchestrator/history.ts
  • src/orchestrator/installation-token.ts
  • src/orchestrator/job-dispatcher.ts
  • src/orchestrator/job-queue.ts
  • src/orchestrator/log-fields.ts
  • src/orchestrator/repo-knowledge-persistence.ts
  • src/orchestrator/repo-knowledge.ts
  • src/orchestrator/workflow-expiry-notifier.ts
  • src/orchestrator/ws-connection.ts
  • src/shared/daemon-types.ts
  • src/shared/dispatch-types.ts
  • src/shared/workflow-types.ts
  • src/shared/ws-messages.ts
  • src/webhook/auto-review-guard.ts
  • src/webhook/dispatch-failure.ts
  • src/webhook/events/issue-comment.ts
  • src/webhook/events/issues.ts
  • src/webhook/events/pull-request.ts
  • src/webhook/events/review-comment.ts
  • src/workflows/completion-reconciler.ts
  • src/workflows/dispatch-outbox.ts
  • src/workflows/dispatcher.ts
  • src/workflows/execution-row.ts
  • src/workflows/handlers/implement.ts
  • src/workflows/handlers/plan.ts
  • src/workflows/handlers/remember.ts
  • src/workflows/handlers/resolve.ts
  • src/workflows/handlers/review.ts
  • src/workflows/handlers/ship.ts
  • src/workflows/handlers/triage.ts
  • src/workflows/orchestrator.ts
  • src/workflows/registry.ts
  • src/workflows/runs-store.ts
  • src/workflows/ship/command-dispatch.ts
  • src/workflows/ship/iteration.ts
  • src/workflows/ship/scoped/chat-thread.ts
  • src/workflows/ship/scoped/dispatch-scoped.ts
  • src/workflows/ship/session-runner.ts
  • src/workflows/tracking-mirror.ts
  • test/core/pipeline.test.ts
  • test/core/tracking-comment.test.ts
  • test/daemon/job-executor.test.ts
  • test/daemon/scoped-offer-evaluator.test.ts
  • test/daemon/workflow-executor.test.ts
  • test/daemon/ws-client.test.ts
  • test/db/migrate.test.ts
  • test/db/migrations/008.test.ts
  • test/integration/repo-knowledge.test.ts
  • test/integration/scoped-rebase-roundtrip.test.ts
  • test/integration/ship-iteration-loop.test.ts
  • test/integration/ship-tickle-resume.test.ts
  • test/integration/telemetry-aggregates.test.ts
  • test/orchestrator/connection-handler.test.ts
  • test/orchestrator/history.test.ts
  • test/orchestrator/installation-token.test.ts
  • test/orchestrator/job-dispatcher.test.ts
  • test/orchestrator/job-queue.test.ts
  • test/orchestrator/log-fields.test.ts
  • test/orchestrator/repo-knowledge-persistence.test.ts
  • test/orchestrator/workflow-expiry-notifier.test.ts
  • test/shared/dispatch-types.test.ts
  • test/shared/scoped-ws-messages.test.ts
  • test/shared/ws-messages.test.ts
  • test/webhook/auto-review-guard.test.ts
  • test/webhook/events/dispatch-failure.test.ts
  • test/webhook/events/issue-comment.test.ts
  • test/webhook/events/pull-request-auto-review.test.ts
  • test/webhook/events/pull-request-config-check.test.ts
  • test/workflows/dispatch-outbox.test.ts
  • test/workflows/dispatcher.test.ts
  • test/workflows/handlers/implement.test.ts
  • test/workflows/handlers/plan.test.ts
  • test/workflows/handlers/remember.test.ts
  • test/workflows/handlers/resolve.test.ts
  • test/workflows/handlers/review.test.ts
  • test/workflows/handlers/ship.test.ts
  • test/workflows/handlers/triage.test.ts
  • test/workflows/orchestrator.test.ts
  • test/workflows/runs-store.test.ts
  • test/workflows/ship/cancellation.test.ts
  • test/workflows/ship/command-dispatch.test.ts
  • test/workflows/ship/fix-attempts.test.ts
  • test/workflows/ship/iteration-cap.test.ts
  • test/workflows/ship/iteration.test.ts
  • test/workflows/ship/lifecycle-commands.test.ts
  • test/workflows/ship/session-runner.resume.test.ts
  • test/workflows/ship/session-runner.test.ts
  • test/workflows/ship/tickle-scheduler.test.ts
  • test/workflows/tracking-mirror.test.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

@chrisleekr
chrisleekr force-pushed the feat/workflow-rail-outbox branch 3 times, most recently from 26b2744 to 06cf58f Compare September 2, 2026 08:49
Repository owner deleted a comment from coderabbitai Bot Sep 2, 2026
Repository owner deleted a comment from coderabbitai Bot Sep 2, 2026
Base automatically changed from feat/foundation-config-migration to main September 2, 2026 09:05
Stack 2 of 3, base `feat/foundation-config-migration`.

Structured `workflow-run` jobs previously rode the shared-daemon protocol:
the payload carried a `workflowRun` ref, a daemon claimed it, and
`src/daemon/workflow-executor.ts` ran it in-process. This replaces that
rail with a leased attempt model in the database, plus a dispatch outbox
so an enqueue and its `workflow_runs` row commit together.

- migration `017_workflow_run_leases`: attempt/lease/deadline columns on
  `workflow_runs`, `offer_id` + result columns on `executions`, the
  `workflow_attempt_commands` receipt table, `repo_memory.content_sha256`.
- `src/workflows/runs-store.ts`: the `mark*` helpers give way to a
  lease-based API. `dispatch-outbox.ts` and `completion-reconciler.ts`
  close the enqueue/commit and result/projection gaps.
- `src/shared/ws-messages.ts`: `workflowRun` leaves the shared job payload,
  and the scoped message names take their namespaced form.
- `src/daemon/workflow-executor.ts` is deleted along with it.
- The webhook event handlers, connection handler, job dispatcher and
  daemon entrypoint follow the runs-store API change.

Migration 017 lands here, not in stack 1: it creates
`workflow_attempt_commands`, and DB tests reset their schema from inline
DROP lists, so the migration is only safe with the test updates that know
about that table.

`workflow-run` jobs have no executor between this and stack 3. The two are
written to merge together; this one alone leaves structured workflows
enqueued but unclaimed.

Verified: typecheck, lint, format, all check gates, 191/191 test files
against live Postgres 17 + Valkey.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KUPpJPtxAaHWrBsjytRGyM
@chrisleekr
chrisleekr force-pushed the feat/workflow-rail-outbox branch from 06cf58f to e4d0f95 Compare September 2, 2026 09:05
Repository owner deleted a comment from coderabbitai Bot Sep 2, 2026
Comment thread src/workflows/runs-store.ts
Comment thread src/daemon/ws-client.ts
Comment thread src/orchestrator/connection-handler.ts
Comment thread src/orchestrator/connection-handler.ts
Comment thread src/shared/dispatch-types.ts
Comment thread src/orchestrator/workflow-expiry-notifier.ts Outdated
Comment thread src/orchestrator/workflow-expiry-notifier.ts Outdated
Comment thread src/workflows/runs-store.ts
Comment thread src/workflows/runs-store.ts
Comment thread src/orchestrator/repo-knowledge.ts
Comment thread src/webhook/events/pull-request.ts Outdated
Comment thread src/webhook/auto-review-guard.ts Outdated
Comment thread src/workflows/dispatch-outbox.ts Outdated
Comment thread src/workflows/completion-reconciler.ts
Comment thread src/webhook/auto-review-guard.ts
Repository owner deleted a comment from coderabbitai Bot Sep 2, 2026
Durability bugs:

- markWorkflowFailureNotified is now a real compare-and-set. The
  COALESCE kept the first timestamp but the row still matched the WHERE
  and still RETURNed, so two reconcilers both saw success and both
  commented on the same PR.
- An unresolvable parent chain writes its receipt instead of skipping.
  findTopAncestor returns null only for a row that is not there, and
  findPendingWorkflowFailureNotifications selects on
  failure_notified_at IS NULL, so the row was re-selected every pass
  forever with no path to clearing.
- saveRepoLearnings regains its per-item guard. The targeted ON CONFLICT
  covers idx_repo_memory_learning_unique only, so it cannot absorb a
  violation of idx_repo_memory_env_unique; category is a bare string on
  the wire, and the caller only logs, so one re-sent env_var learning
  discarded the whole batch including deletions.
- reconcilePendingWorkflowCascades mints an installation Octokit per row.
  It ran with emitGitHub: false while markAttemptCascadeCompleted wrote
  the receipt, so a crash between attempt completion and cascade
  permanently swallowed the outcome comment.

Availability:

- A protocol-incompatible close now calls onFatal, and the daemon exits
  non-zero. Reconnect is disabled for the process lifetime on that path,
  so the daemon was parked live, idle and healthy-looking, with only an
  info log, even after the orchestrator was upgraded.
- Daemon re-registration no longer waits on GitHub. The disconnect
  cleanup awaited notifyDisconnectedDaemonWorkflows, which walks parent
  chains and posts per stranded run, so a flapping daemon held itself out
  of the pool for as long as GitHub was slow. The notification is
  detached but still drained on shutdown, and its receipt makes it
  retryable.

Observability and contract:

- Each failure-notice kind carries its own token-mint `via`; all five
  reported notifyExpiredWorkflowAttempts, so a dispatch expiry and a
  daemon disconnect were indistinguishable in the audit trail.
- publishWorkflowRunById logs when a row is not eligible, so a stranded
  run is distinguishable from a benign no-op.
- isSelfPush warns when self-login resolution fails, since the module
  docstring claims an invariant that a null login relaxes.
- The auto-review claimDelivery moves ahead of the paginated listFiles,
  per the idempotency contract in CLAUDE.md.

Docs and dormancy:

- observability.md and architecture.md carry the second DispatchTarget
  and fifth DispatchReason; the TOKEN_MINT_VIA list in observability.md
  named a mint site that no longer exists and missed four that do.
- The five sweeps and the two shutdown helpers say in their docstrings
  that nothing schedules them on this branch and name where the
  isolated-runner slice wires each one, so the gap reads as staged rather
  than as an oversight.
- hasActiveShipIntent gains the four-branch test the other guards have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KUPpJPtxAaHWrBsjytRGyM
@chrisleekr-bot

chrisleekr-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

bot workflow review, succeeded

🔍 Code review complete, 103 files, +10924/-2499.

Summary

Second-pass review of the leased, durable run rail (103 files, +10924/-2499). The prior round's 15 findings are all addressed in da77eb3 and I re-verified each one against current code rather than trusting the replies; none are re-raised here.

The core design holds up. Every mutating write in runs-store.ts is fenced on (id, attempt_id, status='running', lease_expires_at > now(), attempt_deadline_at > now()) and throws StaleWorkflowAttemptError on a zero-row result, the dispatch outbox commits the workflow_runs row and its executions row in one transaction and publishes only after commit, and the completion reconciler stores the terminal result before the projections so a crash replays instead of losing it. Validation is clean: bun run typecheck passes, bun run lint reports 0 errors, and the isolated test run produced no assertion failures.

2 findings: 1 major, 1 minor. Nothing blocking.

What was checked

  • Read in full: runs-store.ts, dispatch-outbox.ts, completion-reconciler.ts, orchestrator.ts, dispatcher.ts, registry.ts, execution-row.ts, workflow-expiry-notifier.ts, auto-review-guard.ts, repo-knowledge.ts, repo-knowledge-persistence.ts, webhook/events/pull-request.ts, handlers/{review,resolve,remember}.ts, 017_workflow_run_leases.sql, and the relevant ranges of connection-handler.ts.
  • Prior-round fixes re-verified as landed: the markWorkflowFailureNotified CAS is now genuinely conditional on failure_notified_at IS NULL (runs-store.ts:456-471); findTopAncestor returning null now logs workflow.failure_notice_unresolvable and writes the receipt before continue; each notice kind carries a distinct via; disconnect notifications are detached from the handleRegister fence while still joining disconnectCleanups; saveRepoLearnings regained its per-item try/catch; isSelfPush degrades with a warn rather than failing closed; publishWorkflowRunById warns workflow.dispatch_publish_skipped; the cascade reconciler mints an Octokit per row.
  • Dormancy claims audited: all seven dormant sweeps carry the docstring naming where feat(runner): isolate structured workflows in one-attempt Kubernetes Pods #292 wires them. Per repo policy these are deliberate and are not reported.
  • Concurrency: lease fences, the dispatch_generation_id/dispatch_enqueued_at CAS pair, markAttemptCascadeCompleted, and the clearTrackingCommentIdForAttempt CTE fence. persistRepoKnowledge was traced to its only production caller (connection-handler.ts:1510) to confirm it runs on a pooled connection, not inside a transaction — so the per-item unique_violation catch in saveRepoLearnings actually works rather than poisoning a surrounding tx.
  • Protocol: an independent pass confirmed PROTOCOL_VERSION 2.0.0 is consistent across shared/daemon/orchestrator and that the scoped-job:offer / scoped-job:completion renames are synchronized on both the sender and every receiver.
  • Repo gates: doc-sync satisfied (src/shared/dispatch-types.ts changed alongside docs/operate/observability.md and docs/build/architecture.md); claimDelivery ordering in the new auto-review path is suffixed per-branch and precedes all GitHub I/O.
  • Validation: bun run typecheck clean; bun run lint 0 errors (581 pre-existing warnings); bash scripts/test-isolated.sh test/workflows test/webhook and targeted suites produced no assertion failures. DB-gated suites skip in this sandbox (no Postgres), so the runs-store / orchestrator / dispatch-outbox integration assertions were read rather than executed.
  • Deliberately not flagged: the deletion of src/daemon/workflow-executor.ts, the absence of a workflow-run executor, and the unscheduled sweeps — all land with feat(runner): isolate structured workflows in one-attempt Kubernetes Pods #292 by design.

Findings

  • [major] src/workflows/handlers/review.ts:217, resolve.ts:336, and remember.ts:167 swallow StaleWorkflowAttemptError instead of re-throwing it. ctx.setState routes through mergeAttemptState, which raises the fence when another attempt has taken over the run. The other five handlers re-throw (implement.ts:194,305, plan.ts:141,289, ship.ts:103, triage.ts:255,503, tracking-mirror.ts:214,280,293,422,512) and three have tests asserting it; these three do not even import the symbol. The result is that a losing attempt reports review failed: workflow attempt is no longer current as a terminal user-visible outcome and races the winning attempt for the projection, instead of unwinding quietly. Fix: re-throw as the first line of each outer catch, and extend the handler tests.

  • [minor] src/workflows/runs-store.ts:318commitAttemptHandOffChild inserts the child with execution_delivery_id = childRunId but writes no paired executions row, unlike dispatcher.ts:124, orchestrator.ts:279, and ship/iteration.ts:196 (whose comment warns that committing only one row strands the target). Unpaired, the inner join in loadPendingDispatch (dispatch-outbox.ts:32) matches nothing, so the child never publishes and cannot be recovered by publishPendingWorkflowRuns either. test/workflows/runs-store.test.ts:692 encodes the correct usage, but the caller obligation is absent from the docstring and the sole caller ships in feat(runner): isolate structured workflows in one-attempt Kubernetes Pods #292. Fix: fold recordWorkflowExecution into the function, or document the obligation at runs-store.ts:295.

Reasoning

I treated this as a second pass, so the first task was separating genuinely new risk from ground already covered. Several things that would read as bugs in isolation are settled decisions here: the dormant sweeps, the deleted workflow executor, and the full-table backfill in migration 017 all carry comments explaining that the paired half lands in #292. Re-raising them would be noise, so I verified the docstrings exist and moved on. I also persisted the dormancy convention as a repo review-policy directive so future passes don't relitigate it.

Both findings come from the same technique: comparing a code path against its own siblings. The rail establishes two clear conventions — a fence exception propagates rather than being converted into a result, and a workflow_runs row is never committed without its executions partner — and each finding is a single site that departs from a convention the other three-to-five sites follow. That framing is also why I settled on major rather than blocker for the first one. The consumer that distinguishes a thrown fence from a returned status: "failed" is the isolated runner in #292, so neither defect can bite until the stack completes; both are latent, and both are cheap to fix now while the convention is fresh.

I discarded two candidates after tracing them. The cascade reconciler mints an Octokit before applying the DB cascade and only logs on failure, which initially looked like a permanently stuck parent — but the failed row stays in the selection set and is retried by the next sweep, so it is deferred, not lost. And commitAttemptHandOffChild leaving the parent at status='running' with a null lease looked like a row no sweep can reclaim, until I confirmed the parent's liveness is intentionally tied to the child's: whatever terminal state the child reaches, including expiry, cascades back through onStepComplete. Reporting either would have cost more trust than it bought.

cost: $4.4408 · turns: 66 · duration: 1121s

Comment thread src/workflows/handlers/review.ts
Comment thread src/workflows/runs-store.ts
…emember

The three handlers converted every error into `status: "failed"`, including
`StaleWorkflowAttemptError`. A lost lease is a transient fence loss, not a
workflow failure, so the run was reported terminally failed to the user while
another attempt held the lease. The other five handlers already re-throw.

Also document that `commitAttemptHandOffChild` writes only the `workflow_runs`
half of the durable pair: the dispatch outbox joins `executions` on
`execution_delivery_id`, so a caller that skips `recordWorkflowExecution` in
the same transaction strands the target behind the in-flight index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KUPpJPtxAaHWrBsjytRGyM
@chrisleekr
chrisleekr merged commit dcb029a into main Sep 2, 2026
10 checks passed
@chrisleekr
chrisleekr deleted the feat/workflow-rail-outbox branch September 2, 2026 11:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant