Skip to content

Commit 8e8d3fb

Browse files
author
qwen-code-autofix[bot]
committed
fix: repair backfill scan mtime ties, live-entry sync race, and cap double-billing (#9729)
1 parent 202765e commit 8e8d3fb

3 files changed

Lines changed: 284 additions & 81 deletions

File tree

packages/cli/src/serve/routes/session-pr-backfill.test.ts

Lines changed: 176 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ import {
3939
const sidecarReadHook = vi.hoisted(() => ({
4040
current: undefined as { path: string; run: () => Promise<void> } | undefined,
4141
}));
42+
const sidecarCommitHook = vi.hoisted(() => ({
43+
current: undefined as (() => Promise<void>) | undefined,
44+
}));
4245

4346
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
4447
const original =
@@ -62,6 +65,23 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
6265
return result;
6366
},
6467
),
68+
// Test seam: fires a concurrent writer right after the backfill's
69+
// queued rewrite commits, before the continuation that follows it —
70+
// the deterministic interleaving the live-entry sync must survive.
71+
replaceSessionPrs: vi.fn(
72+
async (
73+
filePath: string,
74+
plan: Parameters<typeof original.replaceSessionPrs>[1],
75+
) => {
76+
const result = await original.replaceSessionPrs(filePath, plan);
77+
const hook = sidecarCommitHook.current;
78+
if (hook) {
79+
sidecarCommitHook.current = undefined;
80+
await hook();
81+
}
82+
return result;
83+
},
84+
),
6585
};
6686
});
6787

@@ -190,6 +210,7 @@ describe('backfillWorkspaceSessionPrs', () => {
190210
beforeEach(async () => {
191211
vi.clearAllMocks();
192212
sidecarReadHook.current = undefined;
213+
sidecarCommitHook.current = undefined;
193214
runtimeDir = await fsp.mkdtemp(
194215
path.join(os.tmpdir(), 'qwen-pr-backfill-runtime-'),
195216
);
@@ -817,10 +838,10 @@ describe('backfillWorkspaceSessionPrs', () => {
817838
expect(prs?.map((entry) => entry.number)).toEqual([250]);
818839
});
819840

820-
it('scans every page of the session listing', async () => {
821-
// Backfill pages through listSessions 1000 at a time; a workspace with
822-
// more sessions than one page must still be scanned and bound in full —
823-
// a cursor that stops advancing silently backfills only the first page.
841+
it('scans every session of a workspace beyond one listing page', async () => {
842+
// The sweep enumerates every persisted session; a workspace with more
843+
// than a thousand sessions must be scanned and bound in full — a scan
844+
// that stops at the first page would silently backfill only it.
824845
const chatsDir = path.join(
825846
new Storage(workspaceCwd).getProjectDir(),
826847
'chats',
@@ -850,8 +871,7 @@ describe('backfillWorkspaceSessionPrs', () => {
850871
})}\n`,
851872
'utf8',
852873
)
853-
// Distinct mtimes: the listing pages by mtime cursor, and ties
854-
// can skip entries at the page boundary.
874+
// Distinct mtimes; the mtime-tie hazard has its own test below.
855875
.then(() => fsp.utimes(filePath, baseSeconds + i, baseSeconds + i)),
856876
);
857877
}
@@ -882,6 +902,92 @@ describe('backfillWorkspaceSessionPrs', () => {
882902
).not.toBeNull();
883903
}, 30000);
884904

905+
it('scans sessions whose mtime ties across the old page boundary', async () => {
906+
// listSessions pages with a strict `mtime < cursor` filter and returns
907+
// the page's last mtime as the cursor — sessions tied with that entry
908+
// are filtered out on every run, the hazard findSessionsByTitle
909+
// documents for not paging listSessions. Two files tied across the
910+
// 1000-entry boundary must both be scanned and bound.
911+
const chatsDir = path.join(
912+
new Storage(workspaceCwd).getProjectDir(),
913+
'chats',
914+
);
915+
await fsp.mkdir(chatsDir, { recursive: true });
916+
const total = 1001;
917+
const baseSeconds = Math.floor(Date.now() / 1000) - total - 10;
918+
for (let chunk = 0; chunk < total; chunk += 100) {
919+
const batch: Array<Promise<unknown>> = [];
920+
for (let i = chunk; i < Math.min(chunk + 100, total); i++) {
921+
const sessionId = `00000000-0000-4000-8000-${i
922+
.toString(16)
923+
.padStart(12, '0')}`;
924+
const filePath = path.join(chatsDir, `${sessionId}.jsonl`);
925+
batch.push(
926+
fsp
927+
.writeFile(
928+
filePath,
929+
`${JSON.stringify({
930+
uuid: `${sessionId}-user-1`,
931+
parentUuid: null,
932+
sessionId,
933+
timestamp: '2026-08-01T00:00:00.000Z',
934+
type: 'user',
935+
message: { role: 'user', parts: [{ text: 'hello' }] },
936+
cwd: workspaceCwd,
937+
})}\n`,
938+
'utf8',
939+
)
940+
// Sessions 0 and 1 share an mtime; every other file is
941+
// distinct, so the tied pair straddles the boundary.
942+
.then(() =>
943+
fsp.utimes(
944+
filePath,
945+
i <= 1 ? baseSeconds : baseSeconds + i,
946+
i <= 1 ? baseSeconds : baseSeconds + i,
947+
),
948+
),
949+
);
950+
}
951+
await Promise.all(batch);
952+
}
953+
// Each twin carries a convention binding: whichever side of the lost
954+
// listing page one lands on, its binding must still be persisted.
955+
await seedWorktreeSidecar(
956+
'00000000-0000-4000-8000-000000000000',
957+
'pr-9',
958+
'worktree-pr-9',
959+
);
960+
await seedWorktreeSidecar(
961+
'00000000-0000-4000-8000-000000000001',
962+
'pr-10',
963+
'worktree-pr-10',
964+
);
965+
fetchGitHubPullRequestsMock.mockResolvedValue({
966+
kind: 'ok',
967+
pullRequests: [pr(9, 'worktree-pr-9'), pr(10, 'worktree-pr-10')],
968+
});
969+
970+
const result = await backfillWorkspaceSessionPrs(runtime);
971+
972+
expect(result).toMatchObject({ scanned: 1001, bound: 2 });
973+
expect(
974+
await readSessionPrs(
975+
sessionService.getPrSessionPathForArchiveState(
976+
'00000000-0000-4000-8000-000000000000',
977+
'active',
978+
),
979+
),
980+
).not.toBeNull();
981+
expect(
982+
await readSessionPrs(
983+
sessionService.getPrSessionPathForArchiveState(
984+
'00000000-0000-4000-8000-000000000001',
985+
'active',
986+
),
987+
),
988+
).not.toBeNull();
989+
}, 30000);
990+
885991
it('never binds a session through the repository default branch', async () => {
886992
// Fork PRs opened from the fork's default branch carry that bare name
887993
// as headRefName (gh does not qualify it by owner); mapping it would
@@ -1326,7 +1432,7 @@ describe('backfillWorkspaceSessionPrs', () => {
13261432
expect(prs?.find((entry) => entry.number === 5)?.url).toBe(
13271433
'https://github.com/other-org/other-repo/pull/5',
13281434
);
1329-
expect(result).toMatchObject({ bound: 1, alreadyBound: 0, overLimit: 8 });
1435+
expect(result).toMatchObject({ bound: 1, alreadyBound: 0, overLimit: 7 });
13301436
});
13311437

13321438
it('binds nothing when unresolvable bindings already fill the cap', async () => {
@@ -1378,7 +1484,7 @@ describe('backfillWorkspaceSessionPrs', () => {
13781484
expect(prs?.map((entry) => entry.number)).toEqual([
13791485
101, 102, 103, 104, 105, 106, 107, 108, 109, 42,
13801486
]);
1381-
expect(result).toMatchObject({ bound: 0, overLimit: 2 });
1487+
expect(result).toMatchObject({ bound: 0, overLimit: 1 });
13821488
});
13831489

13841490
it('re-plans around a concurrent foreign binding instead of exceeding the cap', async () => {
@@ -1411,7 +1517,7 @@ describe('backfillWorkspaceSessionPrs', () => {
14111517
expect(result).toMatchObject({ bound: 1, overLimit: 1 });
14121518
});
14131519

1414-
it('treats a concurrently bound planned number as already bound, not added', async () => {
1520+
it('does not bill a concurrently bound planned number twice against the cap', async () => {
14151521
await seedSession(SESSION_A);
14161522
await seedTranscriptBranches(SESSION_A, 1, 2);
14171523
const prPath = await seedPrSidecar(
@@ -1422,8 +1528,10 @@ describe('backfillWorkspaceSessionPrs', () => {
14221528
kind: 'ok',
14231529
pullRequests: [pr(1, 'b-1'), pr(2, 'b-2')],
14241530
});
1425-
// 2 wins the single free slot in the fresh plan and is already present,
1426-
// so the write adds nothing and bound must stay 0.
1531+
// The dialog binds #2 in the seam and the run resolves 1 and 2: the
1532+
// fresh #2 already holds its slot, so the plan must not bill it again
1533+
// as a member — all ten distinct numbers fit the ten slots, nothing
1534+
// is trimmed, and the present #2 is not re-added.
14271535
sidecarReadHook.current = {
14281536
path: prPath,
14291537
run: () =>
@@ -1437,9 +1545,9 @@ describe('backfillWorkspaceSessionPrs', () => {
14371545

14381546
const prs = await readSessionPrs(prPath);
14391547
expect(prs?.map((entry) => entry.number)).toEqual([
1440-
101, 102, 103, 104, 105, 106, 107, 108, 2,
1548+
101, 102, 103, 104, 105, 106, 107, 108, 2, 1,
14411549
]);
1442-
expect(result).toMatchObject({ bound: 0, alreadyBound: 1, overLimit: 1 });
1550+
expect(result).toMatchObject({ bound: 1, alreadyBound: 0, overLimit: 0 });
14431551
});
14441552

14451553
it('keeps a snapshot-held number a client re-binds during the run', async () => {
@@ -1478,7 +1586,7 @@ describe('backfillWorkspaceSessionPrs', () => {
14781586
101, 102, 103, 104, 105, 106, 107, 108, 5, 7,
14791587
]);
14801588
expect(prs).toHaveLength(SESSION_PR_LIST_LIMIT);
1481-
expect(result).toMatchObject({ bound: 1, overLimit: 2 });
1589+
expect(result).toMatchObject({ bound: 1, overLimit: 1 });
14821590
// The live-entry sync must publish the surviving binding, not the
14831591
// cap-trimmed list that dropped it.
14841592
expect(setSessionPrs).toHaveBeenCalledWith(
@@ -1487,6 +1595,46 @@ describe('backfillWorkspaceSessionPrs', () => {
14871595
);
14881596
});
14891597

1598+
it('syncs the live entry from the freshest list when a bind lands after the rewrite', async () => {
1599+
// A dialog bind commits between the queued rewrite and the live-entry
1600+
// sync: the sync must publish it, not the rewrite-time snapshot that
1601+
// lacks it — a post-commit call with the snapshot would clobber the
1602+
// bind from the live entry while the sidecar keeps it.
1603+
await seedSession(SESSION_A);
1604+
await seedTranscriptBranches(SESSION_A, 1, 2);
1605+
const prPath = await seedPrSidecar(
1606+
SESSION_A,
1607+
Array.from({ length: 7 }, (_, i) => 101 + i),
1608+
);
1609+
fetchGitHubPullRequestsMock.mockResolvedValue({
1610+
kind: 'ok',
1611+
pullRequests: [pr(1, 'b-1'), pr(2, 'b-2')],
1612+
});
1613+
sidecarCommitHook.current = () =>
1614+
upsertSessionPr(prPath, {
1615+
number: 99,
1616+
url: 'https://github.com/o/r/pull/99',
1617+
}).then(() => undefined);
1618+
const setSessionPrs = vi.fn();
1619+
const runtimeWithBridge = {
1620+
...runtime,
1621+
bridge: { markSessionCatalogChanged: vi.fn(), setSessionPrs },
1622+
} as unknown as WorkspaceRuntime;
1623+
1624+
const result = await backfillWorkspaceSessionPrs(runtimeWithBridge);
1625+
1626+
expect(result).toMatchObject({ bound: 2, written: 1 });
1627+
const prs = await readSessionPrs(prPath);
1628+
expect(prs?.map((entry) => entry.number)).toEqual([
1629+
101, 102, 103, 104, 105, 106, 107, 1, 2, 99,
1630+
]);
1631+
expect(setSessionPrs).toHaveBeenCalledTimes(1);
1632+
expect(setSessionPrs).toHaveBeenLastCalledWith(
1633+
SESSION_A,
1634+
expect.arrayContaining([expect.objectContaining({ number: 99 })]),
1635+
);
1636+
});
1637+
14901638
it('counts in bound only the bindings the write actually persisted', async () => {
14911639
await seedSession(SESSION_A);
14921640
await seedTranscriptBranches(SESSION_A, 1, 3);
@@ -1853,8 +2001,8 @@ describe('registerSessionPrBackfillRoutes', () => {
18532001

18542002
it('isolates a failing workspace and still backfills the rest', async () => {
18552003
const seeded = await seedTrustedBackfillWorkspace();
1856-
// A regular file where a workspace cwd belongs makes the session
1857-
// listing's readdir throw ENOTDIR (a non-ENOENT error listSessions
2004+
// A regular file where a workspace cwd belongs makes the chats-dir
2005+
// readdir throw ENOTDIR (a non-ENOENT error the session enumeration
18582006
// rethrows); the route must isolate that workspace's failure instead
18592007
// of failing the whole request.
18602008
const brokenParent = await fsp.mkdtemp(
@@ -1963,9 +2111,9 @@ describe('registerSessionPrBackfillRoutes', () => {
19632111
});
19642112

19652113
// Seeds a trusted workspace whose single session recorded ten transcript
1966-
// branches while its sidecar already holds the first nine — the shape a
1967-
// concurrent dialog binding turns into an eviction-only write.
1968-
async function seedTrustedEvictionWorkspace(): Promise<{
2114+
// branches while its sidecar already holds the first nine — one free
2115+
// slot at the cap, the shape a concurrent dialog binding fills.
2116+
async function seedTrustedCapWorkspace(): Promise<{
19692117
runtime: WorkspaceRuntime;
19702118
markSessionCatalogChanged: ReturnType<typeof vi.fn>;
19712119
prPath: string;
@@ -2039,18 +2187,18 @@ describe('registerSessionPrBackfillRoutes', () => {
20392187
};
20402188
}
20412189

2042-
it('invalidates the cache when a persisted plan evicts without adding', async () => {
2043-
const seeded = await seedTrustedEvictionWorkspace();
2190+
it('keeps every binding when a concurrent bind fills the last slot at the cap', async () => {
2191+
const seeded = await seedTrustedCapWorkspace();
20442192
fetchGitHubPullRequestsMock.mockResolvedValue({
20452193
kind: 'ok',
20462194
pullRequests: Array.from({ length: 10 }, (_, i) =>
20472195
pr(i + 1, `b-${i + 1}`),
20482196
),
20492197
});
20502198
// The dialog binds #10 between the snapshot read and the queued write:
2051-
// every surviving plan number is then already present, so the cap trim
2052-
// persists an eviction (of #1) with zero additions — still a catalog
2053-
// mutation the sidebar must refetch.
2199+
// the fresh entry already holds its slot, so the plan must not bill it
2200+
// twice and trim a snapshot binding — all ten numbers fit the ten
2201+
// slots, nothing is written, and the cache stays untouched.
20542202
sidecarReadHook.current = {
20552203
path: seeded.prPath,
20562204
run: () =>
@@ -2078,14 +2226,14 @@ describe('registerSessionPrBackfillRoutes', () => {
20782226
scanned: 1,
20792227
bound: 0,
20802228
alreadyBound: 9,
2081-
overLimit: 1,
2229+
overLimit: 0,
20822230
});
20832231
const after = await readSessionPrs(seeded.prPath);
20842232
expect(after?.map((entry) => entry.number)).toEqual([
2085-
2, 3, 4, 5, 6, 7, 8, 9, 10,
2233+
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
20862234
]);
2087-
expect(invalidateSpy).toHaveBeenCalledTimes(1);
2088-
expect(seeded.markSessionCatalogChanged).toHaveBeenCalledTimes(1);
2235+
expect(invalidateSpy).not.toHaveBeenCalled();
2236+
expect(seeded.markSessionCatalogChanged).not.toHaveBeenCalled();
20892237
} finally {
20902238
invalidateSpy.mockRestore();
20912239
await seeded.cleanup();

0 commit comments

Comments
 (0)