Skip to content

Commit f580c3b

Browse files
committed
fix(serve): run PR-state sidecar commits under the generation guard and archive lane (#9729)
The refresh sweep and POST /sessions/backfill-prs snapshot a workspace runtime from the registry and then await sidecar scans, gh, and queued writes. Unlike every REST/ACP binding writer, neither asserted the runtime's generation guard, so a trust/env replacement or removal that landed mid-run left the retired generation running gh with its stale env, committing sidecars, and notifying its obsolete bridge. Both now assert the guard before gh, inside each atomic commit, and before the cache invalidation + catalog bump; a closed generation aborts the run instead of being miscounted as a write error. The existence check that narrowed R7-3/R14-2 ran in the planner before the temp-file write and rename, so archive/delete could still move the transcript and sidecar in between and the rename recreated an orphan .pr.json. Each commit (and, for backfill, the live-entry sync that follows it) now runs under the session's shared SessionArchiveCoordinator lane — the lane archive/delete take exclusively across their renames — so the two never interleave: an archive in flight defers the session to the next run, an archive attempted during a commit is refused, and a draining daemon stops the sweep. The daemon hands the app-wide coordinator to the route directly and to the timer through a per-tick lookup, since the serve app is built after the timer starts.
1 parent 8e8d3fb commit f580c3b

6 files changed

Lines changed: 579 additions & 25 deletions

File tree

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

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
2223
import { sendBridgeError } from '../server/error-response.js';
24+
import {
25+
DaemonDrainingError,
26+
SessionArchiveCoordinator,
27+
} from '../server/session-archive.js';
2328
import * as sessionListModule from '../server/session-list.js';
2429
import { createWorkspaceRuntimeSessionService } from '../workspace-runtime-storage.js';
2530
import {
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

Comments
 (0)