Repository navigation
[Story 4.2a] DispatchState machine refactor #374
Description
Part of #359 (Epic 4: Parallel Tool Execution).
Depends on: Story 4.1 (ToolExecutionContext).
Fixes #325 (messages/blocks render twice — root cause is the boolean re-entrancy race this story replaces with a proper state machine).
This is a refactor only — no behavior change.
Context
The current re-entrancy guard is a binary boolean presentAssistantMessageLocked (line 345). When the parallel path is introduced in Story 4.2b, this boolean cannot distinguish whether the lock is held by a serial or parallel dispatch — making the dirty-flag path unsafe (it could re-invoke the serial path while parallel is in flight). This story replaces the boolean with a three-state enum before the parallel path exists, so the serial path keeps working correctly and the parallel path has a safe integration point.
Developer Notes
In src/core/task/Task.ts:
- Replace
presentAssistantMessageLocked: booleanandpresentAssistantMessageHasPendingUpdates: booleanwith:dispatchState: DispatchState = DispatchState.IDLE pendingWork: { mode: 'serial' | 'parallel' } | null = null
Define DispatchState enum (co-located in Task.ts or a small adjacent file):
enum DispatchState {
IDLE = 'idle',
SERIAL = 'serial',
PARALLEL = 'parallel', // reserved for Story 4.2b
}Add a transitionDispatch(to: DispatchState) helper that asserts valid transitions and throws on invalid ones. Valid transitions: IDLE → SERIAL, IDLE → PARALLEL, SERIAL → IDLE, PARALLEL → IDLE.
In src/core/assistant-message/presentAssistantMessage.ts:
- Update the entry guard:
if (cline.dispatchState !== DispatchState.IDLE) { cline.pendingWork = { mode: 'serial' } return } cline.dispatchState = DispatchState.SERIAL
- In
finally: resetdispatchState = IDLE, drainpendingWorkif set (same behavior as currenthasPendingUpdatespath).
Files: src/core/task/Task.ts, src/core/assistant-message/presentAssistantMessage.ts
Tests (create src/core/assistant-message/__tests__/presentAssistantMessage.spec.ts):
- Re-entrant call while
SERIAL→ setspendingWork, does not invokepresentAssistantMessageagain. - After first call completes → drains
pendingWork→ invokes once more. - Rapid re-entrancy: N calls → at most 2 total executions (current + one pending drain), never N.
Acceptance Criteria
- All existing
presentAssistantMessagetests pass without modification. presentAssistantMessageLockedandpresentAssistantMessageHasPendingUpdatesno longer exist onTask.DispatchState.PARALLELis defined but never set in this story — reserved for 4.2b.
Activity
ToolExecutionContext#373