a627957c5b
* refactor(task/repo): split sqlite/base.go (1486 lines) into schema/migrations/queries base.go grew to 1.85x the 800-line lint threshold by mixing the Repository struct, schema initialization, ALTER TABLE migrations, recreate-and-copy table rebuilds, and historical backfills/heals. Split along those seams: - base.go (58 lines) keeps only the Repository struct, NewWithDB, newRepository, Close, DB. - base_schema.go owns the initSchema orchestrator, every init*Schema function, ensure*Indexes, ensureRunnerProjectionTables, and the plan-revision backfill that runs as part of initPlansSchema. - base_migrations.go owns migrate*, recreateTable[Named], runMigrations, the backfillRow type, and every backfill*/heal* function. Verbatim function moves with no semantic changes. - base_queries.go owns sqlLimitClause (still referenced by plan.go, document.go, message.go). To keep golangci-lint (new: true) green after the move: - initSchema's 17 if-return chain becomes a for-each-step loop so cyclomatic complexity drops from 19 to within the 15 limit. - initInfraSchema and initSessionWorktreeSchema hoist their DDL strings into package-level consts (infraSchemaDDL, sessionWorktreeSchemaDDL) so each function body fits the 80-line funlen cap. Step ordering in initSchema is preserved exactly (backfillTaskEnvironments -> backfillTaskEnvironmentRepos -> healTaskEnvironmentWorkspacePaths -> healDuplicateTaskEnvironments -> ensureTaskEnvironmentTaskUniqueIndex -> healSessionTaskEnvironmentIDs) so existing prod DBs continue to migrate correctly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(task/repo): add BatchGetSessionsByTaskIDs to eliminate task-list N+1 Adds a single repository method that returns every task_session for a list of task IDs in one chunked IN (...) query, grouped by task_id. The workspace task-list endpoint previously needed three batched calls (GetPrimarySessionIDsByTaskIDs + GetSessionCountsByTaskIDs + GetPrimarySessionInfoByTaskIDs); with this loader the handler can derive primary session ID, session count, and per-task session info from a single round trip. - buildInPlaceholders and chunkIDs land in base_queries.go. sqliteMaxHostParams=500 keeps each chunk well below SQLite's compile-time SQLITE_MAX_VARIABLE_NUMBER (999 on older builds, 32766 on newer). - session.go gains BatchGetSessionsByTaskIDs, which reuses taskSessionSelectCols / scanTaskSessions / loadWorktreesBatch so the returned sessions are indistinguishable from ListTaskSessions output (worktrees populated, JSON snapshots decoded). Empty input short-circuits with a non-nil empty map. - SessionRepository interface and Service.BatchGetSessionsForTasks expose the new method to the handler layer. The three existing *ByTaskIDs methods are left in place because service_events.go still uses them for single-task event publishing. - mockRepository implementations updated in two test files so the packages still satisfy the interface. TestBatchGetSessionsByTaskIDs covers the empty-input short-circuit, the multi-task (3 x 2) case (primary derivation + no cross-task bleed), and the asked-for-but-missing case (absent from result map). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf(task/handlers): use BatchGetSessionsForTasks in task-list endpoint buildTaskDTOsWithSessionInfo previously issued three batched queries per request (GetPrimarySessionIDsForTasks + GetSessionCountsForTasks + GetPrimarySessionInfoForTasks). Each was already chunked by task_id, but together they were three round trips for data that can be derived from one. Collapse to a single BatchGetSessionsForTasks call and derive primary session ID, count, and per-task primary info in memory. The JSON response shape is unchanged: dto.FromTaskWithSessionInfo still receives the same pointer-typed arguments, primarySessionID stays nil when no primary exists, and sessionCount stays nil for tasks with zero sessions (matches the existing pointer-when-present contract that the frontend store hydration depends on). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task/handlers): restore JOIN-derived executor type/name in task-list response buildTaskDTOsWithSessionInfo regressed when it stopped calling GetPrimarySessionInfoForTasks: extractSessionInfo reads ExecutorSnapshot["executor_type"] and ExecutorSnapshot["executor_name"], but those keys exist only on the synthetic snapshot built by the GetPrimarySessionInfoByTaskIDs LEFT JOIN to executors. The persisted ExecutorSnapshot JSON uses different keys (e.g. "type" — see share/builder.go:96), so BatchGetSessionsForTasks alone always yielded empty executor_type / executor_name fields in the task-list response, breaking the frontend task card's executor badge. Fix (caught in code review): - Handler now calls both BatchGetSessionsForTasks (for primary session ID and session count) and GetPrimarySessionInfoForTasks (for the JOIN-derived executor type/name and other session info). Two queries instead of the original three; correctness preserved. - New TestGetPrimarySessionInfoByTaskIDs_PopulatesExecutorJoinFields seeds an executor + primary session, then asserts the JOIN populates ExecutorSnapshot["executor_type"] / ["executor_name"] on the returned session. Locks in the contract the handler depends on so a future refactor that drops the JOIN fails loudly instead of silently emitting empty fields. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task/repo): chunkIDs returns empty slice for empty input chunkIDs previously returned [][]string{nil} for an empty/nil input (a single chunk whose value is nil). The only current caller (BatchGetSessionsByTaskIDs) short-circuits empty input before reaching chunkIDs, so the bug stayed dormant — but any new caller that forgot the short-circuit would feed a nil chunk to buildInPlaceholders (which returns "" for nil), produce a `... WHERE task_id IN () ORDER BY ...` query, and hit a SQL syntax error at runtime. Make the helper safe by construction: an empty input returns nil (empty outer slice), so `range chunkIDs(empty, n)` iterates zero times. Add base_queries_test.go covering: - Empty/nil input → no chunks (locks the safety contract). - Single-pass input fits in one chunk. - Over-threshold input splits into correctly-sized, in-order chunks. - buildInPlaceholders normal + empty-input cases. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task/repo): propagate non-ErrNoRows errors in backfillSingleTask executor lookup `backfillSingleTask` looked up `executors.type` for the legacy executor referenced by a session and defaulted to `"local_pc"` on any error. That silently masked driver failures, schema mismatches, and corruption — every backfilled task_environment would get the wrong executor_type without surfacing the underlying problem. Narrow the default to the legitimate case: only `sql.ErrNoRows` (the executor row was actually deleted, e.g. legacy session whose executor is gone) falls back to `"local_pc"`. Any other scan error now aborts the migration with a wrapped error so operators see the real cause. Adds TestBackfillSingleTask_DefaultsExecutorTypeOnMissingRow which seeds a session referencing a non-existent executor id and asserts the backfill still completes with `executor_type = local_pc` — locks in the legitimate-fallback path. Addresses PR #1058 review feedback from cubic & claude. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task/repo): chunk ListWorktreesBySessionIDs to honor SQLite var limit ListWorktreesBySessionIDs built its IN-clause from every input session ID in one shot. BatchGetSessionsByTaskIDs chunks task IDs at 500 to stay below SQLITE_MAX_VARIABLE_NUMBER (999 on older builds), but its loadWorktreesBatch step then handed all sessions in that 500-task chunk to ListWorktreesBySessionIDs un-chunked — so at modest workloads (500 tasks × ≥2 sessions each = 1000 placeholders) the query fails with SQLITE_RANGE and bubbles up as a 404 on the task-list endpoint. ListWorktreesBySessionIDs now applies the same chunkIDs / 500 cap that BatchGet uses. Body extracted to appendWorktreesForSessionChunk so the public method stays under the 80-line funlen cap. New TestListWorktreesBySessionIDs_ChunksOverPlaceholderLimit feeds the method 1001 non-existent session IDs and asserts it returns cleanly — without chunking, this fails with SQLITE_RANGE on a default-built SQLite. Also splits TestBatchGetSessionsByTaskIDs (was 98 lines) into focused subtests + a shared seedBatchSessions helper, satisfying the test-size convention. The three sub-scenarios (empty input, multi-task grouping, missing-task absence) now fail in isolation. Addresses PR #1058 review feedback from greptile (P1) and claude (suggestion). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task/repo): port repositories.copy_files migration to split files Rebased onto main; main added copy_files to runMigrations() and initTaskSchema() in base.go while our branch had split those into base_migrations.go and base_schema.go. Cherry-pick both additions into the correct split files so the schema is consistent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1274 lines
46 KiB
Go
1274 lines
46 KiB
Go
// 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"
|
||
)
|
||
|
||
// 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"
|
||
|
||
// 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()
|
||
|
||
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 := r.db.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
|
||
}
|
||
|
||
// 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 := json.Marshal(metadata)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to serialize metadata: %w", err)
|
||
}
|
||
now := time.Now().UTC()
|
||
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||
UPDATE task_sessions SET metadata = ?, updated_at = ? WHERE id = ?
|
||
`), string(metadataJSON), now, 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
|
||
}
|
||
|
||
// 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
|
||
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
|
||
WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
|
||
AND tr.repository_id = ?
|
||
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
|
||
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
|
||
WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
|
||
AND tr.repository_id = ?
|
||
`), 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
|
||
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)
|
||
return r.scanTaskSession(ctx, row, fmt.Sprintf("no primary session found for task: %s", taskID))
|
||
}
|
||
|
||
// 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
|
||
}
|