Skip to content

fix(flue-review): recover interrupted workflows#2053

Merged
ascorbic merged 6 commits into
mainfrom
fix/flue-review-interruption-recovery
Jul 15, 2026
Merged

fix(flue-review): recover interrupted workflows#2053
ascorbic merged 6 commits into
mainfrom
fix/flue-review-interruption-recovery

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Recovers automated reviews when a Flue workflow is interrupted, while preventing concurrent or duplicate reviews. Recovery checks the persisted Flue run status, fences superseded runs, retries admission twice, and preserves the original GitHub Check Run.

It also resets the incompatible pre-1.0 FlueRegistry schema using a two-deployment class migration, and makes the Check Run status useful with a human-readable progress checklist, next-step guidance, and relevant details links.

The production reset was applied. PR #2044's replacement review and PR #2053's post-reset review both completed successfully.

Follow-up to #2019.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). N/A: no admin UI strings.
  • I have added a changeset (if this PR changes a published package). Empty changeset added for private deployment infrastructure.
  • New features link to an approved Discussion: N/A, bug fix.

AI-generated code disclosure

  • This PR includes AI-generated code - model/tool: GPT-5.6 Sol with adversarial review passes

Screenshots / test output

  • pnpm build
  • pnpm typecheck
  • pnpm lint
  • pnpm --dir infra/flue-review test (34 tests)
  • pnpm --dir infra/flue-review build
  • pnpm lint:json | jq '.diagnostics | length' (0)
  • git diff --check

Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks fix/flue-review-interruption-recovery. Updated automatically when the playground redeploys.

Copilot AI review requested due to automatic review settings July 15, 2026 09:52
@changeset-bot

changeset-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4f9a1d7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 788 lines across 10 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 15, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 15, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 4f9a1d7 Jul 15 2026, 11:33 AM

@pkg-pr-new

pkg-pr-new Bot commented Jul 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2053

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2053

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2053

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2053

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2053

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2053

emdash

npm i https://pkg.pr.new/emdash@2053

create-emdash

npm i https://pkg.pr.new/create-emdash@2053

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2053

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2053

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2053

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2053

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2053

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2053

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2053

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2053

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2053

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2053

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2053

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2053

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2053

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2053

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2053

commit: 4f9a1d7

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 15, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 4f9a1d7 Jul 15 2026, 11:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 15, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do 4f9a1d7 Jul 15 2026, 11:32 AM

Copilot AI 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.

Pull request overview

This PR hardens the infra/flue-review automated review worker so interrupted Flue workflows can be recovered safely (without duplicate/concurrent reviews), while preserving the original GitHub Check Run and adding a two-step Durable Object class reset for an incompatible FlueRegistry schema.

Changes:

  • Adds stage-aware staleness deadlines and extends ReviewWatchdog to reconcile Flue run status, fence superseded runs, and re-admit stalled workflows with a bounded retry budget.
  • Prevents duplicate PR review posts by probing for an existing attempt marker across paginated GitHub review pages and failing closed when marker inspection is unavailable.
  • Introduces a shared admitReviewWorkflow() helper and wires expectedRunId through the workflow payload to support fencing/ownership claims during recovery.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.

Show a summary per file
File Description
infra/flue-review/wrangler.jsonc Appends DO migration tags to reset the FlueRegistry class via drop+recreate.
infra/flue-review/test/review-watchdog.test.ts Extends unit coverage for stage-specific staleness deadlines.
infra/flue-review/test/review-watchdog-do.test.ts Adds DO-level tests for recovery, fencing, admission retries, and reconciliation behaviors.
infra/flue-review/test/github-checks.test.ts Adds tests covering marker pagination and “fail closed” behavior when marker inspection is unavailable.
infra/flue-review/.flue/workflows/review.ts Threads expectedRunId and runId through watchdog RPC calls to enforce run fencing.
infra/flue-review/.flue/lib/workflow-admission.ts New shared workflow admission helper used by both webhook path and watchdog recovery.
infra/flue-review/.flue/lib/review-watchdog.ts Introduces stage-based staleness thresholds and updates watchdog RPC signatures.
infra/flue-review/.flue/lib/github.ts Implements paginated attempt-marker lookup and integrates it into postReview() to avoid duplicates.
infra/flue-review/.flue/cloudflare.ts Implements watchdog recovery logic (run inspection, bounded re-admission, hard ceiling for “active” stalls) and enforces runId matching for heartbeat/finish.
infra/flue-review/.flue/app.ts Persists workflow input for re-admission and routes initial admission through the new helper with expectedRunId.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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

This is a well-scoped bug fix for the Flue review deployment infrastructure, and the approach is sound. It layers two defenses against duplicate or concurrent reviews: (1) ReviewWatchdog now fences superseded workflow runs with expectedRunId/runId checks and re-admits interrupted runs only when Flue reports them as errored/missing, and (2) postReview now checks for an existing attempt marker before posting and paginates through review pages. The stage-aware stale timeouts and the active-run hard ceiling cover the common interruption modes, and the existing GitHub Check Run is preserved throughout recovery.

I traced the admission flow from app.ts through workflow-admission.ts, the DO state machine in cloudflare.ts, and the workflow in review.ts, and checked that every changed RPC signature (identify, heartbeat, finish) is called with the new runId parameter. The recovery budget is bounded (WORKFLOW_RETRY_LIMIT = 2), the alarm is re-armed on every branch that doesn't terminalize, and the re-read + consistency check in alarm() guards against races with concurrent terminalization. The added tests in review-watchdog-do.test.ts and github-checks.test.ts cover the retry, fencing, active/completed reconciliation, duplication suppression, and pagination paths.

No AGENTS.md violations: this is private deployment infrastructure so no changeset or i18n is required; there is no SQL or admin UI; migrations are append-only. No blocking issues or regressions found.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-review No maintainer or bot review yet labels Jul 15, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/approved Approved; no new commits since labels Jul 15, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 15, 2026

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

This remains a well-scoped fix for the private Flue review deployment: it layers Flue-run status checks, expectedRunId/runId fencing, bounded retry admission, and attempt-marker reconciliation to prevent duplicate/concurrent reviews while recovering interrupted workflows. The approach still fits EmDash's architecture (trusted workflow DO owns GitHub posting; the public handler only admits; migrations are append-only; no admin UI/SQL changes).

As a re-review: my prior review had no findings, and the current diff is the same shape I approved. I re-traced the admission flow from app.ts/workflow-admission.ts into the watchdog DO and the review.ts workflow, confirmed the updated RPC signatures (identify, heartbeat, finish) are used consistently with the new runId parameter, and verified the tests cover retry/fencing/active/completed reconciliation and pagination.

No blockers or regressions. I have two low-priority suggestions: put a page ceiling on the marker pagination loop, and consider Flue’s isError flag when interpreting a "completed" run during recovery.

Comment thread infra/flue-review/.flue/lib/github.ts Outdated
Comment on lines +508 to +528
for (let page = 1; ; page++) {
const suffix = page === 1 ? "" : `&page=${page}`;
const res = await githubFetch(
`${GITHUB_API}/repos/${owner}/${repo}/pulls/${prNumber}/reviews?per_page=100${suffix}`,
{ headers: installationHeaders(token) },
);
if (!res.ok) {
return {
status: "unavailable",
error: new Error(`review marker inspection failed: ${res.status}`),
};
}
const reviews = await res.json<Array<{ body?: string; commit_id?: string }>>();
if (
reviews.some(
(review) => review.commit_id === commitId && review.body?.includes(marker) === true,
)
) {
return { status: "present" };
}
if (reviews.length < 100) return { status: "absent" };

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.

[suggestion] The new marker pagination loop is unbounded. It keeps fetching pages until it finds the marker or GitHub returns fewer than 100 reviews. For a pathological PR with many review pages this can issue a large, sequential chain of API calls, blowing the duplicate-check request budget or hitting the 30s fetch timeout before the function resolves.

Cap the loop at a reasonable page limit and return absent once that limit is reached.

Suggested change
for (let page = 1; ; page++) {
const suffix = page === 1 ? "" : `&page=${page}`;
const res = await githubFetch(
`${GITHUB_API}/repos/${owner}/${repo}/pulls/${prNumber}/reviews?per_page=100${suffix}`,
{ headers: installationHeaders(token) },
);
if (!res.ok) {
return {
status: "unavailable",
error: new Error(`review marker inspection failed: ${res.status}`),
};
}
const reviews = await res.json<Array<{ body?: string; commit_id?: string }>>();
if (
reviews.some(
(review) => review.commit_id === commitId && review.body?.includes(marker) === true,
)
) {
return { status: "present" };
}
if (reviews.length < 100) return { status: "absent" };
for (let page = 1; page <= 10; page++) {
const suffix = page === 1 ? "" : `&page=${page}`;
const res = await githubFetch(
`${GITHUB_API}/repos/${owner}/${repo}/pulls/${prNumber}/reviews?per_page=100${suffix}`,
{ headers: installationHeaders(token) },
);
if (!res.ok) {
return {
status: "unavailable",
error: new Error(`review marker inspection failed: ${res.status}`),
};
}
const reviews = await res.json<Array<{ body?: string; commit_id?: string }>>();
if (
reviews.some(
(review) => review.commit_id === commitId && review.body?.includes(marker) === true,
)
) {
return { status: "present" };
}
if (reviews.length < 100) return { status: "absent" };
}
return { status: "absent" };

Comment thread infra/flue-review/.flue/cloudflare.ts Outdated
): Promise<"active" | "completed" | "recoverable" | "unavailable"> {
try {
const run = await getRun(attempt.runId);
if (!run || run.status === "errored") return "recoverable";

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.

[suggestion] workflowStatus only looks at run.status when deciding that a run has completed successfully. The admin endpoint in app.ts exposes r.isError on the same getRun record, so Flue appears to track completion and error status independently. If a run can return status: "completed" with isError: true, this branch would mark the GitHub Check Run as successful even though the review did not complete cleanly.

Treat isError: true as recoverable to be safe.

Suggested change
if (!run || run.status === "errored") return "recoverable";
if (!run || run.status === "errored" || run.isError === true) return "recoverable";
return run.status;

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 15, 2026
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 15, 2026

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

This re-review confirms the Flue review recovery approach is sound: the watchdog DO inspects persisted run status, expectedRunId/runId fencing prevents superseded admissions from claiming ownership, bounded retry admission + attempt-marker reconciliation cover the interruption cases, and the migrations are the required forward-only two-step reset. The change stays inside the private infra/flue-review deployment, so the core EmDash conventions (Lingui, RTL, SQL safety, API envelopes, locale filtering) don't apply, and the empty changeset is appropriate.

However, this pass also validates that two concerns raised in the prior review are real:

  1. workflowStatus ignores Flue's isError flag — the run record returned by getRun carries isError (see app.ts line 93), so a workflow can end with status === "completed" and isError: true. The watchdog currently reports that as a successful review on GitHub. This is a correctness bug, not just a suggestion.
  2. Unbounded pagination in reviewWasPosted — the marker-inspection loop has no page ceiling, so a pathological many-page response could exhaust time/rate limits before posting.

The new tests otherwise cover retry/fencing/active/completed reconciliation and the pre-post marker check well. I recommend fixing the isError handling before merging and adding a page ceiling (with a closed fallback) as a follow-up hardening step.

Comment thread infra/flue-review/.flue/cloudflare.ts Outdated
): Promise<"active" | "completed" | "recoverable" | "unavailable"> {
try {
const run = await getRun(attempt.runId);
if (!run || run.status === "errored") return "recoverable";

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.

[needs fixing] workflowStatus treats any Flue run whose status is "completed" as a successful review and marks the GitHub check as success. But the run record returned by getRun also carries an isError flag (used in .flue/app.ts line 93), so a workflow can finish with status === "completed" and isError: true. In that case the watchdog reports success even though the review failed or was never posted.

Treat an errored-completed run as recoverable (so the bounded retry path can attempt recovery) rather than success, and add a test case for getRun returning { status: "completed", isError: true }.

Suggested change
if (!run || run.status === "errored") return "recoverable";
if (!run || run.status === "errored" || run.isError) return "recoverable";

Comment thread infra/flue-review/.flue/lib/github.ts Outdated
);
} catch {
return false;
for (let page = 1; ; page++) {

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.

[suggestion] reviewWasPosted paginates through PR reviews with an unbounded for (let page = 1; ; page++) loop. If GitHub ever returns a pathological response where every page contains exactly 100 items, this will keep fetching until the surrounding timeout kills it, wasting tokens and time. Add a small page ceiling and fail closed when it is exceeded.

Change the loop header:

Suggested change
for (let page = 1; ; page++) {
const MAX_REVIEW_PAGES = 10;
for (let page = 1; page <= MAX_REVIEW_PAGES; page++) {

And return unavailable after the loop so duplicate posting is not risked:

Suggested change
for (let page = 1; ; page++) {
return {
status: "unavailable",
error: new Error("review marker inspection exceeded page limit"),
};

@github-actions github-actions Bot added the review/needs-rereview Author pushed changes since the last review label Jul 15, 2026
@github-actions github-actions Bot removed the review/approved Approved; no new commits since label Jul 15, 2026
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 15, 2026
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 15, 2026

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

This re-review confirms the recovery approach is sound and the two issues flagged in the prior review are now fixed.

Approach judgment: The PR correctly targets Flue workflow interruption recovery inside the private infra/flue-review deployment. It inspects the persisted run status, fences superseded admissions with expectedRunId/runId, retries admission a bounded number of times, preserves the original GitHub Check Run, and updates the check with useful progress copy. The change stays within the private deployment infrastructure, so the core EmDash conventions (Lingui, RTL, SQL safety, API envelopes, locale filtering) do not apply, and the empty changeset is appropriate.

What was fixed since the prior review:

  1. workflowStatus now respects isError. infra/flue-review/.flue/cloudflare.ts treats run.isError (and run.status === "errored" / missing run) as "recoverable", so a workflow that ends with status === "completed" but isError: true no longer gets reported as a successful review.
  2. reviewWasPosted pagination is now bounded. infra/flue-review/.flue/lib/github.ts caps the marker-inspection loop at REVIEW_LOOKUP_MAX_PAGES = 10 and returns "unavailable" when the ceiling is hit, failing closed instead of paging indefinitely.

What I checked:

  • Read the full diff and the changed source files.
  • Traced the watchdog alarm, recovery retry, workflow admission, heartbeat/finish fencing, and postReview idempotency paths.
  • Verified the new tests cover both prior concerns plus retry/fencing/active/completed reconciliation.
  • Confirmed the change is scoped to infra/flue-review plus an empty changeset; no core package or admin UI conventions are involved.

No new blocking issues or regressions were found.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 15, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 15, 2026
@ascorbic ascorbic merged commit c786fd4 into main Jul 15, 2026
49 checks passed
@ascorbic ascorbic deleted the fix/flue-review-interruption-recovery branch July 15, 2026 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: signed review/approved Approved; no new commits since size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants