feat: make a Provider outage visible and recoverable - #102
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A deployment with a single
localSessionProvider 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 24hours. 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 statusanswers from~/.codex/auth.jsonalone, so a revoked tokenstill reads as a session. codex
checkLoginStatusnow follows a positive localverdict with
codex doctorand takes its verdict from the row that actuallyreaches the vendor — the WebSocket handshake (
connected (HTTP 101 …)), not theauthrow, which only reports that a credential file is configured. A refusal(
401/403on the handshake) becomes a distinctCREDENTIALS_REJECTEDstate(「登录态已失效」): 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
minVersionfloor.LoginStatus.verifiedis 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/rerunfiled a new row forevery 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
failedrun is now re-executed on its own row (statusCAS
failed → pending, normal admission; the attempt lands as anotherrun_stepsrow —executeChatRunalready numbers steps byMAX(order)+1formulti-turn chat, so it needed no change). The failed attempt is not erased: its
step, output and logs stay.
completed/cancelledkeep 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
executionMetadatais rebuilt from anallowlist. A surviving
liveChatIdmakesresolveQueuedChatIdread the retryas 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/nativeChatResetSessionare kept — being consume-once, still finding themmeans the failed attempt never spent them.
gitTriggerOrigin.queuedis forced false, or the staleness probe cancelsa retry a human just asked for, turning a failure record into a cancellation.
native-Feishu run keeps the original event's
triggerSessionIdwhile thatevent was consumed by the first attempt, so both the running and queued
branches would fail it awaiting a replay that never comes.
retryAttemptmarks the row (
awaitsFeishuEventReplay()), andrequeueForResumerestoresits reply context on the same rule.
failediswritten 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.
queue_full(worktree teardown, SCMarbitration). Either way the row is restored to
failedwith its originalerror instead of being stranded in
pendingwith the failure erased.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
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 afailure in
byStatus, and its retry does not appear as "today's activity".Testing checklist
pnpm lintpasses (0 errors in the changed areas; repo-wide warning countunchanged from main)
pnpm typecheckpassespnpm testpasses — API 7168, web 1098, shared 271. 31 new cases:buildRetryMetadata(12), the rerun route's in-place branch (8), restartrecovery and requeue context (5), the codex verification verdict (6), plus
the web probe/notice/bulk-retry/drawer suites
bash scripts/e2e/restart-recovery.sh— 4/4 scenarioszh.jsonanden.json), and the in-appmanual updated (
06-providers,15-runs, zh + en), plusdocs/agent/core-concepts-notes.mdand therun.retryaudit copyCross-cutting change matrix
a double click, a bulk retry and a concurrent cancel resolve to one claim
run.retryis its own auditaction, written only after admission succeeds
retryAttemptisabsent on every existing row, which reads exactly as today's behaviour
.returning()rows rather than a driver row count, which is the one formthat means the same on both
where applicable
AI assistance disclosure
understand the code and have tested it myself (see CONTRIBUTING.md).
Additional notes
Reviewed with
codex execin two rounds; ten findings were confirmed againstthe code and fixed here (the false-green
authrow, the execution-lease andSCM-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:
queued.The predicate is pinned by unit tests; the end-to-end script is not written.
overwrites the first attempt's copy while its
artifactsrow remains. Thispre-dates the PR (multi-turn chat has the same shape) and is untouched here.
verifiedis only ever true for codex today. The contract exists for any CLIthat grows a real validation command.