From 08557cf6540a35b725490e00d75773601aa06e43 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 04:54:09 -0700 Subject: [PATCH 1/3] fix(T13851): journal-stream remedy names only commands this build can run A build with sync.push unreleased was told to join the journal with `cleo sync enable push`, which it refuses (E_NEXUS_SYNC_REFUSED), on every journal-stream message (BEHIND, STREAM_UPGRADED, LOCAL_CHANGES, verify, status). journalStreamRemedy() now tells such a build to upgrade with `cleo self-update`; a build that ships sync.push keeps the join remedy. Co-Authored-By: Claude Opus 5.5 --- .../t13851-journal-remedy-older-client.md | 6 ++++ .../src/cloud/__tests__/nexus-vault.test.ts | 18 +++++++++++ packages/core/src/cloud/nexus-vault.ts | 31 ++++++++++++------- 3 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 .changeset/t13851-journal-remedy-older-client.md diff --git a/.changeset/t13851-journal-remedy-older-client.md b/.changeset/t13851-journal-remedy-older-client.md new file mode 100644 index 0000000000..d8cd0ace21 --- /dev/null +++ b/.changeset/t13851-journal-remedy-older-client.md @@ -0,0 +1,6 @@ +--- +id: t13851-journal-remedy-older-client +tasks: [T13851] +kind: fix +summary: "a build with sync.push unreleased told to upgrade (cleo self-update) on a journal stream, never to run the cleo sync enable push it refuses" +--- diff --git a/packages/core/src/cloud/__tests__/nexus-vault.test.ts b/packages/core/src/cloud/__tests__/nexus-vault.test.ts index 5390e4430b..f270917df0 100644 --- a/packages/core/src/cloud/__tests__/nexus-vault.test.ts +++ b/packages/core/src/cloud/__tests__/nexus-vault.test.ts @@ -79,6 +79,7 @@ import { legacyStrands, recordBareAccounts, setSyncFlag, + UNRELEASED_FLAGS, } from '../../store/sync/flags.js'; import { cutGenesis, genesisCutOf, genesisPending } from '../../store/sync/genesis.js'; import { readStreamCursor } from '../../store/sync/pull.js'; @@ -149,6 +150,7 @@ import { confirmableRetirements, enableSyncPush, formatTransitionPruned, + journalStreamRemedy, nexusVaultStatus, pullSyncStream, pushNexusVault, @@ -3992,6 +3994,17 @@ function projectDataKey(): Buffer { ); } +describe('journalStreamRemedy names only commands this build can run (T13851)', () => { + it('a build without sync.push says to upgrade; a build with it says to join', () => { + const unreleased = journalStreamRemedy(false); + expect(unreleased).toContain('cleo self-update'); + expect(unreleased).not.toContain('cleo sync enable push'); + const released = journalStreamRemedy(true); + expect(released).toContain('cleo sync enable push'); + expect(released).not.toContain('cleo self-update'); + }); +}); + describe('cloud vault on a stream the change journal writes (segment/v3, checkpoint/v3; T13034)', () => { const PIN_HASH = 'c'.repeat(64); const insertTask = (m: Machine, id: string) => @@ -4137,6 +4150,11 @@ describe('cloud vault on a stream the change journal writes (segment/v3, checkpo expect(behind.fix).toContain('cleo cloud pull'); expect(behind.fix).toContain('change journal (`sync.push`)'); expect(behind.fix).not.toContain('--force'); + // This build has `sync.push` unreleased, so `cleo sync enable push` refuses here: the remedy + // names the upgrade, never the enable step it would refuse (T13851). + expect(UNRELEASED_FLAGS.has('sync.push')).toBe(true); + expect(behind.fix).toContain('cleo self-update'); + expect(behind.fix).not.toContain('cleo sync enable push'); const refused = await failure(on(a, () => pushNexusVault(vopts(a, { force: true })))); expect(refused.code).toBe('E_NEXUS_VAULT_STREAM_UPGRADED'); expect(refused.message).toContain('checkpoint/v3'); diff --git a/packages/core/src/cloud/nexus-vault.ts b/packages/core/src/cloud/nexus-vault.ts index a529f49a66..18f9ea954e 100644 --- a/packages/core/src/cloud/nexus-vault.ts +++ b/packages/core/src/cloud/nexus-vault.ts @@ -578,9 +578,18 @@ async function replaySegments( /** * What works on a stream the change journal writes (its head snapshot is * checkpoint/v3), for every message that would otherwise say "push" (T13034). + * A build where `sync.push` is unreleased cannot join the journal, since `cleo sync enable push` + * refuses there, so it is told to upgrade instead (T13851). + * + * @param pushReleased - Whether this build ships `sync.push`; defaults to the release state. + * @returns The remedy clause, naming only commands this build can run. */ -const JOURNAL_STREAM_REMEDY = - 'on this stream local changes travel through the change journal (`sync.push`), and with it on a vault push checkpoints the journal (T12999); a store that has not joined it joins with `cleo sync enable push`, which keeps its own rows and merges them into the stream (T13466); `cleo cloud pull`, `cleo cloud restore` and `cleo cloud verify` still work'; +export function journalStreamRemedy(pushReleased = !UNRELEASED_FLAGS.has('sync.push')): string { + if (pushReleased) { + return 'on this stream local changes travel through the change journal (`sync.push`), and with it on a vault push checkpoints the journal (T12999); a store that has not joined it joins with `cleo sync enable push`, which keeps its own rows and merges them into the stream (T13466); `cleo cloud pull`, `cleo cloud restore` and `cleo cloud verify` still work'; + } + return 'on this stream local changes travel through the change journal (`sync.push`), which checkpoints the stream (T12999) and which this CLEO cannot run: `sync.push` is not released in this build, so a newer CLEO on another device wrote this stream; upgrade CLEO on this machine with `cleo self-update` to a release that ships `sync.push` before pushing this store (T13851); `cleo cloud pull`, `cleo cloud restore` and `cleo cloud verify` still work'; +} /** Whether a snapshot is checkpoint/v3: the change journal writes its stream (T13034). */ function isJournalSnapshot(cp: Checkpoint | null | undefined): boolean { @@ -632,7 +641,7 @@ function aheadOfThisCleo( function journalStreamRefusal(t: VaultTarget, parent: Checkpoint): NexusAccountError { return streamUpgradedError( `${t.streamId} takes only checkpoint/v3 snapshots: its head snapshot ${parent.checkpointId} is one (the change journal writes this stream), and the server refuses the checkpoint/v2 snapshots this CLEO's vault writes after it`, - `nothing was written; ${JOURNAL_STREAM_REMEDY}`, + `nothing was written; ${journalStreamRemedy()}`, ); } @@ -1325,7 +1334,7 @@ async function appendCheckpoint(a: { if (err instanceof NexusError && err.code === 'E_STREAM_VERSION') { throw streamUpgradedError( `the server refused the snapshot: ${t.streamId} takes only checkpoint/v3 snapshots (${err.message})`, - JOURNAL_STREAM_REMEDY, + journalStreamRemedy(), ); } const countRefusal = @@ -1872,7 +1881,7 @@ async function pushNexusVaultImpl( if (cut !== undefined) { throw streamUpgradedError( `this store started ${t.streamId}'s change journal (genesis cut at capture ${cut}); its changes are pushed as journal segments, not vault snapshots`, - `nothing was written; ${JOURNAL_STREAM_REMEDY}`, + `nothing was written; ${journalStreamRemedy()}`, ); } const force = opts.force === true; @@ -1915,7 +1924,7 @@ async function pushNexusVaultImpl( 'E_NEXUS_VAULT_BEHIND', `another device pushed snapshot ${head.headCheckpointId} after this machine's last sync`, isJournalSnapshot(parent) - ? `run \`cleo cloud pull\` first (it refuses to overwrite local changes); ${JOURNAL_STREAM_REMEDY}` + ? `run \`cleo cloud pull\` first (it refuses to overwrite local changes); ${journalStreamRemedy()}` : 'run `cleo cloud pull` first (it refuses to overwrite local changes), or `--force` to push this store as a labelled fork', ); } @@ -3873,7 +3882,7 @@ async function restoreNexusVaultImpl(opts: NexusVaultRestoreOptions): Promise 0) { warnings.push({ code: 'W_NEXUS_VAULT_KEPT_LOCAL', - message: `kept ${keptUnsynced.length} local file(s) this store never synced with the cloud, in place of the snapshot's copies: ${keptUnsynced.slice(0, 8).join(', ')}${keptUnsynced.length > 8 ? ', …' : ''}; ${isJournalSnapshot(restored.checkpoint) ? `to keep them: ${JOURNAL_STREAM_REMEDY}; or` : 'push to keep them, or'} pull with --force to take the snapshot's`, + message: `kept ${keptUnsynced.length} local file(s) this store never synced with the cloud, in place of the snapshot's copies: ${keptUnsynced.slice(0, 8).join(', ')}${keptUnsynced.length > 8 ? ', …' : ''}; ${isJournalSnapshot(restored.checkpoint) ? `to keep them: ${journalStreamRemedy()}; or` : 'push to keep them, or'} pull with --force to take the snapshot's`, }); } saveSynced(conn, t, restored.checkpoint, { @@ -4164,7 +4173,7 @@ async function nexusVaultStatusImpl( if (headCp && isJournalSnapshot(headCp) && pendingChanges.length > 0) { warnings.push({ code: 'W_NEXUS_VAULT_JOURNAL_STREAM', - message: `${pendingChanges.length} table(s) changed since the last sync, and the newest snapshot ${headCp.checkpointId} is checkpoint/v3 (the change journal writes this stream): ${JOURNAL_STREAM_REMEDY}`, + message: `${pendingChanges.length} table(s) changed since the last sync, and the newest snapshot ${headCp.checkpointId} is checkpoint/v3 (the change journal writes this stream): ${journalStreamRemedy()}`, }); } return { @@ -4445,11 +4454,11 @@ async function verifyNexusVaultImpl( ? 'run `cleo cloud pull` to bring this machine to the newest snapshot' : verdict === 'ahead' ? journalHead - ? `this machine changed since the newest snapshot, which is checkpoint/v3 (the change journal writes this stream): ${JOURNAL_STREAM_REMEDY}` + ? `this machine changed since the newest snapshot, which is checkpoint/v3 (the change journal writes this stream): ${journalStreamRemedy()}` : 'run `cleo cloud push` to back up the local changes' : verdict === 'diverged' ? journalHead - ? `both sides changed, and the newest snapshot is checkpoint/v3 (the change journal writes this stream): ${JOURNAL_STREAM_REMEDY}; or \`cleo cloud pull --force\` to take the cloud (a safety backup is taken first)` + ? `both sides changed, and the newest snapshot is checkpoint/v3 (the change journal writes this stream): ${journalStreamRemedy()}; or \`cleo cloud pull --force\` to take the cloud (a safety backup is taken first)` : 'both sides changed: run `cleo cloud push --force` to keep this machine (a labelled fork), or `cleo cloud pull --force` to take the cloud (a safety backup is taken first)' : verdict === 'empty' ? 'run `cleo cloud push` to make the first snapshot' From f7c355ee36972d6b6153894aa2291e521fea8456 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 09:59:45 -0700 Subject: [PATCH 2/3] fix(T13856): no self-update remedy while sync.push is unreleased journalStreamRemedy(false) sent users to `cleo self-update`, but while sync.push is in UNRELEASED_FLAGS no released CLEO ships it, so the user would loop. It now says a pre-release or allowUnreleased build started the journal, no released CLEO can push to it yet, and pull, restore and verify keep working. cloudSyncImpl's E_SYNC_DISABLED fix gets the same branch through syncDisabledRemedy(); C-1 asserts it, and a new unit test covers both branches. Co-Authored-By: Claude Opus 5.5 --- .../t13851-journal-remedy-older-client.md | 4 +-- .../src/cloud/__tests__/nexus-vault.test.ts | 25 ++++++++++++++++--- packages/core/src/cloud/nexus-vault.ts | 24 +++++++++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.changeset/t13851-journal-remedy-older-client.md b/.changeset/t13851-journal-remedy-older-client.md index d8cd0ace21..511afb5199 100644 --- a/.changeset/t13851-journal-remedy-older-client.md +++ b/.changeset/t13851-journal-remedy-older-client.md @@ -1,6 +1,6 @@ --- id: t13851-journal-remedy-older-client -tasks: [T13851] +tasks: [T13851, T13856] kind: fix -summary: "a build with sync.push unreleased told to upgrade (cleo self-update) on a journal stream, never to run the cleo sync enable push it refuses" +summary: "while sync.push is unreleased, journal-stream and E_SYNC_DISABLED remedies name no command this build refuses: they say a pre-release build started the journal, no released CLEO can push yet, and pull, restore and verify keep working" --- diff --git a/packages/core/src/cloud/__tests__/nexus-vault.test.ts b/packages/core/src/cloud/__tests__/nexus-vault.test.ts index f270917df0..8877167284 100644 --- a/packages/core/src/cloud/__tests__/nexus-vault.test.ts +++ b/packages/core/src/cloud/__tests__/nexus-vault.test.ts @@ -158,6 +158,7 @@ import { releaseNexusVaultLease, restoreNexusVault, serverRetirementsFor, + syncDisabledRemedy, verifyNexusVault, } from '../nexus-vault.js'; import { @@ -3995,14 +3996,26 @@ function projectDataKey(): Buffer { } describe('journalStreamRemedy names only commands this build can run (T13851)', () => { - it('a build without sync.push says to upgrade; a build with it says to join', () => { + it('a build without sync.push names no command it cannot run; a build with it says to join', () => { + // While sync.push is unreleased no released CLEO ships it, so self-update would loop (T13856). const unreleased = journalStreamRemedy(false); - expect(unreleased).toContain('cleo self-update'); + expect(unreleased).not.toContain('cleo self-update'); expect(unreleased).not.toContain('cleo sync enable push'); + expect(unreleased).toContain('no released CLEO can push to it yet'); + expect(unreleased).toContain('pre-release or allowUnreleased'); + expect(unreleased).toContain('`cleo cloud pull`, `cleo cloud restore` and `cleo cloud verify`'); const released = journalStreamRemedy(true); expect(released).toContain('cleo sync enable push'); expect(released).not.toContain('cleo self-update'); }); + + it('the E_SYNC_DISABLED fix names cleo sync enable push only where this build ships sync.push (T13856)', () => { + const unreleased = syncDisabledRemedy(false); + expect(unreleased).not.toContain('cleo sync enable push'); + expect(unreleased).not.toContain('cleo self-update'); + expect(unreleased).toContain('no released CLEO can push to it yet'); + expect(syncDisabledRemedy(true)).toContain('cleo sync enable push'); + }); }); describe('cloud vault on a stream the change journal writes (segment/v3, checkpoint/v3; T13034)', () => { @@ -4153,8 +4166,9 @@ describe('cloud vault on a stream the change journal writes (segment/v3, checkpo // This build has `sync.push` unreleased, so `cleo sync enable push` refuses here: the remedy // names the upgrade, never the enable step it would refuse (T13851). expect(UNRELEASED_FLAGS.has('sync.push')).toBe(true); - expect(behind.fix).toContain('cleo self-update'); + expect(behind.fix).not.toContain('cleo self-update'); expect(behind.fix).not.toContain('cleo sync enable push'); + expect(behind.fix).toContain('no released CLEO can push to it yet'); const refused = await failure(on(a, () => pushNexusVault(vopts(a, { force: true })))); expect(refused.code).toBe('E_NEXUS_VAULT_STREAM_UPGRADED'); expect(refused.message).toContain('checkpoint/v3'); @@ -6014,7 +6028,10 @@ describe('sync enable push: the genesis checkpoint (T12343 S4-1b)', () => { const { m } = await journalMachine(); const refused = await failure(on(m, () => cloudSync(vopts(m, { scope: 'project' })))); expect(refused.code).toBe('E_SYNC_DISABLED'); - expect(refused.fix).toContain('cleo sync enable push'); + // This build has sync.push unreleased, where `cleo sync enable push` refuses (T13856). + expect(UNRELEASED_FLAGS.has('sync.push')).toBe(true); + expect(refused.fix).toBe(syncDisabledRemedy(false)); + expect(refused.fix).not.toContain('cleo sync enable push'); }); it('S5-1: a store that synced a vault snapshot from before the journal genesis refuses to pull across it (T13306)', async () => { diff --git a/packages/core/src/cloud/nexus-vault.ts b/packages/core/src/cloud/nexus-vault.ts index 18f9ea954e..dd458a0b3b 100644 --- a/packages/core/src/cloud/nexus-vault.ts +++ b/packages/core/src/cloud/nexus-vault.ts @@ -579,7 +579,9 @@ async function replaySegments( * What works on a stream the change journal writes (its head snapshot is * checkpoint/v3), for every message that would otherwise say "push" (T13034). * A build where `sync.push` is unreleased cannot join the journal, since `cleo sync enable push` - * refuses there, so it is told to upgrade instead (T13851). + * refuses there (T13851). While it is unreleased no released CLEO ships it either, so the remedy + * never sends the user to `cleo self-update`: a pre-release or allowUnreleased build started the + * journal, and pull, restore and verify keep working (T13856). * * @param pushReleased - Whether this build ships `sync.push`; defaults to the release state. * @returns The remedy clause, naming only commands this build can run. @@ -588,7 +590,23 @@ export function journalStreamRemedy(pushReleased = !UNRELEASED_FLAGS.has('sync.p if (pushReleased) { return 'on this stream local changes travel through the change journal (`sync.push`), and with it on a vault push checkpoints the journal (T12999); a store that has not joined it joins with `cleo sync enable push`, which keeps its own rows and merges them into the stream (T13466); `cleo cloud pull`, `cleo cloud restore` and `cleo cloud verify` still work'; } - return 'on this stream local changes travel through the change journal (`sync.push`), which checkpoints the stream (T12999) and which this CLEO cannot run: `sync.push` is not released in this build, so a newer CLEO on another device wrote this stream; upgrade CLEO on this machine with `cleo self-update` to a release that ships `sync.push` before pushing this store (T13851); `cleo cloud pull`, `cleo cloud restore` and `cleo cloud verify` still work'; + return `on this stream local changes travel through the change journal (\`sync.push\`), which this CLEO cannot run: ${SYNC_PUSH_UNRELEASED_CLAUSE}; \`cleo cloud pull\`, \`cleo cloud restore\` and \`cleo cloud verify\` keep working`; +} + +/** Why no CLEO can push while `sync.push` is unreleased (T13856): no release ships it yet. */ +const SYNC_PUSH_UNRELEASED_CLAUSE = + '`sync.push` is not released yet, so a pre-release or allowUnreleased CLEO build started this journal and no released CLEO can push to it yet (T13856)'; + +/** + * The fix for E_SYNC_DISABLED: turn the journal on where this build can, and otherwise say that no + * released CLEO can yet (T13856), never naming `cleo sync enable push` where it refuses. + * + * @param pushReleased - Whether this build ships `sync.push`; defaults to the release state. + * @returns The fix text. + */ +export function syncDisabledRemedy(pushReleased = !UNRELEASED_FLAGS.has('sync.push')): string { + if (pushReleased) return 'turn the change journal on first: `cleo sync enable push`'; + return `the change journal is off here and this CLEO cannot turn it on: ${SYNC_PUSH_UNRELEASED_CLAUSE}; \`cleo cloud pull\`, \`cleo cloud restore\` and \`cleo cloud verify\` keep working`; } /** Whether a snapshot is checkpoint/v3: the change journal writes its stream (T13034). */ @@ -3333,7 +3351,7 @@ async function cloudSyncImpl( throw vaultError( 'E_SYNC_DISABLED', 'no attached stream has sync.push or sync.pull on', - 'turn the change journal on first: `cleo sync enable push`', + syncDisabledRemedy(), ); } return { apiUrl: conn.apiUrl, streams, warnings: [...conn.warnings] }; From 89b2a8a5622a205811e3d02f7a4caaf462acb7fd Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 12:52:46 -0700 Subject: [PATCH 3/3] fix(T13856): keep the T12999 checkpoint clause in the unreleased remedy The unreleased branch of journalStreamRemedy dropped "which checkpoints the stream (T12999)", which the mixed v2-then-v3 lineage regression asserts (CI shard 2 on f7c355ee3). The clause is back, before the reason this CLEO cannot run sync.push. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/cloud/nexus-vault.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/cloud/nexus-vault.ts b/packages/core/src/cloud/nexus-vault.ts index dd458a0b3b..01efad9e18 100644 --- a/packages/core/src/cloud/nexus-vault.ts +++ b/packages/core/src/cloud/nexus-vault.ts @@ -590,7 +590,7 @@ export function journalStreamRemedy(pushReleased = !UNRELEASED_FLAGS.has('sync.p if (pushReleased) { return 'on this stream local changes travel through the change journal (`sync.push`), and with it on a vault push checkpoints the journal (T12999); a store that has not joined it joins with `cleo sync enable push`, which keeps its own rows and merges them into the stream (T13466); `cleo cloud pull`, `cleo cloud restore` and `cleo cloud verify` still work'; } - return `on this stream local changes travel through the change journal (\`sync.push\`), which this CLEO cannot run: ${SYNC_PUSH_UNRELEASED_CLAUSE}; \`cleo cloud pull\`, \`cleo cloud restore\` and \`cleo cloud verify\` keep working`; + return `on this stream local changes travel through the change journal (\`sync.push\`), which checkpoints the stream (T12999) and which this CLEO cannot run: ${SYNC_PUSH_UNRELEASED_CLAUSE}; \`cleo cloud pull\`, \`cleo cloud restore\` and \`cleo cloud verify\` keep working`; } /** Why no CLEO can push while `sync.push` is unreleased (T13856): no release ships it yet. */