Skip to content

feat: make a Provider outage visible and recoverable - #102

Merged
tatelilith merged 6 commits into
mainfrom
feat/provider-outage-recovery
Sep 8, 2026
Merged

feat: make a Provider outage visible and recoverable#102
tatelilith merged 6 commits into
mainfrom
feat/provider-outage-recovery

Conversation

@tatelilith

Copy link
Copy Markdown
Contributor

Summary

A deployment with a single localSession Provider ran for hours with a green
「已登录」chip while every Run failed on your access token could not be refreshed because your refresh token was revoked — 36 identical failures in 24
hours. Nothing on the page contradicted the credential, and recovering meant
opening each failed Run's drawer one at a time.

This PR makes that class of outage visible, honest, and recoverable.

1. The Provider session is proven, not assumed.
codex login status answers from ~/.codex/auth.json alone, so a revoked token
still reads as a session. codex checkLoginStatus now follows a positive local
verdict with codex doctor and takes its verdict from the row that actually
reaches the vendor — the WebSocket handshake (connected (HTTP 101 …)), not the
auth row, which only reports that a credential file is configured. A refusal
(401 / 403 on the handshake) becomes a distinct CREDENTIALS_REJECTED state
(「登录态已失效」): nothing to install, nothing missing — log in again. Any other
transport failure (proxy, DNS, disabled WebSocket policy) is reported as
unverified rather than blamed on the token, and a verifier that cannot answer at
all (older CLI without the subcommand, timeout) leaves the previous behaviour
untouched — which is why codex still needs no minVersion floor.

LoginStatus.verified is surfaced, so a session is labelled 「已验证」or
「仅本地凭证」. Every other Provider CLI answers from disk, and none of them is
allowed to look like a confirmed session.

2. The evidence a probe cannot contradict. The Provider section now shows
the Agent's most recent failed Run and its error, calling out auth-shaped errors
outright. One real 401 beats any local check.

3. Failures are retried in place. POST /runs/:id/rerun filed a new row for
every replay, so the failed row stayed failed forever and one outage turned the
Runs list into pairs of identical intents with nothing marking which failures
were already handled. A failed run is now re-executed on its own row (status
CAS failed → pending, normal admission; the attempt lands as another
run_steps row — executeChatRun already numbers steps by MAX(order)+1 for
multi-turn chat, so it needed no change). The failed attempt is not erased: its
step, output and logs stay. completed / cancelled keep the new-row rerun,
and the automatic job-retry chain still files new rows — it runs unattended, and
erasing the failure it is recovering from leaves nothing to diagnose.

4. Bulk recovery. A 「全部重试」button next to the Runs refresh retries every
failed Run on the current page, oldest first, behind a confirm, disabled for
viewers (a rerun is a write on the Agent).

5. A how-to where the credential is configured. An info icon on
「使用服务器登录态」explains how to establish the session on the server —
including handing those steps to an Agent that holds a shell there.

Non-obvious failure modes this had to handle

  • A retry must not become a resume. executionMetadata is rebuilt from an
    allowlist. A surviving liveChatId makes resolveQueuedChatId read the retry
    as a resume: the Agent is sent a continuation prompt instead of the intent and
    no chat message is recorded. The allowlist also drops whatever transient field
    is added next, which a denylist would carry forward. oauthResetSession /
    nativeChatResetSession are kept — being consume-once, still finding them
    means the failed attempt never spent them.
  • gitTriggerOrigin.queued is forced false, or the staleness probe cancels
    a retry a human just asked for, turning a failure record into a cancellation.
  • Restart recovery must tell a retry from an unconsumed event. A retried
    native-Feishu run keeps the original event's triggerSessionId while that
    event was consumed by the first attempt, so both the running and queued
    branches would fail it awaiting a replay that never comes. retryAttempt
    marks the row (awaitsFeishuEventReplay()), and requeueForResume restores
    its reply context on the same rule.
  • A retry must not overlap the previous attempt's cleanup. failed is
    written before the execution lease is released, and the durable SCM release
    the lease triggers is fire-and-forget with retries. Re-admitting the same id
    inside that window let the dying owner's cleanup tear down the retry's
    bindings and release its SCM lease. The registry now tracks a run as settling
    until that release lands; the retry refuses with 409 until then.
  • Admission can throw, not only return queue_full (worktree teardown, SCM
    arbitration). Either way the row is restored to failed with its original
    error instead of being stranded in pending with the failure erased.
  • The bulk-retry memory expires. It exists only to bridge the click and the
    refetch; now that a retry reuses the row, the same id can fail again, so the
    memory is keyed to the list snapshot the batch was judged from.

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behavior)
  • Documentation only
  • Refactor / chore

Behaviour change worth calling out even though it is not breaking: a retried run
keeps its original createdAt, so a recovered failure no longer counts as a
failure in byStatus, and its retry does not appear as "today's activity".

Testing checklist

  • pnpm lint passes (0 errors in the changed areas; repo-wide warning count
    unchanged from main)
  • pnpm typecheck passes
  • pnpm test passes — API 7168, web 1098, shared 271. 31 new cases:
    buildRetryMetadata (12), the rerun route's in-place branch (8), restart
    recovery and requeue context (5), the codex verification verdict (6), plus
    the web probe/notice/bulk-retry/drawer suites
  • E2E updated/run: bash scripts/e2e/restart-recovery.sh — 4/4 scenarios
  • User-facing docs, i18n copy (zh.json and en.json), and the in-app
    manual updated (06-providers, 15-runs, zh + en), plus
    docs/agent/core-concepts-notes.md and the run.retry audit copy

Cross-cutting change matrix

  • Create / import / bootstrap paths checked
  • PATCH / enable-disable / cancellation paths checked — the retry CAS makes
    a double click, a bulk retry and a concurrent cancel resolve to one claim
  • DELETE / cleanup / audit paths checked — run.retry is its own audit
    action, written only after admission succeeds
  • Startup recovery and upgrade compatibility checked — retryAttempt is
    absent on every existing row, which reads exactly as today's behaviour
  • Git and P4 behavior checked where applicable
  • SQLite and PostgreSQL behavior checked where applicable — the claim counts
    .returning() rows rather than a driver row count, which is the one form
    that means the same on both
  • Named volume, default bind, explicit bind, and macOS bind behavior checked
    where applicable
  • Failure recovery and operator remediation are documented

AI assistance disclosure

  • This change was substantially AI-generated. If checked, I confirm I
    understand the code and have tested it myself (see CONTRIBUTING.md).

Additional notes

Reviewed with codex exec in two rounds; ten findings were confirmed against
the code and fixed here (the false-green auth row, the execution-lease and
SCM-release overlap, the Feishu recovery gaps, the dropped session-reset flags,
the throwing-admission path, and the bulk-retry memory). The last two commits
are those fixes.

Follow-ups deliberately not in this PR:

  • An E2E scenario for a restart landing while an in-place retry sits queued.
    The predicate is pinned by unit tests; the end-to-end script is not written.
  • Artifacts are keyed by run id, so a second attempt writing a same-named file
    overwrites the first attempt's copy while its artifacts row remains. This
    pre-dates the PR (multi-turn chat has the same shape) and is untouched here.
  • verified is only ever true for codex today. The contract exists for any CLI
    that grows a real validation command.

A deployment whose single Provider runs on localSession fails every Run
the moment that server-side session lapses, while the Agent config keeps
looking valid and the Runs page fills with identical failures. Three
changes make that outage visible and recoverable:

- Expanding a localSession Provider probes the server session once and
  reports logged in / not logged in / CLI not installed / check failed,
  with the CLI version and the server's own reason, plus a re-check
  button to confirm a fresh login on the spot.
- An info icon on the credential-mode option explains how to establish
  that session — including handing the steps to an Agent that holds a
  shell on the server, for an operator who has no server access.
- "Retry all failed" next to the Runs refresh replays every failed Run
  on the current page, oldest first, behind a confirm. It remembers what
  it already resubmitted (a rerun leaves the original failed and pushes
  it onto the next page) and un-remembers anything the server rejected.

The login-status route now tags a thrown probe with code PROBE_FAILED:
its soft-failure body is shaped exactly like an engine's "CLI not found"
verdict, and reporting a broken probe as a missing CLI sends operators
installing something that is already there.
Two circular-arrow icons a few pixels apart read as one control duplicated:
the bulk retry now uses a list-restart glyph (a list, not a refresh cycle)
and is separated from the tab's refresh by a divider.
A deployment ran for hours with a green "已登录" chip while every single Run
failed on "your access token could not be refreshed because your refresh
token was revoked" — 36 identical failures in 24 hours. `codex login status`
answers from ~/.codex/auth.json alone, so a revoked refresh token still
reads as a session, and the config page confidently reported one.

- codex checkLoginStatus now follows a positive local verdict with `codex
  doctor`, the only subcommand that reaches OpenAI. A refusal becomes a
  distinct CREDENTIALS_REJECTED state ("登录态已失效"): nothing to install,
  nothing missing — log in again. A verifier that cannot answer (missing
  subcommand on an older build, timeout, unrecognised output) leaves the
  local verdict standing and marks it unverified rather than inventing an
  outage, which is why codex still needs no minVersion floor.
- LoginStatus carries `verified`, and the UI labels a session "已验证" or
  "仅本地凭证" so an unproven credential is never shown as a confirmed one.
- The Provider section now also shows the Agent's most recent failed Run and
  its error, flagging auth-shaped errors outright. A real run's 401 is
  evidence no local probe can contradict.
Replaying a failure as a new row is what made an outage unreadable: the
failed row stays failed forever, so one lapsed credential left a Runs list
of paired identical intents with nothing marking which failures were
already handled. A failed run is now re-executed in place — status CAS
failed -> pending, normal admission, and the attempt lands as another
run_steps row (execute-chat-run already numbers steps by MAX(order)+1 for
multi-turn chat, so it needed no change at all). The failed attempt is not
erased: its step, output and logs stay put.

Completed and cancelled runs keep the new-row rerun — replaying a success
in place would overwrite a good result, and a cancellation was deliberate.
The automatic job-retry chain also keeps filing new rows: it fires
unattended, and erasing the failure it is recovering from would leave
nothing to diagnose.

Three things that fail silently if missed:

- executionMetadata is rebuilt from an allowlist, not merged. A surviving
  liveChatId makes resolveQueuedChatId read the retry as a *resume*: the
  Agent gets a continuation prompt instead of the intent and no chat
  message is recorded. An allowlist also drops whatever transient field is
  added next, which a denylist would carry forward.
- gitTriggerOrigin.queued is forced false, or the staleness probe cancels
  the retry a human just asked for — turning the failure record into a
  cancellation.
- A retried native-Feishu run keeps the original event's triggerSessionId
  while that event was consumed by the first attempt, so restart recovery
  would fail it awaiting a replay that never comes. retryAttempt marks the
  row; the sendable-context requirement is unchanged.

A full queue restores the row to failed with its original error rather
than stranding it in pending, and the CAS on status='failed' makes a
double click, a bulk retry and a concurrent cancel resolve to one claim.
Six defects, all real:

- The codex verification was still a false green. `codex doctor`'s `auth`
  row reports on the LOCAL credential — a healthy line literally reads
  "auth is configured" — so treating it as confirmation rebuilt exactly
  what this probe was written to remove. The verdict now comes from the
  `websocket` row, which opens a Responses socket with that credential:
  `✓ connected (HTTP 101)` confirms, `handshake transport error http
  401/403` refuses, and any other transport failure (proxy, DNS, blocked
  WebSocket policy) is reported as unverified rather than blamed on the
  token.
- A retry could overlap the previous attempt's cleanup. `failed` is
  written before the execution lease is released, and the lease is keyed
  by run id — so re-admitting the same id inside that window handed the
  dying owner's completeExecutionLease() the new attempt's bindings,
  tearing down its cancellation wiring and releasing its SCM lease. The
  retry now refuses with 409 while a lease is still open.
- A RUNNING retried Feishu run was failed on restart for a replay that
  cannot come: only the queued branch understood `retryAttempt`. Both
  branches now share `awaitsFeishuEventReplay()`.
- The metadata allowlist dropped `oauthResetSession` /
  `nativeChatResetSession`. Both are consume-once, so still finding them
  means the failed attempt never spent them: the request asked for a
  fresh session and the retry would have resumed an older conversation
  instead.
- Only `queue_full` restored the row. Admission can also throw (worktree
  teardown, SCM arbitration), which left the row `pending` with its error
  erased and nothing scheduled.
- Bulk retry remembered submitted ids forever. Now that a retry reuses the
  row, the same id can fail again — the page would have refused to retry a
  genuinely new failure. Ids are forgotten as soon as the list stops
  reporting them as failed.
- Verification accepted any green `websocket` row. doctor also reports
  success when the transport is disabled by configuration, i.e. when no
  handshake was attempted at all, so the row's message must show one
  happened (`connected (HTTP 101 …)`) before a session counts as proven.
- The retry guard checked only the in-memory lease. That lease is dropped
  the moment cleanup finishes, while the durable SCM release it triggers
  is fire-and-forget WITH RETRIES — so a retry admitted in between took a
  fresh SCM lease that the previous attempt's straggler then released.
  The registry now tracks a run as settling until that release lands, and
  the retry waits for it.
- A running retried Feishu run passed recovery's guard but then lost its
  reply context anyway: requeueForResume restores step context only when
  `triggerSessionId == null`, which a retry never satisfies — so the
  queued gate failed it for having no reply target. The restore now uses
  the same retry-aware rule.
- Bulk retry remembered submitted ids until it saw them leave the failed
  set, which never happens for a run that fails again before the next
  refetch. The memory is now keyed to the list snapshot the batch was
  judged from: any fresher snapshot that still calls a run failed means
  it failed again, and offers it back.
@tatelilith
tatelilith merged commit 8143a46 into main Sep 8, 2026
17 checks passed
@tatelilith
tatelilith deleted the feat/provider-outage-recovery branch September 8, 2026 02:06
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