Skip to content

fix(flows): recover orphaned runs and add Stop button - #478

Merged
Inakitajes merged 24 commits into
mainfrom
fix/flow-orphaned-runs-stop-button
Sep 15, 2026
Merged

Inakitajes merged 24 commits into
mainfrom
fix/flow-orphaned-runs-stop-button

Conversation

@albertoperdomo2

@albertoperdomo2 albertoperdomo2 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Flow-execution reliability umbrella: the stop button plus fixes for eight defects found while testing flow runs in production between 2026-08-31 and 2026-09-02. Each problem is described inline below with its fix. Absorbs #497 and #500 (both will be closed when this merges).


1. The scheduler never starts on archectl deployments

Web containers crash-loop with:

[flows] Failed to start scheduler Error: ARCHE_FLOW_SCHEDULER_MODE is required in production (missing value)

archectl never wrote that variable, while the Ansible path sets it — so scheduled flows and stale-run recovery never ran on one-click deployments at all.

Fix: renderEnvFile, the bootstrap heredoc, and an idempotent set_env in the update script all write ARCHE_FLOW_SCHEDULER_MODE=daemon; the compose stack gains a flows service running src/flow-daemon.ts, and the update script recreates it alongside web.

2. Gateway tokens expired mid-flow (APIError: Unauthorized: invalid_token)

The runner synced provider access once per flow, gateway tokens live 15 min by default, and two real runs died: a 17-minute run whose token expired between steps, and a run that started 7 minutes after an interactive visit and inherited a partially aged token. invalid_token was not retryable, so expiry failed the whole run terminally.

Fix: force a provider-access refresh at flow start (full-TTL token at step 1), force it again between steps — a freshness-threshold check is useless here because multi-minute steps rarely land in a threshold window — and classify invalid_token as retryable so any residual expiry costs one step re-run instead of the run. No token-scheme changes: same short TTL, same per-user/workspace/provider scoping, refreshes serialized by the existing per-slug lock.

3. Cancel aborted unrelated generations, then the guard for it created a stale-config trap

Cancelling a run was followed by a 4-minute cascade: six fresh runs each aborted within seconds (MessageAbortedError: Aborted), self-healing only after the sync cadence settled. Root cause: provider syncs end with POST /instance/dispose (which aborts any in-flight generation), and the active-run deferral was checked before the multi-second credential PUTs — a run registering during that window was disposed mid-generation.

Fix: re-check active runs immediately before the dispose, after the writes. Because that skip can leave the instance on stale cached provider discovery, a skipped dispose now withholds the sync-state record, so the next idle-boundary sync re-writes credentials and disposes instead of short-circuiting on a fresh timestamp. Dispose decisions and session-family abort targets are logged so any future MessageAbortedError is correlatable to its source.

Additionally: after a dispose the sync now waits for /global/health to report healthy (bounded 30s) before returning — dispose exits the OpenCode process and Docker restarts it (5–15s), so returning immediately handed callers a dead container. That produced deterministic flow_no_assistant_output failures with empty assistant messages (partTypes: []) on the first flow after any >14-minute idle gap; the one success in the cascade was luck. The SDK exposes no soft-reload alternative to dispose, so the restart itself cannot be avoided from our side.

4. termination_unconfirmed leaked message runs for 35 minutes

When an abort could not be confirmed idle within its 10s window, the flow run is deliberately preserved (unconfirmed termination must never be finalized — the generation may still be winding down), but the tracked message run stayed running too, and its lock blocked provider syncs for the whole 35-minute message-run timeout. Five message runs stuck since July followed exactly this shape.

Fix: the message run is marked failed with the termination cause (it is definitively over — abort was requested); the flow run itself still waits for lease-expiry recovery.

5. Recovery existed but had no worker on one-click deployments

Stale-run recovery fires inside the scheduler tick, so bug #1 silently disabled it; and message-run reaping (reapStaleRuns) is called only by the standalone reaper daemon, which the archectl compose never deployed.

Fix (with #1): the archectl stack now also runs the reaper service (src/reaper-daemon.ts), and the update script brings up web flows reaper. Stale locks and orphaned runs now self-heal everywhere.

6. Five message-run locks stuck since July 2026

Legacy evidence of bug #4. The reaper (above) prevents recurrence; to clear the existing rows immediately, run against production:

UPDATE "message_runs" SET status = 'failed', finished_at = NOW(), error = 'stale_recovered'
  WHERE id IN ('2e7486e6','91e7f9ed','eb4ec5f7','fe13e7a0','0260f1a8') AND status = 'running';
DELETE FROM "message_run_locks" WHERE "runId" IN ('2e7486e6','91e7f9ed','eb4ec5f7','fe13e7a0','0260f1a8');

7. flow_no_assistant_output with sub-second empty responses

Four steps failed with completed sessions but no assistant text (0.3–1.1s responses, no provider error). Provider auth was verified healthy, so the likely cause is model-side (reasoning-only or tool-only replies). The error was undiagnostic.

Fix: the error now carries <provider>/<model>:<partTypes> (e.g. flow_no_assistant_output:openrouter/glm-5.2:reasoning+step-finish) and logs the full profile (agent, message counts). Root-causing the model behavior itself needs live session inspection and remains an ops follow-up.

8. Stop button UX

The flows list previously redirected to run history on Run; it now stays in place and silently refreshes every 5s while a run is active. Cancelling a run settles its in-flight step records (previously the step spinner rendered forever on a cancelled run).


OpenSpec

flow-run-recovery-and-stop, flow-gateway-token-refresh, and archectl-flow-scheduler-mode changes ride on this branch; all validate --strict.

Test plan

  • Full web suite from apps/web: 508 files / 5,199 tests passing (including the root tests/ directory)
  • gofmt / go vet / go test ./... in infra/one-click — 17 tests, including rendered-compose assertions for the flows and reaper services and ARCHE_FLOW_SCHEDULER_MODE
  • eslint clean on all touched files (exit-code verified)
  • CI green

Post-merge checklist

Scheduled flows could get permanently stuck when finalizeRun failed
silently — the lease was released but the run stayed in 'running'
status. recoverStaleRunningRuns only checked for expired leases
(leaseExpiresAt < now), missing the NULL-lease case entirely. The
noActiveRun guard then blocked all future claims for that flow.

Two backend fixes:
- Expand recoverStaleRunningRuns WHERE to also match NULL leases
- Don't release the lease in settleFlowRun when finalizeRun fails;
  let it expire so the existing recovery path handles it

Surface the existing cancel API as a Stop button in three places:
flow list, run history header, and individual run cards.
…recovery

The test asserted the old WHERE clause shape. Updated to match the new
OR condition that also recovers runs with a released (NULL) lease.
@albertoperdomo2
albertoperdomo2 force-pushed the fix/flow-orphaned-runs-stop-button branch from fe61218 to 88ea307 Compare August 28, 2026 21:41
albertoperdomo2 and others added 10 commits August 28, 2026 22:43
archectl-generated deployments never set ARCHE_FLOW_SCHEDULER_MODE, and
the web app requires it in production (getFlowSchedulerMode throws when
missing), so one-click servers failed at web startup. The Ansible remote
path already sets it to daemon.

Set ARCHE_FLOW_SCHEDULER_MODE=daemon in renderEnvFile, the bootstrap env
heredoc, and the update script (idempotent set_env, so existing
deployments converge on next update). Because daemon mode expects a
dedicated runner, the generated compose also gains a flows service
running the web image's flow-daemon entrypoint — mirroring the Ansible
remote template — and the update script brings flows up alongside web.
Multi-step flows failed mid-run with `APIError: Unauthorized:
invalid_token` from the internal provider gateway: the runner synced
provider access exactly once before the flow started, gateway tokens
carry a short TTL (900s default), and a flow could also inherit a
partially aged token when it started shortly after interactive
activity. `invalid_token` was not retryable, so expiry failed the run
terminally.

- Force a provider-access refresh at flow start (`force` flag on
  `ensureProviderAccessFreshForExecution`, threaded through
  `ensureWorkspaceRunningForExecution`) so flows start with a full-TTL
  token; the credential-hash check and active-run deferral still apply.
- Refresh provider access before each flow node in `executeFlowNodes`.
  At a step boundary the flow's own message run is finalized, so this
  only defers when an unrelated run is active — the case where a
  concurrent sync (which disposes the instance) must not abort
  in-flight generation. A failed boundary refresh warns and continues.
- Classify `invalid_token` as retryable so an expiry that survives the
  above costs one step re-run after backoff; the retry re-syncs and
  resumes from the failed node.

No changes to token TTL, claims, or issuance. Mid-step refresh without
dispose is deliberately left for a follow-up pending verification that
OpenCode re-reads auth keys per request.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The step-boundary refresh used the freshness-threshold check, which is
tuned for interactive cadence: it only fires once the sync is older
than TTL minus the 60s skew. Flow steps run for minutes, so boundaries
rarely land in that window — an 11-minute step started with 10 minutes
of token life left and still failed at expiry (2026-09-02 11:07 run),
defeating the between-steps refresh for exactly the flows it was built
for.

Force the refresh before every step after the first, so each step
starts with the full gateway-token TTL; mid-step expiry now requires a
single step longer than the TTL, which the retryable classification
bounds to one step re-run. The first iteration stays exempt because
the run entry points already force a fresh sync before the loop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@albertoperdomo2

Copy link
Copy Markdown
Contributor Author

/build

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📦 PR Workspace Image Built Successfully

Default (amd64):

ghcr.io/peaberry-studio/arche/workspace:pr-478

Optional arm64:

ghcr.io/peaberry-studio/arche/workspace:pr-478-arm64

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📦 PR Image Built Successfully

Default (amd64):

ghcr.io/peaberry-studio/arche/web:pr-478

Optional arm64:

ghcr.io/peaberry-studio/arche/web:pr-478-arm64

Cancelling a run flipped only the run row to `cancelled`; its in-flight
step stayed `running` forever, so the run history kept rendering a
spinner for a dead run. The runner never revisits step rows after
cancellation — finalizeRun early-returns and executors skip step
updates for cancelled prompts — and stale-recovered runs have no living
runner at all.

Settle `pending`/`running`/`waiting_for_human` steps to `failed` with
the corresponding error in the same service operation that settles the
run: both cancel paths (`cancelRunById`, `cancelRunByIdForScope`) and
stale-run recovery. Already-final steps are untouched.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@albertoperdomo2

Copy link
Copy Markdown
Contributor Author

/build

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📦 PR Workspace Image Built Successfully

Default (amd64):

ghcr.io/peaberry-studio/arche/workspace:pr-478

Optional arm64:

ghcr.io/peaberry-studio/arche/workspace:pr-478-arm64

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📦 PR Image Built Successfully

Default (amd64):

ghcr.io/peaberry-studio/arche/web:pr-478

Optional arm64:

ghcr.io/peaberry-studio/arche/web:pr-478-arm64

albertoperdomo2 and others added 7 commits September 2, 2026 14:58
The workspace Flows section passed `navigateToHistoryOnRun`, so clicking
Run on a flow card yanked the user to the run history view; the web list
already refreshed in place. Both surfaces now stay on the list.

To make staying informative, the list silently re-fetches every 5s while
any visible flow has an active run (`running`/`waiting_for_human`), so
badges and stop controls track progress without navigating away or
flashing the loader. Polling tears itself down once no run is active.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The provider-sync deferral checked for active runs before the auth PUTs,
but the instance dispose fired after them. A run registering during the
PUTs was disposed mid-generation and every such generation died with
MessageAbortedError: Aborted — persisted across fresh sessions for as
long as syncs kept landing in generation windows (2026-09-02 13:37
incident: six consecutive runs aborted over four minutes, then
self-healed when the sync cadence settled).

Re-check for active runs immediately before the dispose, after the
writes: the keys are already updated by then, so only the destructive
dispose is skipped and provider-discovery reload defers to the next
sync. Unlike the deferral, this check does not exclude the caller's own
run — an in-flight generation must never be disposed underneath itself.

Also log dispose decisions and session-family abort targets so runtime
MessageAbortedError reports can be correlated with their source.

Co-Authored-By: Claude Code <noreply@anthropic.com>
reapStaleRuns — which fails message runs stuck in `running` past the
35-minute timeout and deletes their locks — is only called by the
standalone reaper daemon, so one-click deployments never reaped stale
locks (five have been stuck since July). Mirror the Ansible reaper
service into renderCompose and bring it up in the update script
alongside web and flows.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… runs

Three follow-ups to the stop/recovery work:

- A provider sync whose dispose was skipped (run started during the
  sync) no longer records a fresh sync state. Recording it made later
  syncs short-circuit on the fresh timestamp while the instance kept
  running on stale cached provider discovery — the keys were in storage
  but never loaded. Withholding the record makes the next idle-boundary
  sync re-write credentials and dispose. Disposal intentionally disabled
  (disposeInstance: false) still records.
- A prompt whose runtime termination could not be confirmed now fails
  its tracked message run instead of leaving it `running` for up to 35
  minutes, which blocked workspace provider syncs. The flow run itself
  stays preserved for lease-expiry recovery, per the existing
  never-finalize-unconfirmed-termination invariant.
- flow_no_assistant_output now carries the provider, model, and part
  type profile (reasoning-only vs tool-only vs empty) as a suffix, and
  logs the full diagnostics — the empty sub-second responses reported
  on 2026-09-02 become self-describing.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…hanges

- session-executor.test.ts had a leftover closing brace from the
  no-output test rewrite (parse error, broke lint and the suite).
- tests/opencode-providers.test.ts mocked @/lib/services without
  messageRunService, so the new dispose guard threw and the sync
  returned sync_failed before disposing. Mock hasActiveRunForSlug
  explicitly.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@albertoperdomo2

Copy link
Copy Markdown
Contributor Author

/build

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📦 PR Workspace Image Built Successfully

Default (amd64):

ghcr.io/peaberry-studio/arche/workspace:pr-478

Optional arm64:

ghcr.io/peaberry-studio/arche/workspace:pr-478-arm64

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📦 PR Image Built Successfully

Default (amd64):

ghcr.io/peaberry-studio/arche/web:pr-478

Optional arm64:

ghcr.io/peaberry-studio/arche/web:pr-478-arm64

albertoperdomo2 and others added 2 commits September 3, 2026 08:34
Disposing exits the OpenCode process and Docker restarts the container
(5-15s), but the sync returned immediately and callers created sessions
against the dead instance — the model produced an assistant message with
zero content parts and the step failed with flow_no_assistant_output.
Deterministic whenever a flow started more than 14 minutes after the
last sync (the refresh threshold), which made scheduled flows fail on
the first run after every idle gap.

Poll /global/health (bounded 30s, 1s interval, 3s probe timeout) after
the dispose and only hand control back to callers once the instance
reports healthy; warn if it never does. The SDK exposes no soft-reload
alternative to dispose, so the restart cannot be avoided from our side.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…wait

Add the flows-list requirement (stay mounted after starting a run, silent
polling while a run is active) that rode in from the closed list-redirect
fix without a spec delta, and task entries for the dispose health wait.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@albertoperdomo2

Copy link
Copy Markdown
Contributor Author

/build

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📦 PR Workspace Image Built Successfully

Default (amd64):

ghcr.io/peaberry-studio/arche/workspace:pr-478

Optional arm64:

ghcr.io/peaberry-studio/arche/workspace:pr-478-arm64

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📦 PR Image Built Successfully

Default (amd64):

ghcr.io/peaberry-studio/arche/web:pr-478

Optional arm64:

ghcr.io/peaberry-studio/arche/web:pr-478-arm64

@albertoperdomo2

Copy link
Copy Markdown
Contributor Author

@Inakitajes this is probably the one with higher priority and the once that probably need a more thorough review.

- Keep active flow controls mounted during silent refresh failures and surface run-card stop errors
- Settle stale runs safely and wrap cancellation settlement in a transaction
- Harden PR scanner inputs, malformed manifest handling, and manual dispatch checks
- Cover silent flow refresh and card Stop errors
- Exercise workflow scanner and dispatch regressions
- Execute malformed-manifest governance behavior

Convoy-Run: 20260915-112714-x8qg
The hardened secret scanner matches quoted password/secret assignments of
eight or more characters, which caught two test fixtures added by this
branch:

- the governance scanner's own test built a literal 'password = ...'
  sample, so assemble it at runtime instead
- the one-click env test reused production-length placeholder secrets;
  shorten them to obvious placeholders

The scanner itself is unchanged.
@Inakitajes
Inakitajes merged commit fac9a61 into main Sep 15, 2026
10 checks passed
@Inakitajes
Inakitajes deleted the fix/flow-orphaned-runs-stop-button branch September 15, 2026 11:53
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.

2 participants