Skip to content

[bug] A repo has been marked broken for five days by a dropped WebSocket — a transport failure is stored as the repo's state, and no verdict is ever re-taken #440

Description

@serge-ivo

A healthy repo has been marked broken for five days, and nothing has re-asked

pas/platform on the Coder Home instance says the checkout cannot be used. The checkout is fine.
The verdict was taken on 2026-08-03 by something that was not looking at the checkout at all, and
nothing has replaced it since.

Measured, production, twice, ten minutes apart

GET /v1/instances/12ebf1f0-…/coding/repos
  pas/platform   cloneStatus: "error"
                 cloneError:  "No runner connected — run `pags up`"
                 workdir:     ~/dev/stores/pas/platform
                 updatedAt:   2026-08-03 01:44:25      ← five days ago

On disk, right now: ~/dev/stores/pas/platform holds 18 entries and
git rev-parse --is-inside-work-treetrue. The machine is connected —
GET /v1/relay/12ebf1f0-…/status?node=Mac{"connected":true}, runner 0.4.44 (which has
/coding/repo-check). Two consecutive reads of the repos list left the row byte-identical.

updateRepoClone bumps updated_at on every write (lib/coding-store.ts:155-171), so the
untouched timestamp is proof that no verdict has been written for this row in five days, not
merely that none changed it.

Meanwhile the chat prompt reads that row and tells the model the repository is unusable
(lib/repo-status-prompt.ts, attachedReposPrompt). #405 exists because a wrong ready makes an
agent invent code; a wrong error makes it refuse work it could do.

Two defects, and one correction to the report

1. A transport failure is stored as a property of the repo.
lib/coding-session-open.ts:112-120:

} catch (e) {
	const msg = e instanceof Error ? e.message.slice(0, 300) : String(e);
	await updateRepoClone(env, repo.id, { cloneStatus: "error", cloneError: msg });

Any exception from POST /coding/start becomes the repo's clone status — including
No runner connected, which is a fact about a WebSocket, not about a directory. clone_status
answers "is there usable code at this path"; a closed laptop is not an answer to that question.
The platform already models this distinction correctly one file over: verdictFromCheck has a
dedicated unverified state for exactly "the machine could not tell us", and
cloneStatusForVerdict returns null for it — deliberately no write (lib/coding-workdir.ts:73-84, 122-133). runnerUnreachable-shaped failures should take that path, not the error path.

2. There is no way to re-take a verdict, and no record of when one was taken.

A correction first, because it changes what to build: the repo list DOES re-verify
routes/coding-repos.ts:139-155 runs verifyLocalWorkdir over up to MAX_VERIFY_PER_LIST = 12
local repos on every GET …/coding/repos. So "the check only runs at add and at workdir change" is
not true of the code. What is true is that the re-check is:

  • conditionalif (!conn) return c.json({ repos }). No resolvable runner connection at that
    instant, no re-check, no trace of the attempt;
  • incidental — the only trigger is someone opening that instance's repo list. A row belonging to
    an instance nobody opens is never re-examined by anything;
  • one-directional in practiceonUnverified: null at list time is right (an offline laptop
    must not condemn a path it verified yesterday), but it means the only way out of a wrong verdict is
    a successful check, which requires the conditional path above to fire.

And an open question I could not close. With the relay reporting connected: true for that very
instance and a 0.4.44 runner, two list reads still wrote nothing — so either
getBoundRunnerConn returned null (a pin, a registration row under another node name, #379
alias resolution) or /coding/repo-check answered unverified. Both are invisible: the route
neither logs the attempt nor reports it in the response. Whoever picks this up should instrument
that first
— it may be the whole bug, and it is unknowable from outside. For contrast, another
instance's row (bd43f4de… / apps/chess-academy) was written today at 06:21:55, so the write
path does work somewhere.

A second live row, reported as the same thing but genuinely different

stores/fds (same instance, workdir ~/dev/stores/fds) reads ready; the directory holds one
entry and no .git of its own. That is not a stale verdict — it is what today's check would
say: ~/dev/stores is itself a git work tree, so git rev-parse --is-inside-work-tree is true
for the subfolder, and #405 chose that test on purpose ("~/dev/monorepo/apps/thing is a
legitimate workdir and an existence test would have condemned every package in every monorepo
checkout"
). Worth knowing while working here, not worth changing on this evidence.

What to do — cheapest first

  1. Never write clone_status: "error" for a transport failure. In coding-session-open.ts:117-120,
    route an unreachable-runner exception (isRunnerUnreachable / relayFailureIsDisconnect already
    exist in lib/runner-availability.ts) to no write — the same answer unverified gets. A repo's
    state should only be written by something that looked at the repo.
  2. Add clone_checked_at (migration + a write in verifyLocalWorkdir). repo-status-prompt.ts:113-129
    already asks for it and explains why updated_at cannot stand in:

    "updated_at is bumped by any edit to the row (rename, launch URLs, merge policy), so rendering
    it as 'checked 3 minutes ago' would be a precise-looking claim the platform cannot actually
    support … A real clone_checked_at column would fix that."

    With it, FRESHNESS_NOTE can say when instead of only "the LAST RECORDED verdict", the
    repo row can show it, and "this verdict is five days old" becomes visible rather than
    reconstructed from a timestamp that means something else.

  3. Make re-checking reachable on purpose. A POST …/coding/repos/:id/recheck (or a "Re-check"
    control beside the diagnosis banner ReposList.tsx already renders) that runs verifyLocalWorkdir
    with onUnverified: "unknown" and returns the verdict — so a user looking at a wrong verdict has
    an action, and so the check can be exercised deliberately when diagnosing.
  4. Say when a re-check did not happen. When the list route returns early for want of a
    connection, that is information: the rows shown are last-known, not current. It is one field on
    the response and one sentence in the UI, and it is what makes the difference between "the
    platform checked and it is broken" and "nobody has looked since Monday".

Alternatives considered and rejected

Acceptance criteria

  • A POST /coding/start that fails because no runner is reachable leaves clone_status
    unchanged (test: unreachable-runner error → no updateRepoClone call).
  • coding_repos.clone_checked_at exists, is written by every verdict, and is surfaced in the
    repo row and in attachedReposPrompt's freshness note.
  • An owner can re-check a repo on demand and see the verdict, with no session and no runner
    restart.
  • The live row above reads ready again after a re-check, and stores/fds still reads ready
    (the monorepo-subfolder case must not regress).
  • When the list route cannot re-check, the response says so.

Regression risk

  • clone_status is a plain string column with an additive union (ready / error /
    needs_attention / unknown); readers are repo-status-prompt.ts, repo-status.ts and
    ReposList.tsx. Adding a column is safe; changing what a value means is not — leave the
    existing four alone.
  • Not writing on a transport failure means a genuinely broken clone that also happens to fail at the
    transport keeps its previous status. That is the correct trade (silence over a false claim) and it
    is the same trade onUnverified: null already makes, but it should be stated in the code, not
    inferred.
  • A recheck route runs a relay command on user input: rate-limit it with the existing buckets and
    scope it to the owner, like every other :instanceId route.

Files: workers/api/src/lib/coding-session-open.ts:112-120, workers/api/src/routes/coding-repos.ts:100-160,181,599,
workers/api/src/lib/coding-workdir.ts:73-133, workers/api/src/lib/coding-store.ts:155-171,
workers/api/src/lib/repo-status-prompt.ts:102-133, store/console/src/…/ReposList.tsx.
Related: #405 (which built the check; still open pending live confirmation — this is why reading
the list cannot confirm it), #416 (which decided against checking at chat time), #410/#411.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions