@@ -19,11 +19,19 @@ import {
1919 upsertSessionPr ,
2020 type SessionService ,
2121} from '@qwen-code/qwen-code-core' ;
22+ import { SessionArchivingError } from '../acp-session-bridge.js' ;
2223import { sendBridgeError } from '../server/error-response.js' ;
24+ import {
25+ DaemonDrainingError ,
26+ SessionArchiveCoordinator ,
27+ } from '../server/session-archive.js' ;
2328import * as sessionListModule from '../server/session-list.js' ;
2429import { createWorkspaceRuntimeSessionService } from '../workspace-runtime-storage.js' ;
2530import {
31+ WorkspaceGenerationClosedError ,
32+ createWorkspaceGenerationGuard ,
2633 createWorkspaceRegistry ,
34+ type WorkspaceGenerationGuard ,
2735 type WorkspaceRegistry ,
2836 type WorkspaceRuntime ,
2937} from '../workspace-registry.js' ;
@@ -1784,6 +1792,158 @@ describe('backfillWorkspaceSessionPrs', () => {
17841792 ) . toBeNull ( ) ;
17851793 } ) ;
17861794
1795+ function attachGuard ( target : WorkspaceRuntime ) : WorkspaceGenerationGuard {
1796+ const guard = createWorkspaceGenerationGuard ( ) ;
1797+ ( target as { generationGuard ?: WorkspaceGenerationGuard } ) . generationGuard =
1798+ guard ;
1799+ return guard ;
1800+ }
1801+
1802+ it ( 'never runs gh for a retired runtime generation' , async ( ) => {
1803+ await seedSession ( SESSION_A ) ;
1804+ await seedWorktreeSidecar ( SESSION_A , 'pr-123' , 'worktree-pr-123' ) ;
1805+ const prPath = sessionService . getPrSessionPathForArchiveState (
1806+ SESSION_A ,
1807+ 'active' ,
1808+ ) ;
1809+ fetchGitHubPullRequestsMock . mockResolvedValue ( {
1810+ kind : 'ok' ,
1811+ pullRequests : [ pr ( 123 , 'worktree-pr-123' ) ] ,
1812+ } ) ;
1813+ // The route snapshots the runtime from the registry; a trust/env
1814+ // replacement that lands before the scan finishes closes its guard, and
1815+ // `gh` must not run with the retired generation's env.
1816+ attachGuard ( runtime ) . close ( ) ;
1817+
1818+ await expect ( backfillWorkspaceSessionPrs ( runtime ) ) . rejects . toBeInstanceOf (
1819+ WorkspaceGenerationClosedError ,
1820+ ) ;
1821+
1822+ expect ( fetchGitHubPullRequestsMock ) . not . toHaveBeenCalled ( ) ;
1823+ expect ( await readSessionPrs ( prPath ) ) . toBeNull ( ) ;
1824+ } ) ;
1825+
1826+ it ( 'commits nothing once the runtime generation closes mid-run' , async ( ) => {
1827+ await seedSession ( SESSION_A ) ;
1828+ await seedWorktreeSidecar ( SESSION_A , 'pr-123' , 'worktree-pr-123' ) ;
1829+ const prPath = sessionService . getPrSessionPathForArchiveState (
1830+ SESSION_A ,
1831+ 'active' ,
1832+ ) ;
1833+ fetchGitHubPullRequestsMock . mockResolvedValue ( {
1834+ kind : 'ok' ,
1835+ pullRequests : [ pr ( 123 , 'worktree-pr-123' ) ] ,
1836+ } ) ;
1837+ const guard = attachGuard ( runtime ) ;
1838+ // The replacement lands between the out-of-queue snapshot read and the
1839+ // queued write — the window every await in this run opens.
1840+ sidecarReadHook . current = {
1841+ path : prPath ,
1842+ run : async ( ) => {
1843+ guard . close ( ) ;
1844+ } ,
1845+ } ;
1846+
1847+ await expect ( backfillWorkspaceSessionPrs ( runtime ) ) . rejects . toBeInstanceOf (
1848+ WorkspaceGenerationClosedError ,
1849+ ) ;
1850+
1851+ expect ( fetchGitHubPullRequestsMock ) . toHaveBeenCalledTimes ( 1 ) ;
1852+ expect ( await readSessionPrs ( prPath ) ) . toBeNull ( ) ;
1853+ } ) ;
1854+
1855+ it ( 'defers a session held by an archive lane to the next run' , async ( ) => {
1856+ await seedSession ( SESSION_A ) ;
1857+ await seedWorktreeSidecar ( SESSION_A , 'pr-123' , 'worktree-pr-123' ) ;
1858+ const prPath = sessionService . getPrSessionPathForArchiveState (
1859+ SESSION_A ,
1860+ 'active' ,
1861+ ) ;
1862+ fetchGitHubPullRequestsMock . mockResolvedValue ( {
1863+ kind : 'ok' ,
1864+ pullRequests : [ pr ( 123 , 'worktree-pr-123' ) ] ,
1865+ } ) ;
1866+ const archiveCoordinator = new SessionArchiveCoordinator ( ) ;
1867+ let release ! : ( ) => void ;
1868+ // An archive/delete in flight holds the session's exclusive lane across
1869+ // its renames; the commit must not race it, so this run reports the
1870+ // session as unwritable and the next run re-plans it.
1871+ const archiving = archiveCoordinator . runExclusiveMany (
1872+ [ SESSION_A ] ,
1873+ ( ) =>
1874+ new Promise < void > ( ( resolve ) => {
1875+ release = resolve ;
1876+ } ) ,
1877+ ) ;
1878+
1879+ const held = await backfillWorkspaceSessionPrs ( runtime , undefined , {
1880+ archiveCoordinator,
1881+ } ) ;
1882+ expect ( held ) . toMatchObject ( { bound : 0 , written : 0 , writeErrors : 1 } ) ;
1883+ expect ( await readSessionPrs ( prPath ) ) . toBeNull ( ) ;
1884+
1885+ release ( ) ;
1886+ await archiving ;
1887+ const retried = await backfillWorkspaceSessionPrs ( runtime , undefined , {
1888+ archiveCoordinator,
1889+ } ) ;
1890+ expect ( retried ) . toMatchObject ( { bound : 1 , written : 1 } ) ;
1891+ expect ( retried . writeErrors ) . toBeUndefined ( ) ;
1892+ expect ( ( await readSessionPrs ( prPath ) ) ?. map ( ( e ) => e . number ) ) . toEqual ( [ 123 ] ) ;
1893+ } ) ;
1894+
1895+ it ( 'holds the session lane across the rewrite and the live-entry sync' , async ( ) => {
1896+ await seedSession ( SESSION_A ) ;
1897+ await seedWorktreeSidecar ( SESSION_A , 'pr-123' , 'worktree-pr-123' ) ;
1898+ fetchGitHubPullRequestsMock . mockResolvedValue ( {
1899+ kind : 'ok' ,
1900+ pullRequests : [ pr ( 123 , 'worktree-pr-123' ) ] ,
1901+ } ) ;
1902+ const archiveCoordinator = new SessionArchiveCoordinator ( ) ;
1903+ let archiveRefused = false ;
1904+ // Fires after the rewrite commits and before the live-entry sync: an
1905+ // archive attempt in that gap must be refused, not interleaved, or the
1906+ // sync would publish a list the archive move is about to split.
1907+ sidecarCommitHook . current = async ( ) => {
1908+ await expect (
1909+ archiveCoordinator . runExclusiveMany ( [ SESSION_A ] , async ( ) => { } ) ,
1910+ ) . rejects . toBeInstanceOf ( SessionArchivingError ) ;
1911+ archiveRefused = true ;
1912+ } ;
1913+
1914+ const result = await backfillWorkspaceSessionPrs ( runtime , undefined , {
1915+ archiveCoordinator,
1916+ } ) ;
1917+
1918+ expect ( archiveRefused ) . toBe ( true ) ;
1919+ expect ( result ) . toMatchObject ( { bound : 1 , written : 1 } ) ;
1920+ // The lane is released once the sync is done.
1921+ await expect (
1922+ archiveCoordinator . runExclusiveMany ( [ SESSION_A ] , async ( ) => 'ok' ) ,
1923+ ) . resolves . toBe ( 'ok' ) ;
1924+ } ) ;
1925+
1926+ it ( 'stops the run once the daemon seals session maintenance' , async ( ) => {
1927+ await seedSession ( SESSION_A ) ;
1928+ await seedWorktreeSidecar ( SESSION_A , 'pr-123' , 'worktree-pr-123' ) ;
1929+ const prPath = sessionService . getPrSessionPathForArchiveState (
1930+ SESSION_A ,
1931+ 'active' ,
1932+ ) ;
1933+ fetchGitHubPullRequestsMock . mockResolvedValue ( {
1934+ kind : 'ok' ,
1935+ pullRequests : [ pr ( 123 , 'worktree-pr-123' ) ] ,
1936+ } ) ;
1937+ const archiveCoordinator = new SessionArchiveCoordinator ( ) ;
1938+ await archiveCoordinator . sealMaintenanceAndWait ( ) ;
1939+
1940+ await expect (
1941+ backfillWorkspaceSessionPrs ( runtime , undefined , { archiveCoordinator } ) ,
1942+ ) . rejects . toBeInstanceOf ( DaemonDrainingError ) ;
1943+
1944+ expect ( await readSessionPrs ( prPath ) ) . toBeNull ( ) ;
1945+ } ) ;
1946+
17871947 it ( 'keeps backfilling other sessions when one sidecar write fails' , async ( ) => {
17881948 await seedTranscriptBranches ( SESSION_A , 1 , 1 ) ;
17891949 await seedTranscriptBranches ( SESSION_B , 2 , 2 ) ;
@@ -2239,4 +2399,108 @@ describe('registerSessionPrBackfillRoutes', () => {
22392399 await seeded . cleanup ( ) ;
22402400 }
22412401 } ) ;
2402+
2403+ it ( 'reports a workspace whose generation retired mid-run without notifying its bridge' , async ( ) => {
2404+ const seeded = await seedTrustedBackfillWorkspace ( ) ;
2405+ const guard = createWorkspaceGenerationGuard ( ) ;
2406+ (
2407+ seeded . runtime as { generationGuard ?: WorkspaceGenerationGuard }
2408+ ) . generationGuard = guard ;
2409+ const prPath = createWorkspaceRuntimeSessionService (
2410+ seeded . runtime ,
2411+ ) . getPrSessionPathForArchiveState ( SESSION_A , 'active' ) ;
2412+ fetchGitHubPullRequestsMock . mockResolvedValue ( {
2413+ kind : 'ok' ,
2414+ pullRequests : [ pr ( 123 , 'worktree-pr-123' ) ] ,
2415+ } ) ;
2416+ // The replacement lands while the route awaits the queued write; the
2417+ // retired generation must neither commit nor notify its obsolete bridge.
2418+ sidecarReadHook . current = {
2419+ path : prPath ,
2420+ run : async ( ) => {
2421+ guard . close ( ) ;
2422+ } ,
2423+ } ;
2424+ const invalidateSpy = vi . spyOn (
2425+ sessionListModule ,
2426+ 'invalidateWorkspaceSessionListCache' ,
2427+ ) ;
2428+ const app = express ( ) ;
2429+ registerSessionPrBackfillRoutes ( app , {
2430+ workspaceRegistry : registry ( [ seeded . runtime ] ) ,
2431+ sendBridgeError,
2432+ mutate : passthroughMutate ,
2433+ } ) ;
2434+
2435+ try {
2436+ const response = await request ( app ) . post ( '/sessions/backfill-prs' ) ;
2437+
2438+ expect ( response . status ) . toBe ( 200 ) ;
2439+ expect ( response . body ) . toMatchObject ( { bound : 0 } ) ;
2440+ expect ( response . body . workspaces [ 0 ] ) . toMatchObject ( {
2441+ workspaceCwd : seeded . runtime . workspaceCwd ,
2442+ bound : 0 ,
2443+ error : new WorkspaceGenerationClosedError ( ) . message ,
2444+ } ) ;
2445+ expect ( await readSessionPrs ( prPath ) ) . toBeNull ( ) ;
2446+ expect ( invalidateSpy ) . not . toHaveBeenCalled ( ) ;
2447+ expect ( seeded . markSessionCatalogChanged ) . not . toHaveBeenCalled ( ) ;
2448+ } finally {
2449+ invalidateSpy . mockRestore ( ) ;
2450+ await seeded . cleanup ( ) ;
2451+ }
2452+ } ) ;
2453+
2454+ it ( 'serialises each commit with the archive lane it is handed' , async ( ) => {
2455+ const seeded = await seedTrustedBackfillWorkspace ( ) ;
2456+ const prPath = createWorkspaceRuntimeSessionService (
2457+ seeded . runtime ,
2458+ ) . getPrSessionPathForArchiveState ( SESSION_A , 'active' ) ;
2459+ fetchGitHubPullRequestsMock . mockResolvedValue ( {
2460+ kind : 'ok' ,
2461+ pullRequests : [ pr ( 123 , 'worktree-pr-123' ) ] ,
2462+ } ) ;
2463+ const archiveCoordinator = new SessionArchiveCoordinator ( ) ;
2464+ const runSharedMany = vi . spyOn ( archiveCoordinator , 'runSharedMany' ) ;
2465+ let release ! : ( ) => void ;
2466+ const archiving = archiveCoordinator . runExclusiveMany (
2467+ [ SESSION_A ] ,
2468+ ( ) =>
2469+ new Promise < void > ( ( resolve ) => {
2470+ release = resolve ;
2471+ } ) ,
2472+ ) ;
2473+ const app = express ( ) ;
2474+ registerSessionPrBackfillRoutes ( app , {
2475+ workspaceRegistry : registry ( [ seeded . runtime ] ) ,
2476+ sendBridgeError,
2477+ mutate : passthroughMutate ,
2478+ archiveCoordinator,
2479+ } ) ;
2480+
2481+ try {
2482+ const held = await request ( app ) . post ( '/sessions/backfill-prs' ) ;
2483+ expect ( held . status ) . toBe ( 200 ) ;
2484+ expect ( held . body . workspaces [ 0 ] ) . toMatchObject ( {
2485+ bound : 0 ,
2486+ written : 0 ,
2487+ writeErrors : 1 ,
2488+ } ) ;
2489+ expect ( runSharedMany ) . toHaveBeenCalledWith (
2490+ [ SESSION_A ] ,
2491+ expect . any ( Function ) ,
2492+ ) ;
2493+ expect ( await readSessionPrs ( prPath ) ) . toBeNull ( ) ;
2494+ expect ( seeded . markSessionCatalogChanged ) . not . toHaveBeenCalled ( ) ;
2495+
2496+ release ( ) ;
2497+ await archiving ;
2498+ const retried = await request ( app ) . post ( '/sessions/backfill-prs' ) ;
2499+ expect ( retried . status ) . toBe ( 200 ) ;
2500+ expect ( retried . body ) . toMatchObject ( { bound : 1 } ) ;
2501+ expect ( seeded . markSessionCatalogChanged ) . toHaveBeenCalledTimes ( 1 ) ;
2502+ } finally {
2503+ await seeded . cleanup ( ) ;
2504+ }
2505+ } ) ;
22422506} ) ;
0 commit comments