<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 |
no — providers, 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:
- Make
mountsync the sole writer. It already emits most of the document and already runs every cycle.
- 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.
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.
- 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.
- 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).
- 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.
- 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.
<localDir>/.relay/state.jsonhas 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.mdall 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:343reads this file.isMountStateReady(:355):providersis writer-1-absent — only the CLI mirror emits it. Whenever mountsync wrote last,providersis missing, the array is empty, and[].every()returnstrue.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):writeMirrorStateFilestampssnapshot.LastReconcileAt = time.Now()unconditionally atcmd/relayfile-cli/main.go:10996, whether or not a reconcile occurred. The timestamp is always fresh by construction.intervalMs: 0, so!intervalMsis truthy and the function early-returnsfalse.Captured off a live mount, read-only:
Root cause of the zero, which is a separate latent bug worth fixing on its own:
publicState.IntervalMsis set froms.interval(internal/mountsync/syncer.go:7153), ands.interval = opts.Interval(:1810).cmd/relayfile-mount/main.go:406passesInterval: cfg.interval— but all threecmd/relayfile-clicall sites (main.go:5135,:5588,:6852) omit it, soopts.Intervalis the zero value. The live daemon is the CLI binary, which is why the live capture shows0. The CLI has the interval in hand — it passes it torunMountLoopWithAuthLock— it just never gives it to the syncer.Net: whichever writer won,
isMountStateStalereturnsfalse. 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/mountsynccannot take that mutex and does not. Both writers read-modify-writefailedWritebacks, 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:
failedWritebacksis 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;TestFailedWritebacksSurvivesAlternationpasses on HEAD.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
internal/mountsync/syncer.go:1608:publicStatePath := filepath.Join(localRoot, ".relay", "state.json"), stored at:1793.internal/mountsync/syncer.go:7200(savePublicState,:7007).publicStateatinternal/mountsync/syncer.go:1497.localRoot,syncMode,states,files,counters,circuit,outbox,lowMemory,lastAppliedRevision,reconcileAgeSecs,credExpiresInSecs,staleAfter.Writer 2 — CLI mirror state
writeMirrorStateFileatcmd/relayfile-cli/main.go:10980, writing at:10998to the identical join. Called from:13040and:2532.syncStateFileatcmd/relayfile-cli/main.go:380-406.providers[],daemon,guards,stallReason,incrementalReadNotReadySince.Both are handed the same directory by one closure. At
cmd/relayfile-cli/main.go:6852-6892,scope.LocalDiris passed both asLocalRoottomountsync.NewSyncerand aslocalDirtorunMountLoopWithAuthLock. 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:10891readBootstrapStatuscmd/relayfile-cli/main.go:10912readGuardCounterscmd/relayfile-cli/main.go:11029incrementFailedWritebacksInState— a full read-modify-write. It decodes intomap[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:7020readPublicFailedWritebacks— the mountsync half of the same pattern.lastEventAtis one field name with two meaningsBoth 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 overproviders[].lastEventAt). A consumer pollinglastEventAtgets 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
readGuardCountersdoes not return nil when writer 1 wins — it falls through to writer 1'scounters/circuitblock 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) readsstallReason, 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:Two documents, 1738 B vs 3.31 MB, alternating sub-second,
lastEventAtvalues six days apart.Reproduction — red test
cmd/relayfile-cli/relay_state_two_writers_test.godrives both real writers against onelocalDir(writer 1 viamountsync.NewSyncer(...).FlushOutboxOnce, which reachessavePublicStatewith no network; writer 2 viawriteMirrorStateFile). Fails on5480825, exit code 1, 15 distinct failures.Both preconditions pass before any collision assertion fires — writer 1 alone emits
localRoot, writer 2 alone emitslastEventAt— so the harness is not failing for its own reasons.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:cmd/relayfile-cli/main.go:4114hasExistingMountStatecmd/relayfile-cli/main.go:4877findRelayMountRootcmd/relayfile-cli/main.go:4851writeback push resolutionworkspaceIdvia #4cmd/relayfile-cli/main.go:5487readWritebackStatesyncStateFileproviders,daemon,guards,stallReasonread as zerocmd/relayfile-cli/main.go:5623readMountRemoteRootremoteRootcmd/relayfile-cli/main.go:8556readPersistedStallReasonstallReason""~half the timecmd/relayfile-cli/main.go:10891readBootstrapStatusbootstrapcmd/relayfile-cli/main.go:10912readGuardCountersguards|counters+circuitcmd/relayfile-cli/main.go:11011readPersistedFailedWritebacksUnlockedfailedWritebackscmd/relayfile-cli/main.go:11029incrementFailedWritebacksInStatemap[string]anycmd/relayfile-mount/main.go:659readBootstrapProgressbootstrappackages/sdk/typescript/src/mount-launcher.ts:343readMountStateFileproviders,lastReconcileAt,intervalMsDocumentation 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/mountsyncowns<localDir>/.relay/state.json; the CLI stops writing it directly.Two alternatives were considered and rejected:
.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 returnstrueon a missing or short-readproviders.Steps:
mountsyncthe sole writer. It already emits most of the document and already runs every cycle.Syncer(e.g.SetSurfaceStatus) carrying the CLI's data:providers[],daemon,guards,stallReason,incrementalReadNotReadySince. The CLI already holds the*SyncerinrunMountLoopWithAuthLock, sowriteSnapshot(main.go:13040) calls the setter instead ofwriteMirrorStateFile.publicStategrows the writer-2-only fields; the on-disk document becomes the union. Every field any consumer reads today is present on every read.isMountStateReadymust treat absentprovidersas not ready, distinct from an explicitly empty list: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.Intervalat the threecmd/relayfile-cliNewSyncercall sites (:5135,:5588,:6852) sointervalMsis non-zero; treatintervalMs <= 0as stale/unknown rather than fresh; and stop stampinglastReconcileAt = time.Now()on a write that is not a reconcile (main.go:10996).lastEventAtcollision by dropping writer 2's top-levellastEventAtrather than adding a field. It is already redundant —main.go:10870-10877computes it as the max overproviders[].lastEventAt, which survives in the union. Top-levellastEventAtthen means exactly one thing: mountsync's event clock.main.go:2532(provisioning wait, possibly a different process) andincrementFailedWritebacksInState(:11029, called from the writeback failure transport at:12034) — enforce merge, never replace: field-scoped read-modify-write preserving unknown keys.incrementFailedWritebacksInStatealready does this and can stay;main.go:2532must 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
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.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 readproviders[].lastEventAt.jqexpressions indocs/skills/relayfile-workspace.mdanddocs/guides/agent-vfs-usage.mdcontinue to work unchanged, since the union preservesproviders[]andpendingWriteback.Notes
intervalMs: 0root cause (§2) is independently fixable and worth landing on its own even ahead of the larger change.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 atsyncer.go:1497(not1521), and thefailedWritebackscounter is not zeroed by the schema alternation — the real defect there is a lost update (§3).The collision is the cause; this is the consequence worth fixing: both health signals computed from
.relay/state.jsonare structurally incapable of reporting ill health. Readiness returnstruewhen there is no provider evidence at all, and staleness returnsfalsewhichever writer won. A mount that is broken reports ready and fresh, and nothing downstream can tell.