Skip to content

fix(server): legacy fork ledgers with title state at 52 split, and refused databases stop restarting - #139

Merged
jayleaton merged 1 commit into
mainfrom
fix/legacy-ledger-title-state
Oct 25, 2026
Merged

jayleaton merged 1 commit into
mainfrom
fix/legacy-ledger-title-state

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Problem

On one install (build 400001, cdbbed21bc), the one-time fork-ledger split from #133 didn't recognise the legacy effect_sql_migrations, and T3 restart-looped. That ledger had 51 = AgentProfileAndPullRequestCompatibility and 52 = ProjectionThreadTitleState, followed by fork main's rows from 53 (RepairAgentUpgradeSchema, …, 78). The split only checked row 52 to decide whether a ledger was the fork's, so it left this one alone. The V2 guard then refused the mixed ledger (55_OrchestrationV2 (recorded: PullRequestFilesViewed) …), and the desktop app restarted the server every 10 s, indefinitely.

dev-box split cleanly because its row 52 was AgentProfileAndPullRequestCompatibility before the split. Its fork row 1 still has that name and the 2026-09-15 timestamp, which is the day fork main first wrote 52. Its post-split 51/52 only look like the failing install's pre-split rows. No fork commit ever recorded TitleState at 52, so that row most likely came from an upstream-shaped build run on the same data around 14–16 Sep. This is inferred from the timestamps, not proven.

Change

 splitLegacyForkLedger
-  fork layout ⇔ row 52 is AgentProfile or ListState
+  fork layout ⇔ some row ≥52 has a fork name that upstream never recorded at that id
+  slot 52 also accepts ProjectionThreadTitleState (fork migration 2 replays what it skipped)
+  upstream names stay out of the fork ledger (fork 1 is recorded as AgentProfile…)
   anything else → MigrationError BadState, before any write

 Sqlite.layerFromPath
+  BadState → DatabaseRefusedError, exit code 65 (SERVER_EXIT_CODE_DATABASE_REFUSED)
 desktop  DesktopBackendManager: exit 65 → no restart; primary shows one error box and quits; WSL stays stopped
 service  serviceLauncher: active child exit 65 → idle (no systemd/launchd loop); a trial still rolls back

Refuse rather than start without splitting. If the ledger isn't split, the migrator skips every upstream id at or below the highest fork row, so the server would run with upstream schema missing. Refusing leaves the database untouched, and the error names the rows it refused. With exit 65, every supervisor now stops after the first refusal and stops retrying.

The 44:ProjectionThreadProfileSnapshot (this build: ClearAutomaticProjectModelDefaults) warning is accurate, so this PR leaves it in place. Upstream 44 is a one-time data cleanup with no schema change, and it never ran on that install. Running it now would be wrong: it relies on old-style project events, so it could clear a default model the user set after the V2 upgrade. The only lasting effect is that some older projects keep a default model that was set automatically, and the user can change it in project settings.

The fork history since 2026-09-01 was walked commit by commit (git log -p -- apps/server/src/persistence/Migrations.ts). Fork main only ever appended (the existing per-id tests cover 51–78). The other recorded variants come from agent builds before fork main. Each now has its own fixture.

Scope and approval

Fixes a startup crash-loop that the fork's own migration repair (#133) caused on a real install. The direction (refuse with exit 65 and no loop, leave the 44 warning) was confirmed by the maintainer.

Verification

  • Before: ForkMigrations.test.ts with the new fixture for the reported install fails at legacy ids 53, 54 and 78 with the same would skip required schema migrations: 55_OrchestrationV2 (recorded: PullRequestFilesViewed) … that the install hit.

  • After: vp test run src/persistence/ForkMigrations.test.ts src/persistence/Sqlite.test.ts → 58/58. The fixtures cover profile snapshots at 48/50, profile snapshots at 49/50, ListState at 52, the reported install (44/48/50/51 plus TitleState at 52), and the same install hand-relabelled at 52. Each runs at legacy ids 52, 53, 54 and 78, and each must give the same ledgers and the same sqlite_master schema as a fresh database. Sqlite.test.ts checks that an unknown ledger fails startup with DatabaseRefusedError and exit code 65, and leaves the ledger unchanged.

  • Desktop: DesktopBackendManager.test.ts ("stops without restarting when the server refuses its database"; one start after a minute on the TestClock) and DesktopWslBackend.test.ts. Server: serviceLauncher.test.ts (idles on 65; a trial that refuses rolls back and its database is restored).

  • vp run check:changed: format, lint, knip, typecheck (30 packages) and the tests beside the changes all pass.

  • Real data, read-only copy. The copy holds dev-box's live statev2.sqlite schema (206 objects) and both migration ledgers. A script rewrites the copy into the single pre-split ledger and relabels it to the reported install's shape: 44/48/50 ProjectionThreadProfileSnapshot, 51 AgentProfileAndPullRequestCompatibility, 52 ProjectionThreadTitleState, then fork main's 53–78. It also drops fork 11's profile_snapshots table, which no pre-split database had. It then opens the copy through SqlitePersistence.layerFromPath, the real startup layer.

    • Before (main 1a43bde71a): startup FAILED, exit code 1. The error is This database's migration history would skip required schema migrations: 55_OrchestrationV2 (recorded: PullRequestFilesViewed), 56_RemoveRedundantProjectionIndexes (recorded: ScheduledTasks), …, and the ledger is unchanged. This is the error the field install hit, and the desktop app restarts on exit code 1 indefinitely.
    • After (55e19a00f2): the log shows Split the fork's migration history from upstream's { latestLegacyMigrationId: 78 }, then Migrations ran successfully { migrations: [ 'fork/11_ProfileSnapshots' ] }, then the one accepted warning 44:ProjectionThreadProfileSnapshot (this build: ClearAutomaticProjectModelDefaults). Startup succeeds. The ledgers end at upstream 1–68 and fork 1–11, the same result the hand-relabelled install got.
    • Unknown row (60 relabelled SomeExperimentalMigration), after: startup FAILED, exit code 65. The error reads T3 Code did not open its database, and left it unchanged: … migrations it does not know: 60_SomeExperimentalMigration …. The ledger is unchanged, with no fork table created.

Model and harness: Claude Opus 5.5 via Claude Code in T3 Code.

🤖 Generated with Claude Code

…s stop restarting

Installs that ran a build recording upstream's ProjectionThreadTitleState at
legacy slot 52 before fork main took over the ledger were not recognised as
the fork's layout. The split skipped them, the V2 guard then refused the
interleaved ledger, and the desktop app restarted the server forever.

The split now recognises the fork's layout by any row carrying a fork name
upstream never recorded at that id, accepts title state as a slot-52 alias,
and keeps upstream names out of the fork ledger. Fixtures cover every slot
44-52 variant shipped agent builds recorded.

A refused migration history now exits with SERVER_EXIT_CODE_DATABASE_REFUSED
(65) before changing the database. The desktop app shows one error and quits,
WSL backends stay stopped, and the service launcher idles instead of exiting
into a systemd/launchd restart; an update trial still rolls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Oct 25, 2026
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 20.9 KiB 20.9 KiB 0 B (0.0%) 29.3 KiB ✅
Codex Live turn messages 2 2 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.2 KiB 21.2 KiB 0 B (0.0%) 29.3 KiB ✅
Claude Live turn messages 2 2 0 (0.0%) 8 ✅

Baseline: 1a43bde · PR result: 55e19a0 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@jayleaton
jayleaton marked this pull request as ready for review October 25, 2026 02:49
@jayleaton
jayleaton merged commit ffa911e into main Oct 25, 2026
46 of 48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant