Skip to content

Make sync actually receive, survive an hour, and say when it doesn't - #69

Merged
amsultan2010 merged 7 commits into
mainfrom
amsultan2010/cc-polling-fallback
Aug 6, 2026
Merged

amsultan2010 merged 7 commits into
mainfrom
amsultan2010/cc-polling-fallback

Conversation

@amsultan2010

Copy link
Copy Markdown
Owner

Three rounds of fixes, each found by running the workflow against production rather than
against the stub, and each hidden behind the one before it.

The stream was the only way in

The service runs as a Vercel serverless function, which cannot answer a WebSocket upgrade,
so /v1/stream 404s on every attempt and no amount of reconnecting would ever help.
Publishing worked; receiving did not. That is an uploader, not a sync.

The receiving half already existed -- catchUp() drains GET /v1/changes?since= and
already ran on every resubscribe. It just had nothing to call it when the socket never
opened. A failed stream now polls that same route every 3s and stops the moment a socket
opens. connected means "in touch by either transport"; presence asks the new streaming,
because presence only exists on the socket.

The session was never refreshed

Supabase access tokens last an hour. The refresh token sat in the config next to the expired
access token and nothing ever spent it, so register() threw "Access token is invalid or
expired" and took the daemon down. Every authenticated call now refreshes a minute ahead of
expiry and persists the result -- Supabase rotates the refresh token on use, so persisting
is what makes the next start-up work. Concurrent callers share one refresh.

start reported a daemon that was already dead, then refused to fix it

The descriptor was written when the loopback server bound, before the stream or the first
publish, and start polls for exactly that file -- so it printed "Daemon started; this
checkout is syncing" about a process that died a second later. It is now published last.

Worse, that daemon told the user to run crosscode start, and start saw a session on disk,
said "Already signed in for this checkout", and started the same doomed daemon again. There
was no way out of that loop. "There is a session" and "this checkout can talk to the service"
are different questions; start now asks the second one.

And it now says when it stops

A failed poll set connected: false and discarded the reason, leaving a stuck cursor and a
silent terminal. It reports once per run of failures.

Verified live against production

Sign-in, crosscode start, crosscode invite producing a link, and a second checkout
joining through that link with its GitHub repo access verified -- all confirmed end to end,
which had never been done before. The joining checkout also raised a genuine conflict on
README.md and quarantined it for its agent rather than overwriting.

Two-way file sync is not confirmed, and not because of anything here: every
authenticated route is currently answering 500 with relation "terms_acceptances" does not exist. That table arrived with #66 and its migration was never applied to production. It
needs pnpm service:migrate against the production DATABASE_URL.

Tests

215 -> 219. New: an edit crossing both ways against a stub that refuses upgrades entirely
(verified it fails without the fix); refresh when expired, leave a live token alone, and
spend the refresh token once under three concurrent calls; no descriptor while start-up is
in flight (verified it fails without the fix); sign in again when the refresh fails, and
reuse a refreshed session without a second sign-in.

Two tests in sync-daemon.test.ts flake under full-suite CPU contention on a loaded
machine, always as timeouts and never assertions. Confirmed pre-existing: main fails the
same way at the same rate with none of this applied.

🤖 Generated with Claude Code

amsultan2010 and others added 3 commits August 6, 2026 12:01
`crosscode start` worked, published edits, and received none of its peers'. The daemon
retried `GET /v1/stream` and got 404 on every attempt: the service runs as a Vercel
serverless function, and a serverless function cannot answer a WebSocket upgrade. No amount
of reconnecting was ever going to help, and there was no other path in -- so the product
was an uploader, not a sync, and `crosscode status` reported `connected: false` with no way
for it to ever become true.

The receiving half already existed. `catchUp()` drains `GET /v1/changes?since=` and applies
each change, and it already ran on every (re)subscribe to close the gap left by a dropped
socket. It just had nothing to call it when the socket never opened at all. So a failed
stream now starts a poll loop that calls the same catch-up every 3s, and stops the moment a
socket opens.

Two details worth naming:

`connected` now means "in touch with the service by either transport", which is what
`crosscode status` should report. Presence asks the new `streaming` instead, because
presence exists only on the socket -- a polling room goes quiet about who is where, but it
is not blind to edits.

The poll loop guards re-entry on `pollActive` rather than on the timer handle. The timer is
unset while a tick is in flight, and the socket fails once per backoff, so starting a second
loop in that window would have been the normal case rather than the rare one.

## Tests

`sync-service-stub` gained a `streams: false` option that omits the upgrade listener
entirely, which is what makes node destroy the socket -- the client's view of a host that
does not serve WebSockets. The new test stands two real daemons up against it and asserts an
edit crosses in both directions. Verified it fails without the fix: it times out waiting for
either daemon to report itself connected.

`pnpm build` clean, `pnpm test` 215 passed / 11 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects found by running the workflow against production rather than against the
stub. Each was hidden behind the last, and together they made a checkout unusable roughly
an hour after sign-in with no way to recover from inside the CLI.

## The session was never refreshed

Supabase access tokens last an hour. The refresh token sat in the config next to the
expired access token and nothing ever spent it, so `register()` threw "Access token is
invalid or expired" and took the whole daemon down. Every authenticated call now goes
through `authorization()`, which refreshes a minute ahead of expiry and hands the refreshed
session back to be written to disk -- Supabase rotates the refresh token on use, so
persisting it is what makes the *next* start-up work. Concurrent callers share one refresh
through a promise, because two would each spend a token the other had already rotated away.

## `start` reported a daemon that was already dead

`crosscode start` polls for the descriptor every 100ms and reports success the moment it
appears. It was written when the loopback server bound -- before the stream, the watcher, or
the first publish -- so a daemon that then died on the expired session still had `start`
printing "Daemon started; this checkout is syncing" about a process that was gone a second
later. The descriptor is now published last, once start-up has actually succeeded.

## And there was no way out of the loop

The daemon told the user to run `crosscode start`. `start` saw a session on disk, said
"Already signed in for this checkout", and started the same doomed daemon again. "There is
a session" and "this checkout can talk to the service" are different questions; `start` now
asks the second one, refreshing if it can and signing in again if it cannot. An expired
sign-in also exits with that one line instead of a stack trace.

## Tests

- The refresh path: refreshes when expired, leaves a live token alone, and spends the
  refresh token exactly once under three concurrent calls.
- The descriptor: against a service that accepts and never answers, no descriptor exists
  while start-up is in flight. Verified it fails without the fix.
- `start`: signs in again when the refresh fails, and reuses (and stores) a refreshed
  session without a second sign-in.

`streamPollMs` joins the other tunable intervals so the polling test does not wait out real
3s cycles.

`pnpm build` clean, `pnpm test` 219 passed / 11 skipped. Two tests in
`sync-daemon.test.ts` flake under full-suite CPU contention on a loaded machine -- always as
timeouts, never assertions. Confirmed pre-existing: `main` fails the same way at the same
rate with none of this applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A failed poll set `connected: false` and discarded the reason. The daemon then sat there
with a cursor that never advanced and nothing on stderr -- which is exactly the state a
production outage put it in, and the reason diagnosing it needed a patched build rather
than a log line.

Reported once per run of failures rather than every 3s, and reset when a poll succeeds, so
a flapping service does not fill the terminal.

This is what surfaced `GET /v1/changes` answering 500 with `relation "terms_acceptances"
does not exist` -- a migration that shipped in the deploy but was never applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
crosscode Ready Ready Preview Aug 6, 2026 6:54pm

apply() calls catchUp() to fill a gap when a peer's change is built on a blob this checkout
has never held. Called from inside a drain, catchUp re-walked the same page from the same
cursor -- and the cursor cannot move past a change that has not applied, so it never moved.

The retry was already guarded: allowCatchup: false on the second receive exists so a second
miss surfaces rather than loops. But the fill in front of it was not, and that is where the
recursion was.

In production this was one peer asking GET /v1/changes several times a second,
indefinitely, while crosscode status showed a cursor frozen at 1 and reported no error at
all. A fresh checkout that joined a project it could not fully rebase never received
anything again.

catchUp is now non-re-entrant: a nested call is a no-op, because the drain already running
is the fill. The retry then decides, and an honest conflict reaches the agent.

The stub counts GET /v1/changes, and the test asserts a daemon holding a change whose
baseHash names a blob that exists nowhere still advances its cursor and then goes quiet.
Verified it fails without the guard: the cursor never advances and it times out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bouncing one file straight back landed the reply inside the hot window the first write
opened, so under CI's load it was a deferral test wearing a transport test's name -- and it
failed there while passing locally in under two seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first time two real checkouts joined one project, the teammate arrived holding three
conflicts before typing anything: .mcp.json, .claude/settings.json, and the crosscode
SKILL.md. Every checkout installs its own copy, so no two peers share an ancestor for them.

They could never converge either, because `crosscode start` rewrites them locally on every
run -- so any resolution is undone by the next start. And they are not content in the sense
the rest of the sync means: they are per-machine setup, regenerated on demand by the command
the invite already tells a teammate to run. Nothing is lost by leaving them out, and nothing
was fixable by leaving them in.

Added to isDenied, which is already the one choke point checked on the way out and on the
way in. The rest of .claude/ still syncs -- a team's own skills and commands are theirs to
share, and only the three paths Crosscode itself writes are excluded.

Verified live: the two checkouts that were holding these three conflicts came back up with
zero, and still sync in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng-fallback

# Conflicts:
#	apps/cli/src/setup.test.ts
#	apps/cli/src/setup.ts
@amsultan2010
amsultan2010 merged commit 769fb21 into main Aug 6, 2026
@amsultan2010
amsultan2010 deleted the amsultan2010/cc-polling-fallback branch August 6, 2026 18:59

This branch was successfully deployed

1 active deployment
Preview — 2610c16b Deployed Aug 6, 2026 by vercel[bot]
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