Repository navigation
[Bug]: t3 auth, t3 pair, t3 thread and other CLI commands apply the CLI's own migrations to a running server's database #26696
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
This is the follow-up named in the #10516 triage ("treat 'mint without this checkout's runMigrations()' as the follow-up so auth pairing create cannot do the same thing with an explicit --base-dir"). #22855 closed #10516 by fixing worktree discovery in t3 pair, and its description says it does not cover this part. #16102 also left out "CLI auth locks" to keep its scope small.
Deterministic, from source on main @ 9826dbb990, with a scratch T3 home:
node apps/server/src/bin.ts serve --base-dir "$H" --host 127.0.0.1 --port 39771 --no-browser. The ledger ends at72 WaitingLinkRequestIndex.- Stand in for a newer CLI: in the same checkout, add
073_CliOnlyProbe.ts(CREATE TABLE IF NOT EXISTS repro_cli_only_probe (...)) and itsmigrationEntriesrow. The running server already loaded its migration list at startup and is not affected by the edit. node apps/server/src/bin.ts auth session issue --base-dir "$H" --token-onlySELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id DESC LIMIT 1on$H/userdata/statev2.sqlite.- Revert the edit, then restart the server (an unmodified
mainbuild). - Stand in for a later build that ships a different migration 73: add
073_ReleasedFeature.ts, then start the server.
The repro script is attached below, and its output is under Logs. The command table further down is from repeating steps 1–4 once per command; that output isn't attached.
Expected behavior
A CLI command does not apply its own pending migrations to the database of a running server that owns the T3 home (server-owner.sqlite held). This is the migration-writing follow-up named in #10516, not the older-CLI case from #21736. How the CLI should behave instead (use the schema as it is, delegate to the server, or refuse) is for triage.
Actual behavior
- Step 3 exits 0 and prints a token. The CLI logs nothing about migrations. The ledger now ends at
73 CliOnlyProbe. The server is still running and still holds the lock: a secondserveon the same home exits 78 with "A T3 Code server already owns …". - Step 5: the server starts and logs
Database migration history diverges from this build; recorded migration ids are skipped, not reconciled by name. - Step 6: the server exits 1 with
This database's migration history would skip required schema migrations: 73_ReleasedFeature (recorded: CliOnlyProbe) …(persistence/Migrations.ts:197). This is the same outcome as [Bug]: Worktree pairing can migrate the live desktop database #10516, which now ends in a clear refusal instead of a missing-column crash.
I repeated steps 1–4 on a fresh home for each command. Every command that opens persistence applied the extra migration while the server ran:
Command (server running, --base-dir set) |
Exit | Server alive | Ledger max |
|---|---|---|---|
t3 auth pairing list |
0 | yes | 73 |
t3 auth session list |
0 | yes | 73 |
t3 pair --ttl 5m |
0 | yes | 73 |
t3 project add <dir> (live mode) |
0 | yes | 73 |
t3 environment list |
0 | yes | 73 |
t3 thread list |
1 (no project in cwd) | yes | 73 |
t3 connect status |
0 | yes | 73 |
Even read-only list commands migrate. A command that later fails also migrates first, because the layer is built before the command's handler runs.
Code path on main @ 9826dbb990:
EnvironmentAuth.layerRuntimeprovideslayerStorage = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistence.layerConfig)(auth/EnvironmentAuth.ts:1395-1400).SqlitePersistence.layerConfigrunsinitializeV2Database(dbPath)(persistence/Sqlite.ts:72), thenlayerSetup, which runsrunMigrations()(Sqlite.ts:33) and resetssqlite_stat1(Sqlite.ts:36-40). None of these steps checks server ownership. The server takes ownership "Before anything opens the database" (server.ts:782-783). The CLI never does.- These commands provide that layer:
cli/auth.ts:41,cli/pair.ts:440,cli/connect.ts:541,cli/thread.ts:239andcli/environment.ts:40,47.cli/project.tsresolvesEnvironmentAuth(:407) before it chooses live or offline mode, so the ownership check that fix(server): enforce one database owner and guard update rollback #16102 added for offline writes (:430-433) comes after the migrations have already run.
Two things are by design, so I'm not reporting them: CLI commands writing rows while the server runs (Sqlite.ts:17), and PRAGMA journal_mode = WAL, which does nothing on a database that is already in WAL mode.
Impact
Minor bug or occasional failure
Who hits it: someone running a CLI that has pending migrations against the same T3 home as a running server. For example, a stable 0.1.3 server stays running while npx t3@nightly auth session issue uses its home (the nightly CLI's migration list runs to 072). A source checkout run without --base-dir is another route, since its default home is T3CODE_HOME or ~/.t3.
- With shipped builds today, the effect is mostly silent. For example, a stable 0.1.3 server has migrations through 069, and a current nightly or preview CLI would apply 070–072 under it. These migrations are additive. I did not run published binaries, so I have not verified that a 0.1.3 server keeps working afterwards.
- The damaging case is when the CLI's migration list diverges from what the server later ships, as with a dev checkout ([Bug]: Worktree pairing can migrate the live desktop database #10516). The server then refuses to start after its next update. Recovery means going back to the build that wrote the migration, restoring a backup, or repairing the ledger. [Bug]: Desktop stuck on "Connecting to WSL" after update due to renumbered migrations 63-65 (MigrationError) #21936 shows the same refusal, but it was closed as unsupported database reuse after an intentional channel switch; here the write comes from an incidental CLI command instead. [Bug]: Desktop fails to start when state DB migration ids collide with a different build (auth_sessions missing scopes) #21106 is a different failure from unsupported reuse of a database written by an unshipped build (skipped migrations 31/32,
auth_sessions has no column named scopes). The launcher backs up the database before an update because "This makes trial migrations reversible without down migrations" (docs/internals/server-updates.md). Migrations run by the CLI happen outside that rollback boundary. - Related, not measured: migrations can be long write transactions. For example, 069 backfills the FTS index over every message, and that would run inside the server's database while the server is running. Against a pre-V2 server (≤0.0.45),
initializeV2Database(Sqlite.ts:72) seedsstatev2.sqlitefrom a snapshot of a database that is still being written to, which is the [Bug]: Orchestrator V2 nightly silently reuses a preview-created database and misses newer V1 threads #15017 trap. fix(server): reject pairing with an incompatible server protocol #23789 stopped this fort3 paironly. Its description confirms that the reverse direction "creates an unnecessary V2 database".t3 authand the other commands above still take this path.
Version or commit
main @ 9826dbb (2026-10-28)
Environment
macOS 27.0 (arm64), Node 26.0.0, server and CLI run from source (node apps/server/src/bin.ts). No provider sessions involved.
Logs or stack traces
== upstream/main 9826dbb990, T3 home /tmp/t3-i3-home.DYNs
== 1. start the server (unmodified main)
server pid 26852 listening on 39771
ledger:
72 WaitingLinkRequestIndex
71 PeerLinkFence
== 2. 'newer CLI': same checkout plus one migration (073_CliOnlyProbe)
t3 auth session issue exit=0 (token printed: yes)
ledger:
73 CliOnlyProbe
72 WaitingLinkRequestIndex
server still running: yes
second 'serve' on the same home exit=78 -> A T3 Code server already owns /tmp/t3-i3-home
== 3. restart the (unmodified) server on the migrated database
started; warning logged:
Database migration history diverges from this build; recorded migration ids are skipped, not reconciled by name.
== 4. a later build that ships a different migration 073 (ReleasedFeature)
start ok? 1 (pid alive: no)
This database's migration history would skip required schema migrations: 73_ReleasedFeature (recorded: CliOnlyProbe). The migrator skips every id up to the highest recorded id (73), regardless of name. It was probably written by a different build. Start the build that wrote it, ...Screenshots, recordings, or supporting files
repro script (run from a scratch worktree of main; edits only that worktree and reverts them)
#!/bin/bash
set -u
WT=/path/to/scratch-worktree; SRV=$WT/apps/server
H=$(mktemp -d /tmp/t3-i3-home.XXXX); PORT=39771; DB=$H/userdata/statev2.sqlite
unset T3CODE_HOME; cd "$SRV"
ledger() { sqlite3 "$DB" "SELECT migration_id || ' ' || name FROM effect_sql_migrations ORDER BY migration_id DESC LIMIT 2;"; }
add_migration() { # $1 = name
cat > src/persistence/Migrations/073_$1.ts <<TS
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";
export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql\`CREATE TABLE IF NOT EXISTS repro_$1 (id INTEGER PRIMARY KEY)\`;
});
TS
perl -0pi -e "s|(import Migration0072 from \"./Migrations/072_WaitingLinkRequestIndex.ts\";)|\$1\nimport Migration0073 from \"./Migrations/073_$1.ts\";|; s|( \[72, \"WaitingLinkRequestIndex\", Migration0072\],)|\$1\n [73, \"$1\", Migration0073],|" src/persistence/Migrations.ts
}
revert_migration() { git -C "$WT" checkout -q -- apps/server/src/persistence/Migrations.ts; rm -f src/persistence/Migrations/073_*.ts; }
start_server() { node src/bin.ts serve --base-dir "$H" --host 127.0.0.1 --port $PORT --no-browser >"$1" 2>&1 & SERVER_PID=$!
for _ in $(seq 1 120); do grep -q "Listening on" "$1" && return 0; kill -0 $SERVER_PID 2>/dev/null || return 1; sleep 0.5; done; return 1; }
stop_server() { kill $SERVER_PID; wait $SERVER_PID 2>/dev/null; }
start_server "$H/server1.log"; ledger
add_migration CliOnlyProbe
node src/bin.ts auth session issue --base-dir "$H" --token-only >/dev/null; echo "exit=$?"; ledger
node src/bin.ts serve --base-dir "$H" --host 127.0.0.1 --port $((PORT+1)) --no-browser >/dev/null 2>&1; echo "second serve exit=$?"
revert_migration; stop_server
start_server "$H/server2.log"; grep -o "Database migration history diverges[^\"]*" "$H/server2.log"; stop_server
add_migration ReleasedFeature; start_server "$H/server3.log"; grep -o "would skip required schema migrations[^\"]*" "$H/server3.log"; stop_server; revert_migrationWorkaround
Run the CLI that matches the running server: ~/.t3/bin/t3, or npx t3@<server version>. Never run a source checkout's CLI without --base-dir pointing at that checkout's own T3 home.
Related: #10516 (closed by #22855, discovery only), #21736 (an older CLI holding the write lock, closed as not planned; this report is about a newer CLI changing the schema), #21936 (the same startup refusal after an intentional channel switch, closed as unsupported reuse), #21106 (skipped migrations from an unshipped build, closed as unsupported reuse), #15017 and #23599 (the V1 snapshot trap), #16102.
Investigated by Opus 5.5 with Claude Code. Reviewed by GPT 6.1 Sol.
Activity