Skip to content

[Story 4.2a] DispatchState machine refactor #374

@edelauna

Description

@edelauna

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions