Skip to content

Commit 87432d4

Browse files
author
weiesky.wangc
committed
fix(migrate): respect status:'done' in pendingOf() to stop false re-prompts after migration
The migration prompt detection compared per-file sizes against the convert state but ignored state.status === 'done'. After migration completes, the active V1 log continues growing (dual-write), causing a size mismatch that re-triggered the prompt even though new entries already land in V2. pendingOf() now short-circuits when status is 'done', treating the completed migration as definitive. The per-file trust rule still applies for non-done states (running/stopped/error).
1 parent a803382 commit 87432d4

3 files changed

Lines changed: 32 additions & 4 deletions

File tree

history.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- fix(migrate): **迁移完成后仍反复提示迁移**`pendingOf()` 仅按文件大小比对判断是否需要迁移,忽略了 `wire-v2-convert-state.json` 中的 `status: 'done'` 标记。迁移完成后活跃的 v1 日志继续增长(双写),大小不匹配导致误判为待迁移。修复:`pendingOf()` 检测到 `status === 'done'` 时直接返回无需迁移。
6+
57
- fix(ccswitch-import): **running cc-switch → `database is locked`** — importing from cc-switch *while it is open* failed with `导入失败(未检测到 cc-switch 或读取出错): query failed: database is locked` because the read-only connection's first query contends with cc-switch's `BEGIN EXCLUSIVE` write lock (its real contention mode: a valid hot journal under an EXCLUSIVE transaction blocks even read-only readers, unlike `BEGIN IMMEDIATE`). The prior malformed-journal fix only caught `SQLITE_BUSY` on its own escalation path; on the main read path `BUSY` escaped to the catch-all and surfaced as the opaque `query failed: database is locked`. Fix in `server/lib/ccswitch-import.js`: detect `SQLITE_BUSY` on **every** path (the read-only open and the providers query), retry once after a 200ms backoff (cc-switch's write transactions are short — transient locks usually clear), and if still held surface the friendly `cc-switch db is locked (cc-switch may be running); retry shortly` instead of the raw wrapper. New `test/ccswitch-import.test.js` cases use a child process holding `BEGIN EXCLUSIVE` to deterministically reproduce both the held-lock (→ friendly message) and transient-lock (→ retry recovers providers) paths.
68

79
- fix(ccswitch-import): **malformed leftover journal → `attempt to write a readonly database`** — importing from cc-switch failed with `导入失败(未检测到 cc-switch 或读取出错): query failed: attempt to write a readonly database` when cc-switch had been killed mid-write and left a **malformed** `cc-switch.db-journal` (truncated/corrupt rollback journal) behind. SQLite must discard a malformed journal to open the DB, which is a write; the read-only connection (`readOnly:true`) refused it → `SQLITE_READONLY`. Root cause locked deterministically: a malformed journal trips recovery; the trigger is a torn/partial journal from an unclean crash. Fix in `server/lib/ccswitch-import.js`: keep the default read-only open, but on `SQLITE_READONLY` escalate **once** to a read-write open guarded by `PRAGMA query_only = ON` (lets SQLite recover/discard the corrupt journal, blocks our own writes), then retry — mirroring what cc-switch itself does on its next normal launch. `SQLITE_BUSY` on the escalation path surfaces a clear "db is locked; retry shortly" message; all other errors still surface their real cause (`query failed: …`, e.g. "file is not a database"). New `test/ccswitch-import.test.js` cases plant a garbage `-journal` next to a valid DB and assert recovery reads providers + cleans the journal, with a follow-up plain read-only read proving the DB is left clean.

server/lib/v2/migrate-prompt.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { listV1Files, listConvertibleProjects, readConvertState } from './conver
1212
/** Pending v1 files + bytes of ONE project dir. */
1313
function pendingOf(projectDir) {
1414
const state = readConvertState(projectDir);
15+
// Migration already completed — don't re-prompt, even if v1 files grew
16+
// (dual-write captures new entries in v2).
17+
if (state && state.status === 'done') return { files: 0, totalBytes: 0 };
1518
const doneAtSize = new Map(
1619
(state && Array.isArray(state.files) ? state.files : [])
1720
.filter((f) => f && f.done)

test/migrate-prompt.test.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
*
55
* Trigger matrix pinned here:
66
* - v1 files present & unconverted → pending (files/bytes counted)
7+
* - status:'done' short-circuits → not pending (dual-write covers tail growth)
78
* - convert state marks a file done AT ITS SIZE → not pending
8-
* - a grown "done" file (active log era) → pending again
9+
* - a grown "done" file when status != 'done' → pending again
910
* - empty v1 shells are ignored; other projects with pending logs counted
1011
* - /events emits the migrate_prompt frame only when pending, carrying
1112
* `continued` from isContinuedLaunch()
@@ -19,7 +20,7 @@
1920
import { describe, it, before } from 'node:test';
2021
import assert from 'node:assert/strict';
2122
import { EventEmitter } from 'node:events';
22-
import { mkdtempSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
23+
import { mkdtempSync, mkdirSync, writeFileSync, statSync, rmSync } from 'node:fs';
2324
import { join } from 'node:path';
2425
import { tmpdir } from 'node:os';
2526

@@ -61,7 +62,7 @@ describe('migrationStatus', () => {
6162
assert.equal(st.totalBytes, 128);
6263
});
6364

64-
it('convert state marks files done at size → not pending; a grown file re-pends', () => {
65+
it('convert state marks files done at size → not pending; status done prevents re-pend from growth', () => {
6566
const f1 = `${PROJECT}_20260101_000000.jsonl`;
6667
const f2 = `${PROJECT}_20260102_000000.jsonl`;
6768
const sizeOf = (n) => statSync(join(projectDir(), n)).size;
@@ -77,7 +78,26 @@ describe('migrationStatus', () => {
7778

7879
writeFileSync(join(projectDir(), f2), 'x'.repeat(128)); // grew after conversion
7980
const st = migrationStatus(tmpDir, PROJECT);
80-
assert.equal(st.pending, true, 'a grown done-file is pending again (converter trust rule)');
81+
assert.equal(st.pending, false, 'a grown file after status:done does not re-prompt (migration complete)');
82+
});
83+
84+
it('a grown done-file re-pends when status is not done (converter trust rule)', () => {
85+
const f1 = `${PROJECT}_20260101_000000.jsonl`;
86+
const f2 = `${PROJECT}_20260102_000000.jsonl`;
87+
const sizeOf = (n) => statSync(join(projectDir(), n)).size;
88+
writeFileSync(join(projectDir(), 'wire-v2-convert-state.json'), JSON.stringify({
89+
version: 1,
90+
// No status field — simulates an in-progress/interrupted state file
91+
files: [
92+
{ name: f1, size: sizeOf(f1), done: true },
93+
{ name: f2, size: sizeOf(f2), done: true },
94+
],
95+
}));
96+
assert.equal(migrationStatus(tmpDir, PROJECT).pending, false, 'files match recorded sizes at pre-grow');
97+
98+
writeFileSync(join(projectDir(), f2), 'x'.repeat(256)); // grew beyond the 128 left by previous test
99+
const st = migrationStatus(tmpDir, PROJECT);
100+
assert.equal(st.pending, true, 'a grown done-file re-pends when status is not done');
81101
assert.equal(st.files, 1);
82102
});
83103

@@ -181,6 +201,9 @@ describe('/events migrate_prompt frame', () => {
181201
}
182202

183203
it('emits the frame when the CURRENT project has pending v1 logs, with continued flag', async () => {
204+
// The previous test left a convert state with status:'done' — remove it so
205+
// this test sees genuinely pending (never-migrated) v1 files.
206+
try { rmSync(join(tmpDir, PROJECT, 'wire-v2-convert-state.json')); } catch {}
184207
// Bind the interceptor to the fixture project (workspace mode boots bare;
185208
// initForWorkspace derives the project name from the path's basename).
186209
interceptor.initForWorkspace(join(tmpDir, 'ws', PROJECT));

0 commit comments

Comments
 (0)