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, {}); }