diff --git a/packages/ui/src/sync/child-store.test.ts b/packages/ui/src/sync/child-store.test.ts index 2ee99f9b..7572eea0 100644 --- a/packages/ui/src/sync/child-store.test.ts +++ b/packages/ui/src/sync/child-store.test.ts @@ -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(); + }); +}); diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts index 353b0a03..8508c3a1 100644 --- a/packages/ui/src/sync/child-store.ts +++ b/packages/ui/src/sync/child-store.ts @@ -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 try {