From ab0f386927d2642072881b2dbc0a6f7fb059fe15 Mon Sep 17 00:00:00 2001 From: Nishant Faria Date: Wed, 17 Jun 2026 21:38:09 +0400 Subject: [PATCH] Add PROOF_SINGLE_REPLICA mode to avoid false LIVE_DOC_UNAVAILABLE on single-replica self-hosts The live-doc mutation gate assumes a multi-replica fleet: a recent collab-session lease with no live connection on the current node (total > 0 && exactEpochCount === 0) is read as "another replica holds the live document" and returns LIVE_DOC_UNAVAILABLE, blocking /edit/v2 (and rewrite.apply) until the lease expires. On a single-replica deployment there are no sibling nodes, so this is a false positive: after any viewer disconnects, all programmatic edits are blocked for the COLLAB_SESSION_TTL_SECONDS window (default 5 min), which pushes agents to recreate documents instead of updating in place. Add an opt-in PROOF_SINGLE_REPLICA flag (isSingleReplicaDeployment) plus a derived isHostedMultiReplicaRewriteEnvironment helper. When single-replica: - the canonical mutation derives activeCollabClients from exactEpochCount (this node's own connections) instead of ghost leases, and skips the hostedRemoteLiveLease block - agent edit/v2 strict live-client count uses exactEpochCount Default behavior is unchanged when the flag is unset. Co-Authored-By: Claude Opus 4.8 --- server/agent-edit-v2.ts | 8 +++++--- server/canonical-document.ts | 12 ++++++++++-- server/rewrite-policy.ts | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/server/agent-edit-v2.ts b/server/agent-edit-v2.ts index 88626cc5..28f1b61c 100644 --- a/server/agent-edit-v2.ts +++ b/server/agent-edit-v2.ts @@ -35,7 +35,7 @@ import { summarizeParseError, type HeadlessMilkdownParser, } from './milkdown-headless.js'; -import { isHostedRewriteEnvironment } from './rewrite-policy.js'; +import { isHostedMultiReplicaRewriteEnvironment } from './rewrite-policy.js'; import { getActiveCollabClientBreakdown } from './ws.js'; import { canonicalizeStoredMarks, type StoredMark } from '../src/formats/marks.js'; import { refreshSnapshotForSlug } from './snapshot.js'; @@ -126,12 +126,14 @@ const COLLAB_WRITE_STABILITY_SAMPLE_MS = parsePositiveInt(process.env.AGENT_EDIT function getStrictLiveClientCount(slug: string): number { const breakdown = getActiveCollabClientBreakdown(slug); - return isHostedRewriteEnvironment() ? breakdown.total : breakdown.exactEpochCount; + // Single-replica trusts its own connection view; only a genuine multi-replica + // fleet counts ghost leases (a live doc that might be held on a sibling node). + return isHostedMultiReplicaRewriteEnvironment() ? breakdown.total : breakdown.exactEpochCount; } async function getStrictLiveClientCountWithGrace(slug: string): Promise { let breakdown = getActiveCollabClientBreakdown(slug); - if (!isHostedRewriteEnvironment()) return breakdown.exactEpochCount; + if (!isHostedMultiReplicaRewriteEnvironment()) return breakdown.exactEpochCount; if (breakdown.total === 0 || breakdown.exactEpochCount > 0) return breakdown.total; const timeoutMs = parsePositiveInt(process.env.HOSTED_LIVE_DOC_GRACE_MS, 1500); diff --git a/server/canonical-document.ts b/server/canonical-document.ts index 3069c8c5..ff473eee 100644 --- a/server/canonical-document.ts +++ b/server/canonical-document.ts @@ -66,7 +66,7 @@ import { summarizeDocumentIntegrity, } from './document-integrity.js'; import { recordProjectionRepair } from './metrics.js'; -import { isHostedRewriteEnvironment } from './rewrite-policy.js'; +import { isHostedRewriteEnvironment, isSingleReplicaDeployment } from './rewrite-policy.js'; import { refreshSnapshotForSlug } from './snapshot.js'; import { pauseDocumentAndPropagate } from './share-state.js'; import { getActiveCollabClientBreakdown, getActiveCollabClientCount } from './ws.js'; @@ -800,17 +800,24 @@ export async function mutateCanonicalDocument(args: CanonicalMutationArgs): Prom const collabRuntimeEnabled = getCollabRuntime().enabled; let collabClientBreakdown = getActiveCollabClientBreakdown(args.slug); const hostedRuntime = isHostedRewriteEnvironment(); + // Single-replica self-host: there is no sibling node that could hold the live + // doc, so a recent lease without a live connection here means "nobody is + // connected", not "another replica owns it". Trust this node's own view. + const singleReplica = isSingleReplicaDeployment(); const strictLiveDocRequested = args.strictLiveDoc !== false; if ( strictLiveDocRequested && collabRuntimeEnabled && hostedRuntime + && !singleReplica && collabClientBreakdown.total > 0 && collabClientBreakdown.exactEpochCount === 0 ) { collabClientBreakdown = await waitForHostedLiveLeaseMaterialization(args.slug); } - let activeCollabClients = collabClientBreakdown.total; + let activeCollabClients = singleReplica + ? collabClientBreakdown.exactEpochCount + : collabClientBreakdown.total; if (strictLiveDocRequested && activeCollabClients > 0 && !collabRuntimeEnabled) { return { ok: false, @@ -822,6 +829,7 @@ export async function mutateCanonicalDocument(args: CanonicalMutationArgs): Prom } const hostedRemoteLiveLease = collabRuntimeEnabled && hostedRuntime + && !singleReplica && collabClientBreakdown.total > 0 && collabClientBreakdown.exactEpochCount === 0; if (strictLiveDocRequested && hostedRemoteLiveLease) { diff --git a/server/rewrite-policy.ts b/server/rewrite-policy.ts index dd0954c6..8e43fee4 100644 --- a/server/rewrite-policy.ts +++ b/server/rewrite-policy.ts @@ -76,6 +76,26 @@ export function isHostedRewriteEnvironment(runtimeEnvironment: string = getRewri return runtimeEnvironment === 'production' || runtimeEnvironment === 'staging' || isRailwayHostedRuntime(); } +// Single-replica self-hosting. The live-doc gate assumes a multi-replica fleet: +// a recent collab-session lease with no live connection *on this node* is read +// as "another replica holds the doc live", returning LIVE_DOC_UNAVAILABLE. On a +// single replica there are no siblings, so that inference is a false positive +// that blocks all programmatic edits for the lease window after a viewer leaves. +// When set, callers trust this node's own connection view (exactEpochCount) +// instead of ghost leases. Auto-on when RAILWAY_REPLICA_ID is absent would be +// too aggressive (cold starts), so this stays explicit/opt-in. +export function isSingleReplicaDeployment(): boolean { + const raw = (process.env.PROOF_SINGLE_REPLICA || '').trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} + +// Multi-replica hosted runtime: hosted AND not explicitly single-replica. Use +// this for the "another replica may hold the live doc" gating; keep +// isHostedRewriteEnvironment for genuinely environment-wide policy. +export function isHostedMultiReplicaRewriteEnvironment(): boolean { + return isHostedRewriteEnvironment() && !isSingleReplicaDeployment(); +} + export function evaluateRewriteLiveClientGate(slug: string, body: unknown): RewriteLiveClientGate { return evaluateRewriteLiveClientGateWithOptions(slug, body, {}); }