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: boolean and presentAssistantMessageHasPendingUpdates: boolean with:
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: reset dispatchState = IDLE, drain pendingWork if set (same behavior as current hasPendingUpdates path).
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 → sets pendingWork, does not invoke presentAssistantMessage again.
- 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
presentAssistantMessage tests pass without modification.
presentAssistantMessageLocked and presentAssistantMessageHasPendingUpdates no longer exist on Task.
DispatchState.PARALLEL is defined but never set in this story — reserved for 4.2b.
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:presentAssistantMessageLocked: booleanandpresentAssistantMessageHasPendingUpdates: booleanwith:Define
DispatchStateenum (co-located inTask.tsor a small adjacent file):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:finally: resetdispatchState = IDLE, drainpendingWorkif set (same behavior as currenthasPendingUpdatespath).Files:
src/core/task/Task.ts,src/core/assistant-message/presentAssistantMessage.tsTests (create
src/core/assistant-message/__tests__/presentAssistantMessage.spec.ts):SERIAL→ setspendingWork, does not invokepresentAssistantMessageagain.pendingWork→ invokes once more.Acceptance Criteria
presentAssistantMessagetests pass without modification.presentAssistantMessageLockedandpresentAssistantMessageHasPendingUpdatesno longer exist onTask.DispatchState.PARALLELis defined but never set in this story — reserved for 4.2b.