diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a2e05e0784..6075fb83e3 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -114,6 +114,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior. - Use `using` declarations (or equivalent disposables) for processes, file handles, etc., to ensure cleanup even on errors. - Centralize magic constants under `src/constants/`; share them instead of duplicating values across layers. - Never repeat constant values (like keybinds) in comments—they become stale when the constant changes. +- **Avoid `void asyncFn()`** - fire-and-forget async calls hide race conditions. When state is observable by other code (in-memory cache, event emitters), ensure visibility order matches invariants. If memory and disk must stay in sync, persist before updating memory so observers see consistent state. ## Component State & Storage diff --git a/src/node/services/initStateManager.ts b/src/node/services/initStateManager.ts index 1190630f39..f415ee5aba 100644 --- a/src/node/services/initStateManager.ts +++ b/src/node/services/initStateManager.ts @@ -197,6 +197,10 @@ export class InitStateManager extends EventEmitter { /** * Finalize init hook execution. * Updates state, persists to disk, emits init-end event, and resolves completion promise. + * + * IMPORTANT: We persist BEFORE updating in-memory exitCode to prevent a race condition + * where replay() sees exitCode !== null but the file doesn't exist yet. This ensures + * the invariant: if init-end is visible (live or replay), the file MUST exist. */ async endInit(workspaceId: string, exitCode: number): Promise { const state = this.store.getState(workspaceId); @@ -207,13 +211,24 @@ export class InitStateManager extends EventEmitter { } const endTime = Date.now(); - state.status = exitCode === 0 ? "success" : "error"; + const finalStatus = exitCode === 0 ? "success" : "error"; + + // Create complete state for persistence (don't mutate in-memory state yet) + const stateToPerist: InitHookState = { + ...state, + status: finalStatus, + exitCode, + endTime, + }; + + // Persist FIRST - ensures file exists before in-memory state shows completion + await this.store.persist(workspaceId, stateToPerist); + + // NOW update in-memory state (replay will now see file exists) + state.status = finalStatus; state.exitCode = exitCode; state.endTime = endTime; - // Persist to disk (fire-and-forget, errors logged internally by EventStore) - await this.store.persist(workspaceId, state); - log.info( `Init hook ${state.status} for workspace ${workspaceId} (exit code ${exitCode}, duration ${endTime - state.startTime}ms)` );