Files
kandev/docs/task-session-turns.md
T

7.8 KiB

Task Session Turns: Table + Flow

This document describes how to add a turn table that groups all messages from a single agent turn, how messages link to a turn, and how turn updates propagate through the backend → event bus → WebSocket → frontend store.

Goals

  • Add a task_session_turns table with at least id, started_at, completed_at.
  • Ensure every task_session_message points to the turn it belongs to.
  • Turn creation/completion follows existing app flow and broadcasts to subscribed clients.

Definitions

  • Turn: A single prompt/response cycle for a task session, starting when a prompt is sent and ending when the agent completes/cancels/errors.
  • Message: A persisted item in task_session_messages (user prompt, agent text, tool call, status, error).

Schema Changes (SQLite)

Add a new table and link messages to it.

New Table: task_session_turns

Suggested columns (minimum + useful metadata):

  • id TEXT PRIMARY KEY (UUID).
  • task_session_id TEXT NOT NULL (FK → task_sessions.id).
  • task_id TEXT NOT NULL (denormalized for query fanout).
  • started_at DATETIME NOT NULL.
  • completed_at DATETIME (nullable).
  • created_at DATETIME NOT NULL.
  • updated_at DATETIME NOT NULL.
  • metadata TEXT DEFAULT '{}' (optional, for provider-specific data like Codex turn_id).

Indexes:

  • idx_turns_session_id on (task_session_id).
  • idx_turns_session_started on (task_session_id, started_at).
  • idx_turns_task_id on (task_id).
  • Optional: partial index for active turn lookup (task_session_id) WHERE completed_at IS NULL.

Add a turn_id column to task_session_messages:

  • turn_id TEXT (FK → task_session_turns.id).
  • Index on (turn_id).
  • Keep nullable for legacy messages; new writes should set it.

Files to update:

  • apps/backend/internal/task/repository/sqlite.go (schema + migration via ensureColumn).
  • apps/backend/internal/task/models/models.go (add Turn model, add TurnID to Message).
  • apps/backend/pkg/api/v1/task.go and apps/web/lib/types/http.ts (API types include turn_id).

Backend Flow (Turn Lifecycle)

Turns should align with the prompt lifecycle already handled in the orchestrator and message services.

Create Turn

Create a turn when a prompt begins:

  • Entry point: apps/backend/internal/orchestrator/service.go in PromptTask before calling executor.Prompt.
  • Create task_session_turns row with started_at = now.
  • Persist the new turn_id in memory for the rest of the prompt lifecycle.
    • Suggested: update task_sessions.metadata.active_turn_id and/or hold in the Executor/AgentExecution context.

Attach Messages to Turn

Ensure all messages created while a prompt is in-flight use the active turn_id.

Where messages are created today:

  • User prompt: apps/backend/internal/task/handlers/message_handlers.go → MessageController.CreateMessage.
  • Agent output + tool events: apps/backend/internal/orchestrator/service.go (saveAgentTextIfPresent, handleToolCallEvent, handleToolUpdateEvent, error/status creation).

Expected behavior:

  • The user prompt message should reference the newly created turn_id.
  • Subsequent agent/tool/status messages should use the same turn_id.
  • If the session is resumed and no active turn exists, create a new turn before message creation.

Implementation sketch:

  • Add TurnID to CreateMessageRequest (service/controller layers).
  • Teach messageCreator.CreateAgentMessage/CreateToolCallMessage/CreateSessionMessage to accept a turn_id or resolve the current active turn by session_id.
  • When the orchestrator receives agent stream events, it should pass turn_id to message creation calls.

Complete Turn

Close the turn when the agent finishes or the turn is cancelled:

  • Completion: apps/backend/internal/orchestrator/service.go in handleAgentStreamEvent when eventType == "complete".
  • Cancellation: apps/backend/internal/orchestrator/service.go in CancelAgent.
  • Errors: Treat eventType == "error" as terminal if it ends the prompt; set completed_at.

Action:

  • Update task_session_turns.completed_at = now, updated_at = now.
  • Clear task_sessions.metadata.active_turn_id (if used).

Event Bus and WebSocket Broadcast

Turn updates should follow the same path as existing message/session events.

Event Types

Add new event types in apps/backend/internal/events/types.go:

  • TurnStarted = "turn.started" (or task_session.turn.started).
  • TurnCompleted = "turn.completed".
  • Optional TurnUpdated = "turn.updated" for partial updates.

Publish Events

Publish turn events when you create/complete a turn:

  • Location: task service or orchestrator service (same layer that writes to DB).
  • Payload fields (minimum):
    • turn_id
    • session_id
    • task_id
    • started_at
    • completed_at

Broadcast to WebSocket Subscribers

Wire up in apps/backend/internal/gateway/websocket/task_notifications.go:

  • Subscribe to the new event types.
  • Broadcast to the session channel (same as session.message.added).

Add action constants in apps/backend/pkg/websocket/actions.go:

  • ActionSessionTurnStarted = "session.turn.started"
  • ActionSessionTurnCompleted = "session.turn.completed"
  • Optional ActionSessionTurnUpdated = "session.turn.updated"

Frontend Flow

Frontend store and handlers should mirror the current message flow.

WebSocket Handlers

Add a new WS handler file (or extend existing):

  • apps/web/lib/ws/handlers/turns.ts to handle:
    • session.turn.started
    • session.turn.completed
  • Store updates should be keyed by session_id.

Store Shape

Add a turns slice in apps/web/lib/state/store.ts:

  • turns.bySession[sessionId] = Turn[]
  • turns.activeBySession[sessionId] = Turn | null (optional for UI)

Message Rendering

Messages already render from messages.bySession[sessionId]. With turn_id:

  • Allow grouping in UI by turn.
  • Allow filtering by active turn.
  • Show turn duration in the UI (e.g., "running for 42s") by comparing started_at to now for active turns.

SSR Data

If needed for initial render:

  • Add an HTTP endpoint to list turns for a session.
  • SSR fetch in apps/web/app/task/[id]/page-client.tsx or equivalent (see docs/session-page-data-flow.md).

Migration / Backfill

Existing messages will not have turn_id:

  • Keep turn_id nullable.
  • Optional backfill: create one turn per session using min/max message timestamps and assign messages with NULL turn_id to that turn.

Edge Cases

  • Multiple prompts queued: Ensure only one active turn per session. If prompts can overlap, add a state column or queue ID.
  • Agent error before messages: Start turn on prompt, complete on error even if no agent messages.
  • Permission requests: If the agent pauses for permission, the turn remains open; completion happens after prompt completion or cancellation.
  • Concurrency: Multiple sessions can run in parallel without conflict as long as turns and broadcasts stay scoped by session_id (and active_turn_id is per session).

Touchpoints Summary

Backend:

  • apps/backend/internal/task/models/models.go (Turn model, Message.TurnID)
  • apps/backend/internal/task/repository/sqlite.go (schema + queries)
  • apps/backend/internal/task/repository/interface.go (turn methods)
  • apps/backend/internal/task/service/service.go (turn create/complete, message association)
  • apps/backend/internal/orchestrator/service.go (prompt start/complete)
  • apps/backend/internal/events/types.go (turn events)
  • apps/backend/internal/gateway/websocket/task_notifications.go (broadcast)
  • apps/backend/pkg/websocket/actions.go (actions)

Frontend:

  • apps/web/lib/ws/handlers/turns.ts (WS handlers)
  • apps/web/lib/state/store.ts (turns slice)
  • apps/web/lib/types/http.ts (Turn + Message.turn_id)
  • apps/web/components/task/chat/* (optional turn grouping UI)

Docs:

  • docs/WEBSOCKET_API.md (document new turn actions and payloads).