@@ -39,6 +39,9 @@ import {
3939const 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
4346vi . 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