[Bug]: A local-only status refresh caches an empty remote, which wipes PR status and skips the turn-end PR refresh#33256
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
VcsStatusBroadcaster.refreshStatus(cwd, { localOnly: true }) writes a { value: null } remote entry into the cache when no remote status is cached, or when the branch changed (apps/server/src/vcs/VcsStatusBroadcaster.ts:540-553 on main @ c6ca605630):
| 1 | const previous = options?.localOnly ? yield* getCachedStatus(cwd) : null; |
| 2 | const local = yield* refreshLocalStatusCore(cwd); |
| 3 | if (options?.localOnly) { |
| 4 | return yield* withRemoteWriteLock(cwd, Effect.gen(function* () { |
| 5 | if (previous?.local?.value.refName !== local.refName || !previous?.remote) { |
| 6 | yield* workflow.invalidateRemoteStatus(cwd); |
| 7 | yield* updateCachedRemoteStatus(cwd, { branch: local.refName, fingerprint: fingerprintStatusPart(null), value: null }, { publish: true }); |
| 8 | } |
| 9 | ... |
Every reader treats that entry as a remote that was read and found empty. previous is read before the remote write lock is taken, so the check can be stale by the time the write happens. The web client sends this refresh when the branch menu opens (BranchToolbarBranchSelector.tsx:515).
Two cases reproduce it deterministically. Add them to the describe("VcsStatusBroadcaster") block in apps/server/src/vcs/VcsStatusBroadcaster.test.ts. They use only that file's existing layerTestFor harness and fixtures.
| 1 | it.effect("a local-only refresh that overlaps the first remote poll keeps its PR", () => { |
| 2 | const state = { |
| 3 | currentLocalStatus: baseLocalStatus, |
| 4 | currentRemoteStatus: remoteStatusWithPr, |
| 5 | localStatusCalls: 0, |
| 6 | remoteStatusCalls: 0, |
| 7 | localInvalidationCalls: 0, |
| 8 | remoteInvalidationCalls: 0, |
| 9 | beforeRemoteStatus: Effect.void, |
| 10 | fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), |
| 11 | }; |
| 12 | |
| 13 | return Effect.gen(function* () { |
| 14 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 15 | const pollStarted = yield* Deferred.make<void>(); |
| 16 | const releasePoll = yield* Deferred.make<void>(); |
| 17 | state.beforeRemoteStatus = Deferred.succeed(pollStarted, undefined).pipe( |
| 18 | Effect.andThen(Deferred.await(releasePoll)), |
| 19 | ); |
| 20 | const subscribed = yield* Deferred.make<void>(); |
| 21 | const localSeen = yield* Deferred.make<void>(); |
| 22 | yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe( |
| 23 | Stream.runForEach((event) => |
| 24 | event._tag === "snapshot" |
| 25 | ? Deferred.succeed(subscribed, undefined) |
| 26 | : event._tag === "localUpdated" |
| 27 | ? Deferred.succeed(localSeen, undefined) |
| 28 | : Effect.void, |
| 29 | ), |
| 30 | Effect.forkScoped, |
| 31 | ); |
| 32 | yield* Deferred.await(subscribed); |
| 33 | // The subscription's first remote read now holds the remote write lock. |
| 34 | yield* Deferred.await(pollStarted); |
| 35 | state.beforeRemoteStatus = Effect.void; |
| 36 | |
| 37 | state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; |
| 38 | const refresh = yield* broadcaster |
| 39 | .refreshStatus("/repo", { localOnly: true }) |
| 40 | .pipe(Effect.forkScoped); |
| 41 | yield* Deferred.await(localSeen); |
| 42 | yield* Deferred.succeed(releasePoll, undefined); |
| 43 | yield* Fiber.join(refresh); |
| 44 | |
| 45 | const status = yield* broadcaster.getStatus({ cwd: "/repo" }); |
| 46 | assert.deepStrictEqual(status.pr, remoteStatusWithPr.pr); |
| 47 | assert.isTrue(status.hasUpstream); |
| 48 | assert.equal(state.remoteStatusCalls, 1); |
| 49 | }).pipe(Effect.provide(layerTestFor(state))); |
| 50 | }); |
| 51 | |
| 52 | it.effect("a local-only refresh before any remote read leaves the PR refresh able to read", () => { |
| 53 | const state = { |
| 54 | currentLocalStatus: baseLocalStatus, |
| 55 | currentRemoteStatus: remoteStatusWithPr, |
| 56 | localStatusCalls: 0, |
| 57 | remoteStatusCalls: 0, |
| 58 | localInvalidationCalls: 0, |
| 59 | remoteInvalidationCalls: 0, |
| 60 | fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), |
| 61 | }; |
| 62 | |
| 63 | return Effect.gen(function* () { |
| 64 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 65 | yield* broadcaster.refreshLocalStatus("/repo"); |
| 66 | const refreshed = yield* broadcaster.refreshStatus("/repo", { localOnly: true }); |
| 67 | assert.isNull(refreshed.pr); |
| 68 | |
| 69 | // The turn-end refresh after an agent turn. |
| 70 | const remote = yield* broadcaster.refreshPullRequestStatus("/repo"); |
| 71 | assert.deepStrictEqual(remote?.pr, remoteStatusWithPr.pr); |
| 72 | assert.equal(state.remoteStatusCalls, 1); |
| 73 | }).pipe(Effect.provide(layerTestFor(state))); |
| 74 | }); |
Run vp test run apps/server/src/vcs/VcsStatusBroadcaster.test.ts -t "local-only refresh".
In the app, the first case corresponds to opening the branch menu while the server's first remote read for that checkout (git fetch plus the PR lookup) is still running, for example right after the server starts. The second corresponds to opening the menu before any remote read has finished, then letting an agent turn end. We traced the app steps from the code and have not reproduced them in the UI on main; the server cases above are the evidence.
Expected behavior
A local-only refresh updates local status only. It does not remove a remote status that a concurrent read has just cached, and it does not leave an entry that stops later remote reads. Here, getStatus returns the PR and upstream from the first remote read, and refreshPullRequestStatus reads the remote.
Actual behavior
The local-only refresh caches { value: null } and publishes remoteUpdated: null:
- Overlap. The client loses the PR badge, upstream and ahead/behind that the first read just published. They come back at the next poll, 30 seconds later by default. With the Git fetch interval set to 0, later polls skip, so they stay empty until a full refresh.
- Before any remote read.
refreshPullRequestStatusreturnsnullwithout reading, because it skips whencached.remote?.value === null. The turn-end divergence and PR refresh is lost. - After a branch switch. The same entry is written when the branch changed, for example after a checkout outside T3 Code followed by opening the branch menu. The turn-end PR refresh then skips. A subscription that opens afterwards finds a cached remote, so it waits a full interval before its first read.
Impact
Minor bug or occasional failure
Version or commit
main @ c6ca6056309904f89b90b6055d797973be0a0939 (2026-11-09). apps/server/src/vcs/ is identical in v0.1.8-nightly.20261109.4399.
Environment
Linux x64, Node 26.12.1. Server unit tests with the fake GitWorkflowService in VcsStatusBroadcaster.test.ts. No provider involved.
Logs or stack traces
| 1 | × a local-only refresh that overlaps the first remote poll keeps its PR |
| 2 | AssertionError: expected null to deeply equal { number: 2978, …(5) } |
| 3 | ❯ assert.deepStrictEqual(status.pr, remoteStatusWithPr.pr); |
| 4 | × a local-only refresh before any remote read leaves the PR refresh able to read |
| 5 | AssertionError: expected undefined to deeply equal { number: 2978, …(5) } |
| 6 | ❯ assert.deepStrictEqual(remote?.pr, remoteStatusWithPr.pr); |
| 7 | Tests 2 failed | 26 skipped (28) |
The file's existing 26 cases pass on the same tree.
Workaround
A full refresh (vcs.refreshStatus without localOnly) reads the remote again and restores the status.
How our fork fixed it, in case it helps. We have not opened a PR. In the local-only branch:
- It never writes the
{ value: null }entry. When the branch did not change, it leaves the remote alone, so an in-flight read or the subscription's first read fills it. - When the branch changed,
updateCachedLocalStatushas already cleared the remote and publishedremoteUpdated: null. The fork leaves the remote absent, invalidates the remote cache, and asks that checkout's existing poller for one immediate read. The poller waits on a latch with a timeout instead of a plain sleep, so the request ends its current wait without resetting its failure backoff. A failed requested read is retried at the backoff pace.
| 1 | const branchChanged = |
| 2 | previous?.local !== null && previous?.local !== undefined && |
| 3 | previous.local.value.refName !== local.refName; |
| 4 | if (branchChanged && !(yield* getCachedStatus(cwd))?.remote) { |
| 5 | yield* workflow.invalidateRemoteStatus(cwd); |
| 6 | const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); |
| 7 | if (poller) yield* poller.readNow; |
| 8 | } |
With this, both cases above pass, along with branch-switch cases for the turn-end PR refresh, automatic fetch at 0, and a subscription opened after the switch. Two existing cases had fakes that ignore the branch and needed adjusting: "publishes terminal branch switches and validates cached status reads" and "clears the previous branch's remote status after an in-flight PR refresh".