Skip to content

.relay/state.json has two writers with disjoint schemas: readiness fails open and staleness detection is dead #412

Description

@khaliqgant

<localDir>/.relay/state.json has two independent writers in the same binary, emitting two disjoint JSON schemas to the same path, overwriting each other roughly once per second.

The consequence is not primarily a confusing document. It is that both health signals computed from this file are structurally incapable of reporting ill health: the readiness gate fails open, and staleness detection is dead in both branches. A mount that is broken reports ready and fresh.

This file is a documented public contract (docs/productized-cloud-mount-contract.md, docs/guides/agent-vfs-usage.md, docs/skills/relayfile-workspace.md all instruct agents to read it), so the blast radius is external.

Verified against HEAD 5480825.


1. The readiness gate fails open

packages/sdk/typescript/src/mount-launcher.ts:343 reads this file. isMountStateReady (:355):

const providers = Array.isArray(state.providers) ? state.providers : []
return providers.every((provider) => { ... })

providers is writer-1-absent — only the CLI mirror emits it. Whenever mountsync wrote last, providers is missing, the array is empty, and [].every() returns true.

$ node -e '...'
writer1 providers.every() READY: true

The mount is declared ready because there is no provider evidence at all. This is a readiness gate that fails open, flipped by a race, and it is deterministic whenever mountsync wins.

2. Staleness detection is dead in both branches

isMountStateStale (mount-launcher.ts:366):

const intervalMs = normalizeInteger(state.intervalMs)
if (!lastReconcileAt || !intervalMs || intervalMs <= 0) return false
  • Writer 2's document is never stale, because writeMirrorStateFile stamps snapshot.LastReconcileAt = time.Now() unconditionally at cmd/relayfile-cli/main.go:10996, whether or not a reconcile occurred. The timestamp is always fresh by construction.
  • Writer 1's document is never stale, because it carries intervalMs: 0, so !intervalMs is truthy and the function early-returns false.

Captured off a live mount, read-only:

WRITER2 intervalMs= 30000  lastReconcileAt= 2026-08-09T20:13:07Z
WRITER1 intervalMs= 0      lastReconcileAt= 2026-08-09T20:14:41.115871Z
writer1 stale-check early-return (!intervalMs): true
writer2 age(ms): 0 -> stale? false

Root cause of the zero, which is a separate latent bug worth fixing on its own: publicState.IntervalMs is set from s.interval (internal/mountsync/syncer.go:7153), and s.interval = opts.Interval (:1810). cmd/relayfile-mount/main.go:406 passes Interval: cfg.interval — but all three cmd/relayfile-cli call sites (main.go:5135, :5588, :6852) omit it, so opts.Interval is the zero value. The live daemon is the CLI binary, which is why the live capture shows 0. The CLI has the interval in hand — it passes it to runMountLoopWithAuthLock — it just never gives it to the syncer.

Net: whichever writer won, isMountStateStale returns false. Staleness detection is not degraded; it does not function.

3. A mutex that looks synchronized and is not — writeback failures are undercounted

failedWritebacksStateMu (cmd/relayfile-cli/main.go) serializes CLI-side increments against each other. internal/mountsync cannot take that mutex and does not. Both writers read-modify-write failedWritebacks, so an increment landing between mountsync's read (syncer.go:7020) and its write (syncer.go:7200) is overwritten with the stale value. A lock that appears to synchronize across a boundary it does not cross is more dangerous than no lock.

Being precise about what is and is not broken, because the distinction matters to anyone reading the code:

  • The counter is not zeroed by the schema alternation. failedWritebacks is in both structs (syncer.go:1513, main.go:394), and both writers deliberately read it back before writing — readPublicFailedWritebacks() (syncer.go:7020) and a max against the persisted value (main.go:10986). That hardening works; TestFailedWritebacksSurvivesAlternation passes on HEAD.
  • The counter is lost to the classic lost update. 200 increments against a republishing syncer:
run 1: failedWritebacks = 197 after 200 increments  (3 lost)
run 2: failedWritebacks = 196                        (4 lost)
run 3: failedWritebacks = 196                        (4 lost)
run 4: PASS — no loss
run 5: failedWritebacks = 199                        (1 lost)
run 6: failedWritebacks = 195                        (5 lost)

Loss in 5 of 6 runs, ~0.5–2.5%. This is a race demonstration, non-deterministic by construction — not a stable CI gate, and it should not be wired up as one. The consequence is that the surface which exists to reveal writeback failures under-reports them.


The two writers

Writer 1 — mountsync public state

  • Path derived at internal/mountsync/syncer.go:1608: publicStatePath := filepath.Join(localRoot, ".relay", "state.json"), stored at :1793.
  • Written at internal/mountsync/syncer.go:7200 (savePublicState, :7007).
  • Struct: publicState at internal/mountsync/syncer.go:1497.
  • Emits: localRoot, syncMode, states, files, counters, circuit, outbox, lowMemory, lastAppliedRevision, reconcileAgeSecs, credExpiresInSecs, staleAfter.
  • Observed live size: ~3.3 MB.

Writer 2 — CLI mirror state

  • writeMirrorStateFile at cmd/relayfile-cli/main.go:10980, writing at :10998 to the identical join. Called from :13040 and :2532.
  • Struct: syncStateFile at cmd/relayfile-cli/main.go:380-406.
  • Emits: providers[], daemon, guards, stallReason, incrementalReadNotReadySince.
  • Observed live size: 1738 B.

Both are handed the same directory by one closure. At cmd/relayfile-cli/main.go:6852-6892, scope.LocalDir is passed both as LocalRoot to mountsync.NewSyncer and as localDir to runMountLoopWithAuthLock. Same process, same mount, same path.

Fifteen fields, zero overlap between the two writer-only sets. The overlapping fields (workspaceId, remoteRoot, mode, status, pendingWriteback, failedWritebacks, lastReconcileAt, bootstrap) survive both writes, which is why this went unnoticed: mount-root discovery and writeback-path resolution keep working.

Read-modify-write races on top

Four sites read this file and can have it overwritten under them:

  • cmd/relayfile-cli/main.go:10891 readBootstrapStatus
  • cmd/relayfile-cli/main.go:10912 readGuardCounters
  • cmd/relayfile-cli/main.go:11029 incrementFailedWritebacksInState — a full read-modify-write. It decodes into map[string]any, so it round-trips unknown fields, which means it can resurrect an entire stale 3.3 MB writer-1 document after writer 2 replaced it.
  • internal/mountsync/syncer.go:7020 readPublicFailedWritebacks — the mountsync half of the same pattern.

lastEventAt is one field name with two meanings

Both writers emit it. Writer 1 means mountsync's own event clock (fresh, advancing). Writer 2 means the cloud provider feed (main.go:10870-10877, the max over providers[].lastEventAt). A consumer polling lastEventAt gets a value whose meaning changes between reads. This caused a human operator to misreport "the feed moved" twice from single reads.

Operator surfaces silently swap data sources

readGuardCounters does not return nil when writer 1 wins — it falls through to writer 1's counters/circuit block and returns a guards document sourced from a different writer. The operator sees plausible numbers that are not the ones writer 2 persisted. Returning nil would have been safer.

readPersistedStallReason (main.go:8556) reads stallReason, writer-2-only, so it returns "" on roughly half of all reads.


Live evidence

Read-only stat + parse of a running mount, once per second:

22:05:01 size=1738     lastEventAt=2026-08-03T07:26:26.334Z  keys=[bootstrap,daemon,deniedPaths,failedWritebacks,guards,intervalMs]
22:05:02 size=1738     lastEventAt=2026-08-03T07:26:26.334Z
22:05:04 size=3313688  lastEventAt=2026-08-09T19:54:59.273Z  keys=[bootstrap,circuit,counters,credExpiresInSecs,deniedPaths,files]
22:05:05 size=1738     lastEventAt=2026-08-03T07:26:26.334Z
22:05:06 size=3313688  lastEventAt=2026-08-09T19:55:32.864Z
22:05:07 size=1738     lastEventAt=2026-08-03T07:26:26.334Z

Two documents, 1738 B vs 3.31 MB, alternating sub-second, lastEventAt values six days apart.

Reproduction — red test

cmd/relayfile-cli/relay_state_two_writers_test.go drives both real writers against one localDir (writer 1 via mountsync.NewSyncer(...).FlushOutboxOnce, which reaches savePublicState with no network; writer 2 via writeMirrorStateFile). Fails on 5480825, exit code 1, 15 distinct failures.

Both preconditions pass before any collision assertion fires — writer 1 alone emits localRoot, writer 2 alone emits lastEventAt — so the harness is not failing for its own reasons.

$ go test ./cmd/relayfile-cli/ -run 'TestRelayStateJSON' -v ; echo "EXIT=$?"

=== RUN   TestRelayStateJSONHasExactlyOneWriter
    writer 2 clobbered writer 1: field "localRoot" is absent from .relay/state.json after the CLI mirror write
      (keys now: [daemon deniedPaths failedWritebacks guards intervalMs lastEventAt lastReconcileAt mode
       pendingConflicts pendingWriteback providers remoteRoot stallReason status workspaceId])
    ... same for "states", "counters", "files", "outbox", "credExpiresInSecs"
    writer 1 clobbered writer 2: field "providers" is absent from .relay/state.json after the mountsync write
      (keys now: [circuit counters deniedPaths intervalMs lastReconcileAt lastSuccessfulReconcileAt localRoot
       lowMemory mode outbox pendingConflicts pendingWriteback remoteRoot states status syncMode workspaceId])
    ... same for "daemon", "guards", "stallReason"
    readGuardCounters (main.go:10912) circuitOpenEvents = 0, want 7 — it silently switched from writer 2's
      persisted `guards` block to writer 1's `counters` block
    readGuardCounters (main.go:10912) tombstonesConfirmed = 0, want 3
    readPersistedStallReason (main.go:8556) = "", want "provider feed frozen"
    readWritebackState (main.go:5487) sees 0 providers
--- FAIL: TestRelayStateJSONHasExactlyOneWriter (0.01s)
=== RUN   TestRelayStateJSONLastEventAtHasOneMeaning
    lastEventAt vanished from .relay/state.json after a mountsync write: a consumer polling this field sees
      the provider-feed timestamp "2026-08-03T07:26:26.334Z" disappear and reappear
--- FAIL: TestRelayStateJSONLastEventAtHasOneMeaning (0.01s)
FAIL	github.com/agentworkforce/relayfile/cmd/relayfile-cli	0.432s
EXIT=1

The two key-lists printed above are the defect, emitted by the code itself.

Reader survey

Every production reader of .relay/state.json. Greps used:

git grep -n 'state\.json' -- '*.go' | grep -v '_test.go'
git grep -n -E 'state\.json' -- '*.ts' '*.mjs' '*.js' ':!*test*'
git grep -n -E '\.relay/state\.json' -- ':!*.go'
# Site Reads Safe across both writers?
1 cmd/relayfile-cli/main.go:4114 hasExistingMountState existence only yes
2 cmd/relayfile-cli/main.go:4877 findRelayMountRoot existence only yes
3 cmd/relayfile-cli/main.go:4851 writeback push resolution workspaceId via #4 yes (overlapping)
4 cmd/relayfile-cli/main.go:5487 readWritebackState whole syncStateFile noproviders, daemon, guards, stallReason read as zero
5 cmd/relayfile-cli/main.go:5623 readMountRemoteRoot remoteRoot yes (overlapping)
6 cmd/relayfile-cli/main.go:8556 readPersistedStallReason stallReason no — writer-2-only, "" ~half the time
7 cmd/relayfile-cli/main.go:10891 readBootstrapStatus bootstrap yes (both emit it)
8 cmd/relayfile-cli/main.go:10912 readGuardCounters guards | counters+circuit no — silently swaps data source
9 cmd/relayfile-cli/main.go:11011 readPersistedFailedWritebacksUnlocked failedWritebacks decodes from either document; hazard is the lost update, not the schema
10 cmd/relayfile-cli/main.go:11029 incrementFailedWritebacksInState full RMW via map[string]any no — can resurrect the other writer's whole document
11 cmd/relayfile-mount/main.go:659 readBootstrapProgress bootstrap yes
12 packages/sdk/typescript/src/mount-launcher.ts:343 readMountStateFile providers, lastReconcileAt, intervalMs no — fail-open readiness (§1) and dead staleness (§2)

Documentation instructing external agents to read fields from this file, therefore also affected: docs/skills/relayfile-workspace.md:83 (jq '.providers[] | select(.provider=="notion") | .status' — writer-2-only), docs/guides/agent-vfs-usage.md:216 (jq '.pendingWriteback'), docs/productized-cloud-mount-contract.md:673,703.


Proposed fix

Chosen: a single owner for the file, with the second writer contributing in-process, and the on-disk document becoming the union of both schemas. internal/mountsync owns <localDir>/.relay/state.json; the CLI stops writing it directly.

Two alternatives were considered and rejected:

  • Split into two paths (.relay/state.json + .relay/mount-state.json). Rejected. Both halves are documented at the same path today, so whichever writer loses the canonical name breaks every external agent following our own docs plus the TS SDK. It gives no consumer a single coherent view, and it re-introduces cross-file consistency as a new problem. Critically, it would leave the fail-open readiness gate intact — separating the files stops the clobber but [].every() still returns true on a missing or short-read providers.
  • Union schema with two writers retained. Rejected on its own. It makes the documents schema-compatible but does nothing about lost updates: each writer still marshals from scratch and still discards the other's fresh values. It reshapes the race instead of removing it.

Steps:

  1. Make mountsync the sole writer. It already emits most of the document and already runs every cycle.
  2. Add an exported setter on Syncer (e.g. SetSurfaceStatus) carrying the CLI's data: providers[], daemon, guards, stallReason, incrementalReadNotReadySince. The CLI already holds the *Syncer in runMountLoopWithAuthLock, so writeSnapshot (main.go:13040) calls the setter instead of writeMirrorStateFile.
  3. publicState grows the writer-2-only fields; the on-disk document becomes the union. Every field any consumer reads today is present on every read.
  4. Make readiness fail closed. This is the point of the change, not a side effect. isMountStateReady must treat absent providers as not ready, distinct from an explicitly empty list:
    if (!Array.isArray(state.providers)) return false   // no evidence != healthy
    if (state.providers.length === 0) return false
    return state.providers.every(...)
    Add a schema-version field (e.g. stateVersion: 2) so a reader can tell "this writer does not publish providers" from "this mount has no providers", and refuse readiness on an unrecognised or missing version.
  5. Fix staleness in both branches. Pass Interval at the three cmd/relayfile-cli NewSyncer call sites (:5135, :5588, :6852) so intervalMs is non-zero; treat intervalMs <= 0 as stale/unknown rather than fresh; and stop stamping lastReconcileAt = time.Now() on a write that is not a reconcile (main.go:10996).
  6. Resolve the lastEventAt collision by dropping writer 2's top-level lastEventAt rather than adding a field. It is already redundant — main.go:10870-10877 computes it as the max over providers[].lastEventAt, which survives in the union. Top-level lastEventAt then means exactly one thing: mountsync's event clock.
  7. For the paths that write without a syncer handle — main.go:2532 (provisioning wait, possibly a different process) and incrementFailedWritebacksInState (:11029, called from the writeback failure transport at :12034) — enforce merge, never replace: field-scoped read-modify-write preserving unknown keys. incrementFailedWritebacksInState already does this and can stay; main.go:2532 must stop calling the full-overwrite path. To actually close the lost-update window rather than narrow it, the counter needs a single-owner increment path through the syncer, or an advisory file lock both packages take.

Migration cost, stated plainly

  • Readers 1, 2, 3, 5, 7, 11 — unaffected. They read existence or fields present in both schemas today.
  • Readers 4, 6, 8, 9, 10 — all in cmd/relayfile-cli, all improve with no code change: the union document always carries the fields they currently find only half the time. Reader 10 should still be converted to the single-owner path to close the counter race.
  • Reader 12 (TypeScript SDK) — the only reader requiring code changes, and they are the point of the fix: fail-closed readiness plus the staleness correction (step 4/5). This is a behaviour change: mounts that previously reported ready-with-no-evidence will now correctly report not-ready, which may surface as new failures in callers that were silently passing. That is the bug being fixed, and it should be called out in the changelog rather than smoothed over.
  • An old reader running against the new layout is safe. The union is additive: every field an old reader looks up still exists at the same JSON path. An old SDK build keeps its fail-open bug — it does not gain a new failure from the layout — so SDK and daemon can be rolled out independently, in either order. The only field an old reader loses is writer 2's top-level lastEventAt (step 6); since its current value is a coin flip between two clocks six days apart, no consumer can be correctly depending on it today. Consumers wanting the provider feed should read providers[].lastEventAt.
  • Documented jq expressions in docs/skills/relayfile-workspace.md and docs/guides/agent-vfs-usage.md continue to work unchanged, since the union preserves providers[] and pendingWriteback.

Notes

  • The intervalMs: 0 root cause (§2) is independently fixable and worth landing on its own even ahead of the larger change.
  • Diagnosis originated with relayfile-coordination-lead-0809; this issue is the independent verification, the reader survey, the red test, and the fix recommendation. Two claims from the original diagnosis were corrected in the course of verification: the writer-1 struct head is at syncer.go:1497 (not 1521), and the failedWritebacks counter is not zeroed by the schema alternation — the real defect there is a lost update (§3).
  • No readiness/dispatch label is applied deliberately: filing this must not trigger a Factory run.

The collision is the cause; this is the consequence worth fixing: both health signals computed from .relay/state.json are structurally incapable of reporting ill health. Readiness returns true when there is no provider evidence at all, and staleness returns false whichever writer won. A mount that is broken reports ready and fresh, and nothing downstream can tell.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions