Repository navigation
[bug] A run is admitted onto a folder the platform has already judged "not a git working tree", and starting it overwrites that verdict with ready — three git pull runs died on it #548
Description
git pull failed three times overnight in a folder that has no .git
Investigated from the owner's report "Two git pull runs failed overnight — both timed out (stuck
not resolved in time)" (the agent's own summary, instance_messages, 2026-08-12 22:38:11Z), plus a
third failure the same night.
Instance a1d3522f-6b91-4cd9-bf7b-380e470192b7 (Repo Coder), repo dev/aipa, workdir ~/dev/aipa.
On the machine, right now:
$ ls -d ~/dev/aipa → /Users/serge-ivo/dev/aipa
$ ls -d ~/dev/aipa/.git → ls: No such file or directory
$ ls -la ~/dev/aipa → one entry: platform/
The platform already knows this. GET /v1/instances/a1d3522f…/coding/repos:
{"id":"repo_145254cf-…","name":"dev/aipa","provider":"local","workdir":"~/dev/aipa",
"cloneStatus":"needs_attention",
"cloneError":"The configured checkout `/Users/serge-ivo/dev/aipa` has files but is not inside a git working tree — it is a plain folder, not a clone of a repository.",
"updatedAt":"2026-08-12 22:41:20"}That sentence is lib/coding-workdir.ts:111, written by #405's machinery, working exactly as
designed. updated_at is 22:41:20 — twenty seconds after the run's last instruction and
fifteen minutes before it gave up. The verdict was on the row while the run was still going.
The run started anyway, and nothing consulted the verdict
codingDriver.start (workers/api/src/lib/loop-drivers.ts:150-284) is the path behind
start_work, the Loop button and delegate_goal. Its admission checks, in order:
pickLoopRepo(:156) — is there a repo, and is it this agent's;runtimeConnectivity+classifySubordinateConnectivity(:163-175) — is a machine reachable;claimSessionDriver(:192) — is somebody else already driving.
There is no fourth check. repo.cloneStatus is never read in this function — grepped across
routes/coding*.ts, lib/coding-store.ts and workflows/coding-session.ts: the only readers of
cloneStatus are the console (ReposList.tsx:150, CodingTab.tsx:405,814), the chat prompt
(lib/repo-status-prompt.ts), diagnostics (routes/coding-diagnostics.ts:233,265) and repo
selection for the local-repo connector (lib/connectors/repo-local.ts:128). Nothing on the run
path.
Worse: starting the run overwrites the verdict with ready
workers/api/src/lib/coding-session-open.ts:129, in startSessionOnRunner, immediately after a
successful POST /coding/start:
await updateRepoClone(env, repo.id, { cloneStatus: "ready", cloneError: null });Nothing looked at the directory. /coding/start succeeding means the runner could chdir there and
was willing to run a command — a plain folder satisfies that. So the run's own first act is to
erase the sentence that would have stopped it.
This is the same invariant the file states 4 lines below, in the comment #440 put there:
// A REPO'S STATE IS ONLY EVER WRITTEN BY SOMETHING THAT LOOKED AT THE REPO (#440).
#440 closed the false-error direction (a dropped WebSocket stored as the repo's state; the
isRunnerUnreachable guard at :149 is that fix, and it is in the code). The false-ready
direction, four lines above it, was not addressed — and it is ready, per #405, that makes an
agent invent code.
The third safety net is silent too
workflows/coding-session.ts:587-591 runs a repo-state-start step before the first instruction:
return (await readRepoWorkingState(conn, { repo, sessionId })) ?? null;readRepoWorkingState (lib/repo-state.ts:120-136) runs git status over the relay and returns
null on any error — which is right for its own purpose (an absent report reads as "unknown", not
as a fabricated clean tree) but means fatal: not a git repository arrives as null, stateNote
is skipped, and the Pilot is told nothing.
The composition
Three individually-defensible decisions:
codingDriver.startchecks connectivity, not repo health;startSessionOnRunnertreats a successful spawn as proof the checkout is good;readRepoWorkingStatedegrades a git error to "unknown".
Together: a run is admitted onto a non-repository, the evidence against it is deleted, and the one
component that could have said so says nothing. What the owner got was three git pull attempts
(22:40:52, 22:40:56, 22:41:00), an unanswered stuck-handoff, and at 22:56:23 the message
"Loop stopped (failed) — stuck not resolved in time". Nowhere in that chain does the phrase
"not a git repository" appear, though the platform had it in D1 at 22:41:20.
The engine-side half of the same run — every Codex turn exiting 1, unclassified — is filed
separately as #545; either fix alone would have shortened this failure, and neither subsumes
the other.
What to do, cheapest first
1. Refuse the run. In codingDriver.start, after pickLoopRepo and before opening a session:
if (repo.cloneStatus === "needs_attention") {
return { ok: false, status: 409, error: repo.cloneError || `${repo.name}'s folder is not usable — fix it on the Coding tab.` };
}cloneError is already written to be relayed verbatim (coding-workdir.ts:47-51 — "an agent can
say it to the owner"). A 409 with that sentence turns 15 minutes and three BYOK decisions into one
honest reply the owner can act on. Only needs_attention, which #405 defines as a definite
runner verdict — unknown and unverified must NOT block, or an offline laptop stops every run.
2. Stop writing ready from a spawn. coding-session-open.ts:129 should either drop the write
or take a real verdict. Since startSessionOnRunner already holds a live conn, the honest form is
checkWorkdirVia(conn, repo.workdir) + cloneStatusForVerdict — the pair routes/coding-repos.ts:105-110
already uses, which returns null (no write) for unverified. For a managed clone (no
workdir) the existing ready write is fine and should stay: something did look, and it cloned it.
3. Let the Pilot see it. readRepoWorkingState returning null for "git said no" and for "the
runner did not answer" is one value for two facts. Distinguish them, and let
describeRepoState/stateNote carry "this checkout is not a git repository" into the prompt — the
channel is already built and already injected at workflows/coding-session.ts:596-603.
Alternatives considered and rejected
- Re-check the workdir at run start instead of reading the row. A relay round trip (5s timeout)
on everystart_work, to re-derive a verdict the list route already takes. Reading the row is
free; step 2 is what keeps it trustworthy. Worth revisiting only if [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's "no verdict has been
written in five days" case turns out to be common. - Auto-
git init/ auto-clone into the folder. No.~/dev/aipaholdsplatform/— quite
possibly the user's real intent was~/dev/aipa/platform. Guessing writes into a directory the
owner did not ask us to touch, and the same argument the workflow already makes about not
discarding an unexpected branch (coding-session.ts:582-586) applies here. - Refuse at repo-add time (400 instead of 201). Explicitly rejected by
routes/coding-repos.ts:290-296, and correctly: the row is the handle the owner needs to fix the
path. Nothing here changes that — the refusal belongs at run start, not at add.
Acceptance criteria
start_work/ Loop /delegate_goalon a repo whosecloneStatusisneeds_attentionreturns
409 carryingcloneError, and no session is opened, no loop-run row created, no budget pool
opened.cloneStatusunknownorunverifiedstill starts a run (a unit test for each).- A successful
/coding/starton a local workdir no longer writesreadywithout a
checkWorkdirViaverdict; a managed clone still may. - Replaying this repo's state, the failure the owner sees names the folder and the word "git",
within seconds, instead ofstuck not resolved in timefifteen minutes later. repo-state-startdistinguishes "the runner could not answer" from "git said this is not a
repository", and the second reaches the Pilot's prompt.
Regression risk
- A stale
needs_attentionbecomes a hard block. This is the real cost, and [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 is the open
evidence that stale verdicts happen. Mitigated by step 2 (a run's own start refreshes the verdict
honestly) and by the refusal naming the recheck path; but it should be stated in the PR, and
POST …/repos/:repoId/recheck(routes/coding-repos.ts:249) should be reachable from whatever
the refusal says. - Delegation. A Lead delegating to a subordinate whose repo is broken now gets a 409 instead of
a run. That is the desired behaviour, butnoSessionMessage's wording is tuned for connectivity
— the repo-health refusal needs its own sentence or a Lead will report "runpags up" for a
problempags upcannot fix (the [bug] #461's fix did not reach the second adapter — classifySubordinateConnectivity drops the pin, so subordinate_status, start_work and the chat tool still prescribepags up --forcefor a pinned agent #468/[bug] The agent prescribespags upwhen the runner is excluded by the pin — the chat prompt is a fourth "no runner" construction site and the only one never wired to diagnoseAttachment #530 failure mode, twice fixed already). updateRepoClonebumpsupdated_at(lib/coding-store.ts:155-171), whichlistReposorders
by andrepoStateForInstanceuses to pickrepos[0]. Removing a write changes that ordering for
multi-repo agents; checkrepo-local.test.tsandrepo-state.ts:141-148.
Not from feedback
This one was investigated from the owner's observation of repeated git pull failures, not from a
record_feedback row — there is no feedback row to stamp.
Activity
fix(coding): a run refuses a folder measured as "not a git working tr…
serge-ivo commentedon Aug 13, 2026
Fixed in
2d56156— CI green, API Worker deployed, migration N/A.What shipped
All three halves the issue asks for, and the acceptance criteria in order.
1. Admission.
lib/coding-repo-admission.ts(new, pure) + a gate incodingDriver.start, sostart_work/ the Loop button /delegate_goalrefuse a repo whose stored verdict isneeds_attention, relayingclone_errorverbatim. Two deviations from the sketch in the issue, both deliberate:pickLoopRepo. With the machine off, aneeds_attentionverdict can be days stale and the runner diagnosis is the truer, more actionable blocker; with the machine up, the folder verdict is the live one and is said first. A test pins both orderings.noSessionMessage, exactly as the Regression risk section warns. A test asserts the stringpags upnever appears in it, and that the message names Re-check — the remedy that lifts the block is the same probe that imposed it.unknown,cloning,missing_urland a staleerrorall still start (one test each; theerrorcase cites #440's five-day false verdict).2. The overwrite.
startSessionOnRunnerno longer writesreadyfrom a successful spawn.recordStartVerdicttakes a real verdict viacheckWorkdirVia+cloneStatusForVerdictand stampsclone_checked_at;unverifiedwrites nothing, so apags upolder than/coding/repo-checkcannot condemn a healthy repo. A managed clone (noworkdir) keeps the unconditionalreadyand is not probed, per the issue. Framed in the commit as the mirror of #440: that stopped a disconnect demoting a good row, this stops a spawn promoting a bad one.3. The silent net.
readRepoWorkingStatestopped collapsing "the runner did not answer" and "git said this is not a repository" into onenull.RepoWorkingStategrowsnotAGitRepo, set only from a definite refusal (matched on the runner's ownnot a git repoand git'sfatal: not a git repository), anddescribeRepoStateemits a clause that replaces the other two — a path with no.githas no branch to be off and no diff to protect. That reaches the Pilot through thestateNotechannel that already existed, soworkflows/coding-session.tsneeded no change (another agent was working in that file).One thing the issue did not anticipate, found while making that change:
evaluateRepoPoliciesscored the new stateheldfor every policy, because both clauses return null for it — a claim of compliance about a repository that is not there. It now scoresunknown. Guarded by its own test.Verified
pnpm typecheck,pnpm vitest run(8108 passed), biome, and all seven script gates includingcheck-file-size.mjsandcheck-migrations.mjs --require-history— run in a throwaway worktree atorigin/main, because the shared checkout had another agent's in-progress edit toworkers/api/src/lib/usage.ts, which was failing typecheck and is not mine.2d56156.GET /v1/instances/a1d3522f…/coding/repos:ran: true), not a stale row — so the new gate is acting on a measurement a minute old. A run on this repo is now refused with that sentence instead of admitted.Not verified
I did not trigger a run to see the 409 in production — that would mean driving the owner's live agent. The refusal is covered by unit tests against the real repo row shape; the production check above is read-only.
Left open, deliberately
ensureSessionForChatis not gated.read_terminalandsend_to_cliopen a session on demand and were left alone: gating them would also refuse reading an existing session's output, and the issue scopes the refusal to run start. Worth a separate ticket ifsend_to_clion a broken folder turns out to matter.e2ewas not run for this commit — nothing understore/,agents/coder/webor the specs changed.alive/ready/idle) is separable and already filed as [bug] Every Codex turn exited 1 and the session still reported alive/ready/idle — a non-zero engine exit is a line of prose in the pane and nothing else #545; nothing here touches it.