Files
kandev/apps/backend/internal/task/repository/sqlite/session.go
T
2026-06-14 21:40:52 +01:00

1404 lines
51 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package sqlite provides SQLite-based repository implementations.
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
agentdto "github.com/kandev/kandev/internal/agent/dto"
"github.com/kandev/kandev/internal/agentctl/tracing"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
)
type taskSessionExecutor interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
}
// Turn operations
// CreateTurn creates a new turn
func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error {
if turn.ID == "" {
turn.ID = uuid.New().String()
}
now := time.Now().UTC()
if turn.StartedAt.IsZero() {
turn.StartedAt = now
}
if turn.CreatedAt.IsZero() {
turn.CreatedAt = now
}
turn.UpdatedAt = now
metadataJSON := "{}"
if turn.Metadata != nil {
metadataBytes, err := json.Marshal(turn.Metadata)
if err != nil {
return fmt.Errorf("failed to serialize turn metadata: %w", err)
}
metadataJSON = string(metadataBytes)
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt)
return err
}
func scanTurnRow(row *sql.Row) (*models.Turn, error) {
turn := &models.Turn{}
var metadataJSON string
var completedAt sql.NullTime
err := row.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt)
if err != nil {
return nil, err
}
if completedAt.Valid {
turn.CompletedAt = &completedAt.Time
}
if metadataJSON != "" && metadataJSON != "{}" {
if err := json.Unmarshal([]byte(metadataJSON), &turn.Metadata); err != nil {
return nil, fmt.Errorf("failed to deserialize turn metadata: %w", err)
}
}
return turn, nil
}
// GetTurn retrieves a turn by ID
func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at
FROM task_session_turns WHERE id = ?
`), id)
return scanTurnRow(row)
}
// GetActiveTurnBySessionID gets the currently active (non-completed) turn for a session
func (r *Repository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at
FROM task_session_turns
WHERE task_session_id = ? AND completed_at IS NULL
ORDER BY started_at DESC LIMIT 1
`), sessionID)
return scanTurnRow(row)
}
// UpdateTurn updates an existing turn
func (r *Repository) UpdateTurn(ctx context.Context, turn *models.Turn) error {
turn.UpdatedAt = time.Now().UTC()
metadataJSON := "{}"
if turn.Metadata != nil {
metadataBytes, err := json.Marshal(turn.Metadata)
if err != nil {
return fmt.Errorf("failed to serialize turn metadata: %w", err)
}
metadataJSON = string(metadataBytes)
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_session_turns
SET completed_at = ?, metadata = ?, updated_at = ?
WHERE id = ?
`), turn.CompletedAt, metadataJSON, turn.UpdatedAt, turn.ID)
return err
}
// CompleteTurn marks a turn as completed with the current time
func (r *Repository) CompleteTurn(ctx context.Context, id string) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_session_turns
SET completed_at = ?, updated_at = ?
WHERE id = ?
`), now, now, id)
return err
}
// AbandonTurn marks a turn as completed with completed_at = started_at, giving it
// zero duration. Used when a turn was orphaned by an interruption (backend
// restart, agent crash) and the previous "running" window was not real work —
// recording `now` would inflate analytics and the UI's last-turn duration with
// hours of dead time.
func (r *Repository) AbandonTurn(ctx context.Context, id string) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_session_turns
SET completed_at = started_at, updated_at = ?
WHERE id = ? AND completed_at IS NULL
`), now, id)
return err
}
// ListTurnsBySession returns all turns for a session ordered by start time
func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) {
ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTurnsBySession")
defer span.End()
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at
FROM task_session_turns WHERE task_session_id = ? ORDER BY started_at ASC
`), sessionID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.Turn
for rows.Next() {
turn := &models.Turn{}
var metadataJSON string
var completedAt sql.NullTime
err := rows.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt)
if err != nil {
return nil, err
}
if completedAt.Valid {
turn.CompletedAt = &completedAt.Time
}
if metadataJSON != "" && metadataJSON != "{}" {
if err := json.Unmarshal([]byte(metadataJSON), &turn.Metadata); err != nil {
return nil, fmt.Errorf("failed to deserialize turn metadata: %w", err)
}
}
result = append(result, turn)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// taskSessionSelectCols is the column list used by every SELECT that scans
// into a *models.TaskSession via scanTaskSession / scanTaskSessionRow. Centralised
// so columns can be added in one place. Order MUST match the scan helpers.
//
// agent_execution_id and container_id come from executors_running (the single
// source of truth for active execution state) via LEFT JOIN. All other columns
// come from task_sessions (aliased ts).
//
// ADR 0005: agent_profile_id is the single column for both kanban (FK to a
// shallow profile) and office (FK to a per-workspace rich profile) sessions —
// the two column names that used to live here have collapsed into one.
const taskSessionSelectCols = `ts.id, ts.task_id,
COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''),
ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id,
ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path,
ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot,
ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at,
ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id`
// taskSessionFromClause is the FROM clause that pairs with taskSessionSelectCols.
// Always reference task_sessions as `ts` and executors_running as `er` in WHERE/ORDER.
const taskSessionFromClause = `FROM task_sessions ts
LEFT JOIN executors_running er ON er.session_id = ts.id`
// Task Session operations
// CreateTaskSession creates a new agent session
func (r *Repository) CreateTaskSession(ctx context.Context, session *models.TaskSession) error {
if session.ID == "" {
session.ID = uuid.New().String()
}
now := time.Now().UTC()
// Only default StartedAt / UpdatedAt when the caller hasn't supplied
// one. The test harness backdates StartedAt so completed sessions
// have a non-zero duration (e.g. "Agent worked for 30s"); blowing
// those values away here defeats that.
if session.StartedAt.IsZero() {
session.StartedAt = now
}
if session.UpdatedAt.IsZero() {
session.UpdatedAt = now
}
if session.State == "" {
session.State = models.TaskSessionStateCreated
}
metadataJSON, err := json.Marshal(session.Metadata)
if err != nil {
return fmt.Errorf("failed to serialize agent session metadata: %w", err)
}
agentProfileSnapshotJSON, err := json.Marshal(session.AgentProfileSnapshot)
if err != nil {
return fmt.Errorf("failed to serialize agent profile snapshot: %w", err)
}
executorSnapshotJSON, err := json.Marshal(session.ExecutorSnapshot)
if err != nil {
return fmt.Errorf("failed to serialize executor snapshot: %w", err)
}
environmentSnapshotJSON, err := json.Marshal(session.EnvironmentSnapshot)
if err != nil {
return fmt.Errorf("failed to serialize environment snapshot: %w", err)
}
repositorySnapshotJSON, err := json.Marshal(session.RepositorySnapshot)
if err != nil {
return fmt.Errorf("failed to serialize repository snapshot: %w", err)
}
// agent_profile_id is NULL-able. Empty string would defeat the partial
// unique index since SQLite treats two empty strings as equal — store NULL
// for kanban / quick-chat rows and a real value only for office sessions
// (per ADR 0005, kanban and office now share the same column).
var agentProfileID interface{}
if session.AgentProfileID != "" {
agentProfileID = session.AgentProfileID
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_sessions (
id, task_id, agent_profile_id, executor_id, executor_profile_id, environment_id,
repository_id, base_branch, base_commit_sha, workspace_path,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, review_status, is_passthrough, task_environment_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), session.ID, session.TaskID, agentProfileID,
session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath,
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
string(session.State), session.ErrorMessage, string(metadataJSON),
session.StartedAt, session.CompletedAt, session.UpdatedAt,
dialect.BoolToInt(session.IsPrimary), session.ReviewStatus,
dialect.BoolToInt(session.IsPassthrough), session.TaskEnvironmentID)
if err != nil && strings.Contains(err.Error(), "uniq_office_task_session") {
// Two callers raced past their SELECT-then-INSERT for the same
// (task_id, agent_profile_id) — surface a typed sentinel so callers
// can classify with errors.Is rather than driver-message matching.
return fmt.Errorf("%w: %w", ErrOfficeSessionRaceConflict, err)
}
return err
}
// unmarshalSessionJSON deserializes a JSON string into dest, skipping empty/placeholder values.
func unmarshalSessionJSON(jsonStr string, dest interface{}, fieldDesc string) error {
if jsonStr == "" || jsonStr == "{}" {
return nil
}
if err := json.Unmarshal([]byte(jsonStr), dest); err != nil {
return fmt.Errorf("failed to deserialize %s: %w", fieldDesc, err)
}
return nil
}
func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsErr string) (*models.TaskSession, error) {
session := &models.TaskSession{}
var state string
var metadataJSON string
var agentProfileSnapshotJSON string
var executorSnapshotJSON string
var environmentSnapshotJSON string
var repositorySnapshotJSON string
var completedAt sql.NullTime
var isPrimary int
var isPassthrough int
var reviewStatus sql.NullString
// agent_profile_id is nullable (kanban / quick-chat rows store NULL); decode
// via NullString so the empty case maps to "" on the model.
var agentProfileID sql.NullString
err := row.Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &agentProfileID,
&session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID,
&session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath,
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
&isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%s", noRowsErr)
}
if err != nil {
return nil, err
}
session.State = models.TaskSessionState(state)
session.IsPrimary = isPrimary == 1
session.IsPassthrough = isPassthrough == 1
if reviewStatus.Valid {
session.ReviewStatus = models.ReviewStatus(reviewStatus.String)
}
if agentProfileID.Valid {
session.AgentProfileID = agentProfileID.String
}
if completedAt.Valid {
session.CompletedAt = &completedAt.Time
}
if err := unmarshalSessionJSON(metadataJSON, &session.Metadata, "agent session metadata"); err != nil {
return nil, err
}
if err := unmarshalSessionJSON(agentProfileSnapshotJSON, &session.AgentProfileSnapshot, "agent profile snapshot"); err != nil {
return nil, err
}
if err := unmarshalSessionJSON(executorSnapshotJSON, &session.ExecutorSnapshot, "executor snapshot"); err != nil {
return nil, err
}
if err := unmarshalSessionJSON(environmentSnapshotJSON, &session.EnvironmentSnapshot, "environment snapshot"); err != nil {
return nil, err
}
if err := unmarshalSessionJSON(repositorySnapshotJSON, &session.RepositorySnapshot, "repository snapshot"); err != nil {
return nil, err
}
worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID)
if err != nil {
return nil, fmt.Errorf("failed to load session worktrees: %w", err)
}
session.Worktrees = worktrees
return session, nil
}
// GetTaskSession retrieves an agent session by ID
func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+` WHERE ts.id = ?`,
), id)
return r.scanTaskSession(ctx, row, fmt.Sprintf("agent session not found: %s", id))
}
// GetTaskSessionByTaskID retrieves the most recent agent session for a task
func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+` WHERE ts.task_id = ? ORDER BY ts.started_at DESC LIMIT 1`,
), taskID)
return r.scanTaskSession(ctx, row, fmt.Sprintf("agent session not found for task: %s", taskID))
}
// GetActiveTaskSessionByTaskID retrieves the active (running/waiting) agent session for a task
func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+`
WHERE ts.task_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
ORDER BY ts.started_at DESC LIMIT 1`,
), taskID)
return r.scanTaskSession(ctx, row, fmt.Sprintf("no active agent session for task: %s", taskID))
}
// GetTaskSessionByTaskAndAgent retrieves the office task session for the given
// (task_id, agent_profile_id) pair. The pair is unique across non-NULL
// agent_profile_id rows, so at most one row matches. Returns nil, nil when
// no session exists for the pair.
func (r *Repository) GetTaskSessionByTaskAndAgent(ctx context.Context, taskID, agentInstanceID string) (*models.TaskSession, error) {
if taskID == "" || agentInstanceID == "" {
return nil, nil
}
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+`
WHERE ts.task_id = ? AND ts.agent_profile_id = ?
ORDER BY ts.started_at DESC LIMIT 1`,
), taskID, agentInstanceID)
session, err := r.scanTaskSession(ctx, row, sessionNotFoundMsg)
if err != nil && err.Error() == sessionNotFoundMsg {
return nil, nil
}
return session, err
}
// sessionNotFoundMsg is the sentinel string used by GetTaskSessionByTaskAndAgent
// to detect "no row found" so the caller gets nil, nil instead of an error.
const sessionNotFoundMsg = "task_sessions: no matching row"
// noPrimarySessionSentinel is the no-rows marker passed to scanTaskSession by
// GetPrimarySessionByTaskID. Matching this exact string lets the function wrap
// the result with ErrNoPrimarySession so callers can use errors.Is.
const noPrimarySessionSentinel = "task_sessions: no primary session row"
// ListNonTerminalSessionsByAgentInstance returns every office task_session row
// for the given agent_profile_id whose state is NOT terminal
// (CREATED / STARTING / RUNNING / IDLE / WAITING_FOR_INPUT). Used by the
// agent-instance deletion cascade in office, which must terminate all of an
// agent's live sessions across every task.
func (r *Repository) ListNonTerminalSessionsByAgentInstance(ctx context.Context, agentInstanceID string) ([]*models.TaskSession, error) {
if agentInstanceID == "" {
return nil, nil
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+`
WHERE ts.agent_profile_id = ?
AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'IDLE', 'WAITING_FOR_INPUT')`,
), agentInstanceID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTaskSessions(ctx, rows)
}
// UpdateTaskSession updates an existing agent session.
// Note: metadata is NOT updated here to prevent clobbering concurrent writes
// from UpdateSessionMetadata callers (e.g. setSessionPlanMode, persistPrepareResult).
// Use UpdateSessionMetadata for metadata changes.
func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.TaskSession) error {
session.UpdatedAt = time.Now().UTC()
return r.updateTaskSession(ctx, r.db, session)
}
// UpdateTaskSessionWithMetadata updates the session row and metadata column in
// one transaction so callers cannot observe a partially-applied update.
func (r *Repository) UpdateTaskSessionWithMetadata(
ctx context.Context,
session *models.TaskSession,
metadata map[string]interface{},
) error {
session.UpdatedAt = time.Now().UTC()
metadataJSON, err := marshalSessionMetadata(metadata)
if err != nil {
return err
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.updateTaskSession(ctx, tx, session); err != nil {
return err
}
if err := r.updateSessionMetadataJSON(ctx, tx, session.ID, metadataJSON, session.UpdatedAt); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) updateTaskSession(
ctx context.Context,
exec taskSessionExecutor,
session *models.TaskSession,
) error {
agentProfileSnapshotJSON, err := json.Marshal(session.AgentProfileSnapshot)
if err != nil {
return fmt.Errorf("failed to serialize agent profile snapshot: %w", err)
}
executorSnapshotJSON, err := json.Marshal(session.ExecutorSnapshot)
if err != nil {
return fmt.Errorf("failed to serialize executor snapshot: %w", err)
}
environmentSnapshotJSON, err := json.Marshal(session.EnvironmentSnapshot)
if err != nil {
return fmt.Errorf("failed to serialize environment snapshot: %w", err)
}
repositorySnapshotJSON, err := json.Marshal(session.RepositorySnapshot)
if err != nil {
return fmt.Errorf("failed to serialize repository snapshot: %w", err)
}
// metadata is NOT written here — callers wanting to change it must use
// UpdateSessionMetadata or SetSessionMetadataKey. A full-row write here
// would clobber metadata set via those side-channel paths since the
// caller's in-memory copy may be stale.
// agent_profile_id is stored as NULL when empty so the partial unique
// index over (task_id, agent_profile_id) ignores kanban / quick-chat rows.
var agentProfileID interface{}
if session.AgentProfileID != "" {
agentProfileID = session.AgentProfileID
}
result, err := exec.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET
agent_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?,
repository_id = ?, base_branch = ?, base_commit_sha = ?, workspace_path = ?,
agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?,
state = ?, error_message = ?, completed_at = ?, updated_at = ?,
is_primary = ?, review_status = ?, is_passthrough = ?, task_environment_id = ?
WHERE id = ?
`), agentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID,
session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath,
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
string(session.State), session.ErrorMessage, session.CompletedAt, session.UpdatedAt,
dialect.BoolToInt(session.IsPrimary), session.ReviewStatus,
dialect.BoolToInt(session.IsPassthrough), session.TaskEnvironmentID,
session.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent session not found: %s", session.ID)
}
return nil
}
// UpdateTaskSessionState updates just the state and error message of an agent session
func (r *Repository) UpdateTaskSessionState(ctx context.Context, id string, status models.TaskSessionState, errorMessage string) error {
now := time.Now().UTC()
var completedAt *time.Time
if status == models.TaskSessionStateCompleted || status == models.TaskSessionStateFailed || status == models.TaskSessionStateCancelled {
completedAt = &now
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET state = ?, error_message = ?, completed_at = ?, updated_at = ? WHERE id = ?
`), string(status), errorMessage, completedAt, now, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent session not found: %s", id)
}
return nil
}
// CancelActiveTaskSessionsByTaskID transitions every active session of a task
// (CREATED/STARTING/RUNNING/WAITING_FOR_INPUT) to CANCELLED, returning the
// number of rows changed. The transition is a pure DB state change and does not
// require a live agent execution, making it the authoritative way to finalize a
// task's sessions independent of agent-process teardown.
func (r *Repository) CancelActiveTaskSessionsByTaskID(ctx context.Context, taskID, reason string) (int64, error) {
now := time.Now().UTC()
writeCtx := context.WithoutCancel(ctx)
result, err := r.db.ExecContext(writeCtx, r.db.Rebind(`
UPDATE task_sessions
SET state = ?, error_message = ?, completed_at = ?, updated_at = ?
WHERE task_id = ?
AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
`), string(models.TaskSessionStateCancelled), reason, now, now, taskID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// UpdateSessionMetadata updates only the metadata column of a session,
// avoiding a full-row overwrite that could clobber concurrent field updates.
func (r *Repository) UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error {
metadataJSON, err := marshalSessionMetadata(metadata)
if err != nil {
return err
}
now := time.Now().UTC()
return r.updateSessionMetadataJSON(ctx, r.db, sessionID, metadataJSON, now)
}
func marshalSessionMetadata(metadata map[string]interface{}) (string, error) {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return "", fmt.Errorf("failed to serialize metadata: %w", err)
}
return string(metadataJSON), nil
}
func (r *Repository) updateSessionMetadataJSON(
ctx context.Context,
exec taskSessionExecutor,
sessionID string,
metadataJSON string,
updatedAt time.Time,
) error {
result, err := exec.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET metadata = ?, updated_at = ? WHERE id = ?
`), metadataJSON, updatedAt, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent session not found: %s", sessionID)
}
return nil
}
// SetSessionMetadataKey atomically sets a single key in the session's metadata
// using SQLite's json_set. Unlike UpdateSessionMetadata (which does a full
// replacement), this preserves all other metadata keys.
func (r *Repository) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error {
valueJSON, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to serialize metadata value: %w", err)
}
now := time.Now().UTC()
path := "$." + key
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?)), updated_at = ? WHERE id = ?
`), path, string(valueJSON), now, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent session not found: %s", sessionID)
}
return nil
}
// GetLastAgentMessage returns the content of the most recent agent message in a session.
func (r *Repository) GetLastAgentMessage(ctx context.Context, sessionID string) (string, error) {
var content string
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT content FROM task_session_messages
WHERE task_session_id = ? AND author_type = 'agent' AND type = 'message'
ORDER BY created_at DESC LIMIT 1
`), sessionID).Scan(&content)
if err != nil {
return "", err
}
return content, nil
}
// IncrementTaskSessionUsage adds the given deltas to the cumulative
// tokens / cost columns on task_sessions. Used by the office cost
// subscriber after a cost event lands so the per-session totals stay
// in sync without re-summing office_cost_events. The model + DTO
// don't surface these columns yet (DB-only per the office-costs
// wedge); the cost explorer follow-up will expose them.
func (r *Repository) IncrementTaskSessionUsage(
ctx context.Context, sessionID string, tokensIn, tokensOut, costSubcents int64,
) error {
if sessionID == "" {
return nil
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions
SET tokens_in = COALESCE(tokens_in, 0) + ?,
tokens_out = COALESCE(tokens_out, 0) + ?,
cost_subcents = COALESCE(cost_subcents, 0) + ?
WHERE id = ?
`), tokensIn, tokensOut, costSubcents, sessionID)
return err
}
// UpdateTaskSessionBaseCommit updates the base_commit_sha for a session.
// This is called after agent launch to capture the HEAD commit at session start.
func (r *Repository) UpdateTaskSessionBaseCommit(ctx context.Context, id string, baseCommitSHA string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET base_commit_sha = ?, updated_at = ? WHERE id = ?
`), baseCommitSHA, now, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent session not found: %s", id)
}
return nil
}
// ResetTaskSessionBasesForRepository rewrites base_branch and clears
// base_commit_sha on every task_session belonging to (taskID, repositoryID).
// Used by service.UpdateRepositoryBaseBranch after the changes-panel
// "Compare against" picker changes the recorded base.
//
// Clearing base_commit_sha matters because git_handlers.computeGitCommits and
// computeCumulativeDiff read it first and only fall back to the live
// agentctl GetGitStatus().BaseCommit (which now resolves against the new
// base_branch via Phase 1) when the column is empty. Without this reset the
// task-card stat counts would refresh against the new base while the
// commits panel + cumulative diff would keep filtering against the OLD
// commit snapshot — visible to users as "Commits section disappeared
// after I changed the base".
//
// Returns the number of sessions touched so callers can log / no-op when
// the task has no sessions yet.
func (r *Repository) ResetTaskSessionBasesForRepository(ctx context.Context, taskID, repositoryID, baseBranch string) (int64, error) {
if taskID == "" {
return 0, fmt.Errorf("task_id is required")
}
if repositoryID == "" {
return 0, fmt.Errorf("repository_id is required")
}
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions
SET base_branch = ?, base_commit_sha = '', updated_at = ?
WHERE task_id = ? AND repository_id = ?
`), baseBranch, now, taskID, repositoryID)
if err != nil {
return 0, err
}
rows, _ := result.RowsAffected()
return rows, nil
}
// ListTaskSessions returns all agent sessions for a task
func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTaskSessions")
defer span.End()
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+` WHERE ts.task_id = ? ORDER BY ts.started_at DESC`,
), taskID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
sessions, err := r.scanTaskSessions(ctx, rows)
if err != nil {
return nil, err
}
return r.loadWorktreesBatch(ctx, sessions)
}
// ListActiveTaskSessions returns all active agent sessions across all tasks
func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) {
ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListActiveTaskSessions")
defer span.End()
rows, err := r.ro.QueryContext(ctx,
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+` WHERE ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY ts.started_at DESC`,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
sessions, err := r.scanTaskSessions(ctx, rows)
if err != nil {
return nil, err
}
return r.loadWorktreesBatch(ctx, sessions)
}
// ListActiveTaskSessionsByTaskID returns all active agent sessions for a specific task
func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+` WHERE ts.task_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY ts.started_at DESC`,
), taskID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
sessions, err := r.scanTaskSessions(ctx, rows)
if err != nil {
return nil, err
}
return r.loadWorktreesBatch(ctx, sessions)
}
// loadWorktreesBatch loads worktrees for multiple sessions in a single query.
func (r *Repository) loadWorktreesBatch(ctx context.Context, sessions []*models.TaskSession) ([]*models.TaskSession, error) {
if len(sessions) == 0 {
return sessions, nil
}
sessionIDs := make([]string, len(sessions))
for i, s := range sessions {
sessionIDs[i] = s.ID
}
worktreeMap, err := r.ListWorktreesBySessionIDs(ctx, sessionIDs)
if err != nil {
return nil, fmt.Errorf("failed to batch-load session worktrees: %w", err)
}
for _, session := range sessions {
session.Worktrees = worktreeMap[session.ID]
}
return sessions, nil
}
// BatchGetSessionsByTaskIDs returns all task_sessions for the given task IDs,
// grouped by task ID and ordered by started_at DESC within each task. The
// returned sessions carry their associated worktrees (loaded in one extra
// query). Used by callers that need primary session, session count, and
// session info for many tasks in one round trip (e.g. the workspace
// task-list endpoint) to avoid issuing one GetSession per task.
//
// Returns an empty map for an empty input. Chunks input larger than
// sqliteMaxHostParams to stay well below SQLite's compile-time placeholder
// limit.
func (r *Repository) BatchGetSessionsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*models.TaskSession, error) {
result := make(map[string][]*models.TaskSession, len(taskIDs))
if len(taskIDs) == 0 {
return result, nil
}
for _, chunk := range chunkIDs(taskIDs, sqliteMaxHostParams) {
placeholders, args := buildInPlaceholders(chunk)
query := `SELECT ` + taskSessionSelectCols + ` ` + taskSessionFromClause +
` WHERE ts.task_id IN (` + placeholders + `) ORDER BY ts.task_id, ts.started_at DESC`
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
sessions, scanErr := r.scanTaskSessions(ctx, rows)
_ = rows.Close()
if scanErr != nil {
return nil, scanErr
}
sessions, err = r.loadWorktreesBatch(ctx, sessions)
if err != nil {
return nil, err
}
for _, s := range sessions {
result[s.TaskID] = append(result[s.TaskID], s)
}
}
return result, nil
}
func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) {
var exists int
// Exclude ephemeral tasks (quick chat, config chat) - they shouldn't block profile deletion
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT 1 FROM task_sessions ts
JOIN tasks t ON ts.task_id = t.id
WHERE ts.agent_profile_id = ?
AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
AND t.is_ephemeral = 0
LIMIT 1
`), agentProfileID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
func (r *Repository) GetActiveTaskInfoByAgentProfile(ctx context.Context, agentProfileID string) ([]agentdto.ActiveTaskInfo, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT DISTINCT t.id, t.title, t.is_ephemeral
FROM task_sessions ts
JOIN tasks t ON t.id = ts.task_id
WHERE ts.agent_profile_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
`), agentProfileID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []agentdto.ActiveTaskInfo
for rows.Next() {
var info agentdto.ActiveTaskInfo
if err := rows.Scan(&info.TaskID, &info.TaskTitle, &info.IsEphemeral); err != nil {
return nil, err
}
result = append(result, info)
}
return result, rows.Err()
}
func (r *Repository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) {
var exists int
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT 1 FROM task_sessions
WHERE environment_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
LIMIT 1
`), environmentID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
func (r *Repository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) {
var exists int
// Only sessions of live (non-archived) tasks count; archived tasks never
// block repository deletion.
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT 1
FROM task_sessions s
INNER JOIN task_repositories tr ON tr.task_id = s.task_id
INNER JOIN tasks t ON t.id = s.task_id
WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
AND tr.repository_id = ?
AND t.archived_at IS NULL
LIMIT 1
`), repositoryID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
func (r *Repository) CountActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (int, error) {
var count int
// Counts only sessions of live (non-archived) tasks, matching
// HasActiveTaskSessionsByRepository.
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT COUNT(*)
FROM task_sessions s
INNER JOIN task_repositories tr ON tr.task_id = s.task_id
INNER JOIN tasks t ON t.id = s.task_id
WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
AND tr.repository_id = ?
AND t.archived_at IS NULL
`), repositoryID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
// DeleteEphemeralTasksByAgentProfile deletes all ephemeral tasks (and their sessions)
// that are using the specified agent profile. This is used during profile deletion
// to clean up transient quick chat / config chat tasks.
func (r *Repository) DeleteEphemeralTasksByAgentProfile(ctx context.Context, agentProfileID string) (int64, error) {
// Delete tasks that are ephemeral and have sessions using this profile.
// CASCADE will handle session deletion.
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
DELETE FROM tasks
WHERE is_ephemeral = 1
AND id IN (
SELECT DISTINCT task_id FROM task_sessions WHERE agent_profile_id = ?
)
`), agentProfileID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// scanTaskSessions is a helper to scan multiple agent session rows
func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*models.TaskSession, error) {
var result []*models.TaskSession
for rows.Next() {
session, err := scanTaskSessionRow(rows)
if err != nil {
return nil, err
}
result = append(result, session)
}
return result, rows.Err()
}
// scanTaskSessionRow scans a single row into a TaskSession, applying all field mappings and JSON unmarshalling.
func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) {
session := &models.TaskSession{}
var state string
var metadataJSON string
var agentProfileSnapshotJSON string
var executorSnapshotJSON string
var environmentSnapshotJSON string
var repositorySnapshotJSON string
var completedAt sql.NullTime
var isPrimary int
var isPassthrough int
var reviewStatus sql.NullString
var agentProfileID sql.NullString
err := rows.Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &agentProfileID,
&session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID,
&session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath,
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
&isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID,
)
if err != nil {
return nil, err
}
session.State = models.TaskSessionState(state)
session.IsPrimary = isPrimary == 1
session.IsPassthrough = isPassthrough == 1
if reviewStatus.Valid {
session.ReviewStatus = models.ReviewStatus(reviewStatus.String)
}
if agentProfileID.Valid {
session.AgentProfileID = agentProfileID.String
}
if completedAt.Valid {
session.CompletedAt = &completedAt.Time
}
if err := unmarshalSessionSnapshots(session, metadataJSON, agentProfileSnapshotJSON,
executorSnapshotJSON, environmentSnapshotJSON, repositorySnapshotJSON); err != nil {
return nil, err
}
return session, nil
}
// unmarshalSessionSnapshots deserializes all JSON snapshot fields into the session struct.
func unmarshalSessionSnapshots(
session *models.TaskSession,
metadataJSON, agentProfileSnapshotJSON, executorSnapshotJSON, environmentSnapshotJSON, repositorySnapshotJSON string,
) error {
if err := unmarshalSessionJSON(metadataJSON, &session.Metadata, "agent session metadata"); err != nil {
return err
}
if err := unmarshalSessionJSON(agentProfileSnapshotJSON, &session.AgentProfileSnapshot, "agent profile snapshot"); err != nil {
return err
}
if err := unmarshalSessionJSON(executorSnapshotJSON, &session.ExecutorSnapshot, "executor snapshot"); err != nil {
return err
}
if err := unmarshalSessionJSON(environmentSnapshotJSON, &session.EnvironmentSnapshot, "environment snapshot"); err != nil {
return err
}
return unmarshalSessionJSON(repositorySnapshotJSON, &session.RepositorySnapshot, "repository snapshot")
}
// DeleteTaskSession deletes an agent session by ID
func (r *Repository) DeleteTaskSession(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_sessions WHERE id = ?`), id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent session not found: %s", id)
}
return nil
}
// Task Session Worktree operations
func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error {
if sessionWorktree.ID == "" {
sessionWorktree.ID = uuid.New().String()
}
now := time.Now().UTC()
sessionWorktree.CreatedAt = now
updatedAt := now
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_session_worktrees (
id, session_id, worktree_id, repository_id, position,
worktree_path, worktree_branch, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, worktree_id) DO UPDATE SET
repository_id = excluded.repository_id,
position = excluded.position,
worktree_path = excluded.worktree_path,
worktree_branch = excluded.worktree_branch,
updated_at = excluded.updated_at
`),
sessionWorktree.ID,
sessionWorktree.SessionID,
sessionWorktree.WorktreeID,
sessionWorktree.RepositoryID,
sessionWorktree.Position,
sessionWorktree.WorktreePath,
sessionWorktree.WorktreeBranch,
sessionWorktree.CreatedAt,
updatedAt,
)
return err
}
// UpdateTaskSessionWorktreeBranch updates the cached worktree_branch for all
// worktrees belonging to a session. Called when a branch switch or rename is
// detected in the live workspace so downstream consumers (PR watch
// reconciliation, branch listings) see the current branch rather than the
// value captured at worktree creation.
func (r *Repository) UpdateTaskSessionWorktreeBranch(ctx context.Context, sessionID, branch string) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_session_worktrees SET worktree_branch = ?, updated_at = ? WHERE session_id = ?
`), branch, now, sessionID)
return err
}
func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT
tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position,
tsw.worktree_path, tsw.worktree_branch, tsw.created_at
FROM task_session_worktrees tsw
WHERE tsw.session_id = ?
ORDER BY tsw.position ASC, tsw.created_at ASC
`), sessionID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var worktrees []*models.TaskSessionWorktree
for rows.Next() {
var wt models.TaskSessionWorktree
err := rows.Scan(
&wt.ID,
&wt.SessionID,
&wt.WorktreeID,
&wt.RepositoryID,
&wt.Position,
&wt.WorktreePath,
&wt.WorktreeBranch,
&wt.CreatedAt,
)
if err != nil {
return nil, err
}
worktrees = append(worktrees, &wt)
}
return worktrees, rows.Err()
}
// ListWorktreesBySessionIDs returns all worktrees for the given session IDs,
// grouped by session ID. This eliminates N+1 queries when loading worktrees
// for multiple sessions. Chunks input above sqliteMaxHostParams (500) because
// callers like loadWorktreesBatch — invoked from BatchGetSessionsByTaskIDs —
// can pass `chunk_size_tasks × avg_sessions_per_task` IDs, which crosses
// SQLite's SQLITE_MAX_VARIABLE_NUMBER (999 on older builds) at modest task
// volumes (500 tasks × 2 sessions = 1000 placeholders).
func (r *Repository) ListWorktreesBySessionIDs(ctx context.Context, sessionIDs []string) (map[string][]*models.TaskSessionWorktree, error) {
result := make(map[string][]*models.TaskSessionWorktree, len(sessionIDs))
if len(sessionIDs) == 0 {
return result, nil
}
for _, chunk := range chunkIDs(sessionIDs, sqliteMaxHostParams) {
if err := r.appendWorktreesForSessionChunk(ctx, chunk, result); err != nil {
return nil, err
}
}
return result, nil
}
// appendWorktreesForSessionChunk runs the worktree SELECT for one chunk of
// session IDs and merges the rows into result. Extracted from
// ListWorktreesBySessionIDs so the public method stays inside the funlen cap
// after the chunking loop was added.
func (r *Repository) appendWorktreesForSessionChunk(
ctx context.Context,
sessionIDs []string,
result map[string][]*models.TaskSessionWorktree,
) error {
placeholders, args := buildInPlaceholders(sessionIDs)
query := `SELECT tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position,
tsw.worktree_path, tsw.worktree_branch, tsw.created_at
FROM task_session_worktrees tsw
WHERE tsw.session_id IN (` + placeholders + `)
ORDER BY tsw.position ASC, tsw.created_at ASC`
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var wt models.TaskSessionWorktree
if err := rows.Scan(&wt.ID, &wt.SessionID, &wt.WorktreeID, &wt.RepositoryID,
&wt.Position, &wt.WorktreePath, &wt.WorktreeBranch, &wt.CreatedAt); err != nil {
return err
}
result[wt.SessionID] = append(result[wt.SessionID], &wt)
}
return rows.Err()
}
func (r *Repository) DeleteTaskSessionWorktree(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE id = ?`), id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("task session worktree not found: %s", id)
}
return nil
}
func (r *Repository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE session_id = ?`), sessionID)
return err
}
// GetPrimarySessionByTaskID retrieves the primary session for a task.
// Returns ErrNoPrimarySession (wrapped) when the task has no primary session
// row; callers should use errors.Is to distinguish this from real DB errors.
func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(
`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+` WHERE ts.task_id = ? AND ts.is_primary = 1 LIMIT 1`,
), taskID)
session, err := r.scanTaskSession(ctx, row, noPrimarySessionSentinel)
if err != nil && err.Error() == noPrimarySessionSentinel {
return nil, fmt.Errorf("%w: %s", ErrNoPrimarySession, taskID)
}
return session, err
}
// GetPrimarySessionIDsByTaskIDs returns a map of task ID to primary session ID for the given task IDs.
// Tasks without a primary session are not included in the result.
func (r *Repository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) {
if len(taskIDs) == 0 {
return make(map[string]string), nil
}
// Build placeholders for IN clause
placeholders := make([]string, len(taskIDs))
args := make([]interface{}, len(taskIDs))
for i, id := range taskIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(`
SELECT id, task_id FROM task_sessions
WHERE task_id IN (%s) AND is_primary = 1
`, strings.Join(placeholders, ","))
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
result := make(map[string]string)
for rows.Next() {
var sessionID, taskID string
if err := rows.Scan(&sessionID, &taskID); err != nil {
return nil, err
}
result[taskID] = sessionID
}
return result, rows.Err()
}
// GetSessionCountsByTaskIDs returns a map of task ID to session count for the given task IDs.
func (r *Repository) GetSessionCountsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]int, error) {
if len(taskIDs) == 0 {
return make(map[string]int), nil
}
// Build placeholders for IN clause
placeholders := make([]string, len(taskIDs))
args := make([]interface{}, len(taskIDs))
for i, id := range taskIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(`
SELECT task_id, COUNT(*) as count FROM task_sessions
WHERE task_id IN (%s)
GROUP BY task_id
`, strings.Join(placeholders, ","))
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
result := make(map[string]int)
for rows.Next() {
var taskID string
var count int
if err := rows.Scan(&taskID, &count); err != nil {
return nil, err
}
result[taskID] = count
}
return result, rows.Err()
}
// GetPrimarySessionInfoByTaskIDs returns a map of task ID to primary session for the given task IDs.
// Returns review_status, executor info, agent profile snapshot, and repository snapshot.
func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*models.TaskSession, error) {
if len(taskIDs) == 0 {
return make(map[string]*models.TaskSession), nil
}
// Build placeholders for IN clause
placeholders := make([]string, len(taskIDs))
args := make([]interface{}, len(taskIDs))
for i, id := range taskIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(`
SELECT ts.id, ts.task_id, ts.review_status, ts.executor_id, ts.state,
ts.agent_profile_snapshot, ts.repository_snapshot,
e.type, e.name
FROM task_sessions ts
LEFT JOIN executors e ON e.id = ts.executor_id
WHERE ts.task_id IN (%s) AND ts.is_primary = 1
`, strings.Join(placeholders, ","))
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
result := make(map[string]*models.TaskSession)
for rows.Next() {
var sessionID string
var taskID string
var reviewStatus sql.NullString
var executorID sql.NullString
var sessionState sql.NullString
var agentProfileSnapshotJSON sql.NullString
var repositorySnapshotJSON sql.NullString
var executorType sql.NullString
var executorName sql.NullString
if err := rows.Scan(&sessionID, &taskID, &reviewStatus, &executorID, &sessionState, &agentProfileSnapshotJSON, &repositorySnapshotJSON, &executorType, &executorName); err != nil {
return nil, err
}
session := &models.TaskSession{
ID: sessionID,
TaskID: taskID,
}
if sessionState.Valid {
session.State = models.TaskSessionState(sessionState.String)
}
if reviewStatus.Valid {
session.ReviewStatus = models.ReviewStatus(reviewStatus.String)
}
if executorID.Valid {
session.ExecutorID = executorID.String
}
if executorType.Valid || executorName.Valid {
session.ExecutorSnapshot = make(map[string]interface{}, 2)
if executorType.Valid {
session.ExecutorSnapshot["executor_type"] = executorType.String
}
if executorName.Valid {
session.ExecutorSnapshot["executor_name"] = executorName.String
}
}
if agentProfileSnapshotJSON.Valid && agentProfileSnapshotJSON.String != "" {
if err := json.Unmarshal([]byte(agentProfileSnapshotJSON.String), &session.AgentProfileSnapshot); err != nil {
return nil, fmt.Errorf("failed to unmarshal agent profile snapshot for task %s: %w", taskID, err)
}
}
if repositorySnapshotJSON.Valid && repositorySnapshotJSON.String != "" {
if err := json.Unmarshal([]byte(repositorySnapshotJSON.String), &session.RepositorySnapshot); err != nil {
return nil, fmt.Errorf("failed to unmarshal repository snapshot for task %s: %w", taskID, err)
}
}
result[taskID] = session
}
return result, rows.Err()
}
// SetSessionPrimary marks a session as primary and clears primary flag on other sessions for the same task
func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) error {
now := time.Now().UTC()
// First, get the task_id for this session
var taskID string
err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id FROM task_sessions WHERE id = ?`), sessionID).Scan(&taskID)
if err == sql.ErrNoRows {
return fmt.Errorf("session not found: %s", sessionID)
}
if err != nil {
return err
}
// Clear primary flag on all sessions for this task
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET is_primary = 0, updated_at = ? WHERE task_id = ?
`), now, taskID)
if err != nil {
return err
}
// Set primary flag on the specified session
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET is_primary = 1, updated_at = ? WHERE id = ?
`), now, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("session not found: %s", sessionID)
}
return nil
}