fix(sync): keep bootstrap context live for deferred recovery pulls

## Problem
Pending permission requests (and questions, MCP/LSP/VCS status) vanish
permanently after a page reload. The deferred recovery phase in
bootstrapDirectory — the code that re-pulls permission.list after load —
never executed, so the UI had no way to re-render a card for a request
the opencode server still holds. Fixes #3150.

## Root cause
The deferred phase is scheduled via setTimeout(0) guarded by isStale(),
which maps to the pump's isCurrent(). isCurrent required the run token
to still be present in runningBootstraps, but the pump deletes that
token in .finally() as soon as onBootstrap settles — always before the
setTimeout macrotask fires. The guard was therefore deterministically
stale and the phase was dead code (introduced by 85400459).

## Fix
Make isCurrent a store-liveness check — disposed, generation, and store
identity — instead of run-token ownership. The pump never replaces a
running entry for the same directory (queueBootstrap defers via
rerunRequested), so during the run itself this is equivalent. This
mirrors the existing isCurrent contract in session-message-loader.ts.

## Validation
- New regression test in child-store.test.ts fails on main and passes
  with the fix (isCurrent stays true across the post-settle macrotask,
  flips false after teardown).
- bun test src/sync/: 539 pass / 15 fail — the same 15 fail on pristine
  main; the only delta is the new passing test.
- bun run type-check (packages/ui): pass.
- bunx oxlint on both changed files: no findings on authored lines.
This commit is contained in:
yangyaofei
2026-08-27 00:21:43 +08:00
parent fc21ad5e9c
commit cab2f9adf3
2 changed files with 37 additions and 2 deletions
+27
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
ChildStoreManager,
type DirectoryBootstrapContext,
markDirectorySessionPartChanged,
subscribeDirectoryPermission,
subscribeDirectoryQuestion,
@@ -558,3 +559,29 @@ describe('ChildStoreManager directory bootstrap scheduler', () => {
manager.disposeAll();
});
});
describe('ChildStoreManager bootstrap context liveness', () => {
test('isCurrent stays true after the run settles so deferred recovery work can commit', async () => {
const manager = new ChildStoreManager();
let captured: DirectoryBootstrapContext | undefined;
const cleanup = manager.configure({
onBootstrap: (context) => {
captured = context;
},
});
manager.requestBootstrap({ directory: '/workspace', priority: 'selected', reason: 'current-directory' });
await settle();
expect(manager.getBootstrapState('/workspace')).toBe('complete');
// bootstrapDirectory schedules deferred recovery pulls (permission.list
// and friends) from a setTimeout(0), which always runs after the pump's
// .finally() has cleaned up the run entry. isCurrent must remain true
// there, or those pulls and every commit they make get skipped.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(captured?.isCurrent()).toBe(true);
cleanup();
expect(captured?.isCurrent()).toBe(false);
manager.disposeAll();
});
});
+10 -2
View File
@@ -596,11 +596,19 @@ export class ChildStoreManager {
queuedMs: Math.max(0, Date.now() - next.enqueuedAt),
})
// Store liveness, not run-token ownership. The pump deletes the run
// token in `.finally()` as soon as onBootstrap settles, while
// bootstrapDirectory schedules deferred recovery pulls (permission.list
// and friends) from a `setTimeout(0)` that always runs after that
// cleanup — gating those on the token made them dead code. The pump
// never replaces a running entry for the same directory (queueBootstrap
// defers via rerunRequested), so during the run itself this is
// equivalent. Mirrors the isCurrent contract in session-message-loader.
const store = this.children.get(next.directory)
const isCurrent = () => (
!this.disposed
&& this.bootstrapGeneration === running.generation
&& this.runningBootstraps.get(next.directory)?.token === token
&& this.children.has(next.directory)
&& this.children.get(next.directory) === store
)
let bootstrapPromise: Promise<void>
try {