Files
kandev/apps/backend/internal/task/repository/sqlite/base_migrations.go
T
João Salavisa 57adf1449c feat(review): native code-review agent with inline findings in the Changes panel (#1957)
* feat(review): persist and publish native code-review findings

Adds the backend core for reviewing a task's working diff before a PR
exists: task-scoped review runs and anchored, advisory findings that the
Changes/Review panel can render as inline comments.

- task_review_runs / task_review_findings schema, models, and repository,
  with supersede-on-re-review and a restart sweep that cancels in-flight
  runs rather than leaving them looking alive.
- ReviewService as the single write path, publishing bus events that the
  WS gateway forwards to the client.
- internal/review: reviewer-identity resolution (agent profile > code-review
  utility agent > user default, failing closed on passthrough-only profiles),
  change-set collection, prompt batching, response parsing, anchor
  extraction, and run orchestration.
- publish_review_findings_kandev MCP tool plus the task.review.* WS actions.
- Go djb2 that matches apps/web/lib/utils/hash.ts, asserted from shared
  fixtures on both sides so stale detection agrees across the wire.

Spec: docs/specs/native-code-review/spec.md
Plan: docs/plans/native-code-review/plan.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(review): add run_code_review workflow step and wire the review runner

Makes a review pass triggerable both ways and actually runnable end to end.

- run_code_review on_enter action across models, engine compile, and an
  orchestrator callback that logs rather than propagates failures, so a task
  never gets stuck in a step because no reviewer was configured.
- The step's optional agent_profile_id is what lets a step review with a
  different model than it implemented with; export/import swap it for a
  portable agent name/model/mode so it survives a cross-workspace round trip
  instead of carrying a dangling id.
- Concrete adapters for reviewer identity, changed-file collection, prompt
  rendering from the code-review utility agent's editable prompt, and
  inference (session-bound agentctl, falling back to the host instance so a
  review works on a task whose agent is not running).
- Boot sweep cancels review runs interrupted by a restart; the E2E reset
  clears review state before deleting the tasks that own it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): add review findings state, helpers, and live updates

Frontend plumbing for native code review, ahead of the UI that renders it.

- Review types, WS message map, and an API client that preserves the backend's
  machine-readable failure code so the surface can show an actionable message.
- A review store slice that merges findings by id (a re-publish refreshes an
  issue rather than listing it twice) and keeps a bounded run history.
- Findings helpers: composite file keys matching reviewFileKey, staleness via
  the same per-file diff hash that drives review marks, and best-effort
  re-anchoring from the stored anchor text so a moved finding follows the code
  it was written about instead of pointing at whatever now occupies its line.
- Hooks for backfill, resolve/dismiss/reopen with optimistic rollback, and
  sending a finding to the agent as follow-up context without changing its
  status — handing a finding to an agent is not the human accepting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): render review findings inline in the Changes diff

Findings now appear where the code is, in the same annotation surface as
anchored review comments, with the run control that produces them.

- ReviewFindingCard: severity chip, category, Markdown body, and the three
  advisory actions. A suggestion is labelled "not applied automatically"
  because it is display-only in this iteration. Resolved and dismissed
  findings collapse to one muted line with an Undo, so a reviewed diff does
  not stay cluttered while the human can still change their mind.
- A review-finding annotation type threaded through the diff viewer, anchored
  from the same per-file diff hash the backend stamped. A finding whose diff
  moved is re-anchored from its stored text when that text survives, and
  otherwise reported as unanchored rather than placed against unrelated code.
- Review changes control with idle / running / cancel / failed states, and an
  inline Settings link for the one failure the user can actually fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): make the review control reachable from the Review toolbar

Mounts the Review changes control next to the walkthrough action and lets an
inline finding wire its own actions.

The finding card container now reads the active task and session from the store
instead of taking four callbacks threaded through FileDiffViewer and DiffViewer;
an inline finding annotation only exists when the active task has findings, so
the store is always present and every other diff consumer is spared the
plumbing. Staleness likewise hashes the diff the viewer already holds, which is
already normalized, so it matches the hash the backend stamped without passing
it down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): surface stale review findings above their file's diff

A finding whose diff moved and whose anchor text is gone had nowhere to render,
so it was silently dropped. It now appears in a collapsed per-file banner above
the diff: the finding may still be valid, and placing it at its stored line
would point at whatever code now occupies that line instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(review): mark the landed native code-review plan tasks done

Tasks 01-09 (backend persistence through inline diff rendering) are
implemented, tested, and committed. Tasks 10-13 — findings overview and step
editor, mobile parity, E2E, and public docs — remain pending.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(review): cover the on-demand review path end to end

Adds the mock-agent review responder and a Playwright spec, so both the happy
path and the no-reviewer path are verified against a real backend.

The mock agent answers any prompt carrying the review sentinel with fenced JSON,
deriving its anchors from the prompt's own diff rather than hardcoding them, so
findings land on files and lines the fixture really changed. It includes one
deliberately malformed entry to exercise the rejected-entry path.

Writing it caught an off-by-one shared by three diff walkers: splitting on "\n"
leaves an empty element after a trailing newline, which carries no diff marker
and was being counted as a context line, pushing new-side numbering one past the
end of a hunk. Interior blank lines still count, since a whitespace-stripped
blank context line legitimately appears as "".

Also adds a listInferenceAgents helper: default_utility_agent_id needs the
registered agent-type id, and an agent row UUID is rejected as not
inference-capable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(review): close review-run races and stale client findings

Addresses the three blockers and three suggestions from the PR #1957 automated
reviews. Two independent reviewers found each blocker separately.

- Launch claimed the task slot only after inserting the run row, so two
  concurrent launches both created a run and the loser returned a pending row
  with no goroutine behind it — a review that never finished until the next
  restart. The slot is now claimed before any DB write, and freed on every
  early return.
- Cancelling only marked the row. The goroutine ran on the shared background
  context, finished inference, and its completion overwrote the cancelled
  status and published findings the user had declined. Runs now carry a cancel
  handle, the WS action routes through the runner so the live context is
  cancelled, CompleteRun/FailRun refuse to resurrect a terminal run, and a
  cancelled pass neither publishes nor re-reports itself as failed.
- The backend deletes a superseded finding and inserts a replacement with a new
  id, but the publish event carried only the new one, so every open browser
  showed both at the same anchor until a reload. The event now names the
  superseded ids and the slice drops them.
- Run() waited on the shared WaitGroup, blocking on every other task's review;
  it now waits on its own pass only.
- createReviewSlice is typed over the `set` it actually uses, so call sites no
  longer pass superfluous arguments (CodeQL js/superfluous-trailing-arguments).
- A GetAgentProfile fault no longer collapses into "profile not found", which
  would have told the user to configure an agent they already have.

The cancel bug exposed a hole in the original tests: they asserted cancel could
not override a terminal run, but never the reverse. Both directions are covered
now, along with the concurrent-launch race, per-run waiting, and the supersede
path on both sides of the wire. Test fakes now mirror the service's terminal
guard rather than hiding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:05:56 +01:00

1036 lines
45 KiB
Go

// Package sqlite provides SQLite-based repository implementations.
package sqlite
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/db/dialect"
)
// migrateExecutorProfiles adds mcp_policy column and drops is_default from executor_profiles.
func (r *Repository) migrateExecutorProfiles() error {
r.migrate.Apply("executor_profiles.mcp_policy", `ALTER TABLE executor_profiles ADD COLUMN mcp_policy TEXT DEFAULT ''`)
// Drop is_default column - SQLite doesn't support DROP COLUMN before 3.35.0,
// so we just ignore the old column if present. New schema omits it.
return nil
}
// migrateTaskSessions adds new columns to task_sessions.
func (r *Repository) migrateTaskSessions() error {
r.migrate.Apply("task_sessions.executor_profile_id", `ALTER TABLE task_sessions ADD COLUMN executor_profile_id TEXT DEFAULT ''`)
return nil
}
// migrateSessionsAddCostColumns backfills the per-session cost/token columns on
// task_sessions. These are otherwise only introduced inside two gated
// CREATE-TABLE statements — the fresh create (a no-op once the table exists) and
// the agent_execution_id-drop rebuild (gated on that column still being present).
// A DB that no longer contains the rebuild trigger columns would never gain the
// cost columns, breaking the office cost subscriber's IncrementTaskSessionUsage
// with "no such column: tokens_in". These additive ALTERs are idempotent — the
// MigrateLogger swallows "duplicate column name" on DBs that already have them.
func (r *Repository) migrateSessionsAddCostColumns() {
r.migrate.Apply("task_sessions.cost_subcents", `ALTER TABLE task_sessions ADD COLUMN cost_subcents INTEGER NOT NULL DEFAULT 0`)
r.migrate.Apply("task_sessions.tokens_in", `ALTER TABLE task_sessions ADD COLUMN tokens_in INTEGER NOT NULL DEFAULT 0`)
r.migrate.Apply("task_sessions.tokens_out", `ALTER TABLE task_sessions ADD COLUMN tokens_out INTEGER NOT NULL DEFAULT 0`)
}
// runMigrations applies idempotent ALTER TABLE migrations for schema evolution.
func (r *Repository) runMigrations() error {
if err := r.ensureTaskWorkspaceFoldersSchema(); err != nil {
return err
}
r.migrate.Apply("task_sessions.execution_profile_id", `ALTER TABLE task_sessions ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`)
r.migrate.Apply("executors_running.execution_profile_id", `ALTER TABLE executors_running ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`)
r.migrate.Apply("executors_running.last_message_uuid", `ALTER TABLE executors_running ADD COLUMN last_message_uuid TEXT DEFAULT ''`)
r.migrate.Apply("executors_running.metadata", `ALTER TABLE executors_running ADD COLUMN metadata TEXT DEFAULT '{}'`)
// local_pid holds a host-local liveness handle (the standalone agentctl
// control-server PID Kandev spawns) for local/standalone rows. It is kept
// deliberately separate from the SSH-only `pid` column, which holds an
// agentctl PID on the *remote* host. See ADR 0025 / issue #1597.
r.migrate.Apply("executors_running.local_pid", `ALTER TABLE executors_running ADD COLUMN local_pid INTEGER DEFAULT 0`)
r.migrate.Apply("tasks.is_ephemeral", `ALTER TABLE tasks ADD COLUMN is_ephemeral INTEGER NOT NULL DEFAULT 0`)
r.migrate.Apply("task_repositories.checkout_branch", `ALTER TABLE task_repositories ADD COLUMN checkout_branch TEXT DEFAULT ''`)
// Multi-branch support: drop the old UNIQUE(task_id, repository_id) and
// replace it with UNIQUE(task_id, repository_id, checkout_branch) so the
// same repo can appear multiple times in a task on different branches.
if err := r.migrateTaskRepositoriesAllowMultiBranch(); err != nil {
return err
}
r.migrate.Apply("task_sessions.base_commit_sha", `ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`)
r.migrate.Apply("workspaces.default_config_agent_profile_id", `ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`)
r.migrate.Apply("task_sessions.task_environment_id", `ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`)
r.migrate.Apply("task_session_worktrees.branch_slug", `ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`)
r.migrate.Apply("tasks.parent_id", `ALTER TABLE tasks ADD COLUMN parent_id TEXT DEFAULT ''`)
// Remove FK constraint on workflow_id to allow ephemeral tasks without workflows
if err := r.migrateTasksRemoveWorkflowFK(); err != nil {
return err
}
// Remove deprecated workflow_step_id column from task_sessions
if err := r.migrateSessionsRemoveWorkflowStepID(); err != nil {
return err
}
// Backfill executors_running from task_sessions and drop the denormalized
// agent_execution_id / container_id columns. After this migration,
// executors_running is the single source of truth for "active execution per
// session" - see persistence.go in the lifecycle package for the new ownership
// model. Order matters: backfill must run BEFORE the column drop.
if err := r.backfillExecutorsRunningFromTaskSessions(); err != nil {
return err
}
if err := r.migrateSessionsRemoveAgentExecutionID(); err != nil {
return err
}
// Must run BEFORE migrateTaskEnvironmentsRemoveAgentExecutionID, which copies task_dir_name into the recreated table.
r.migrate.Apply("task_environments.task_dir_name", `ALTER TABLE task_environments ADD COLUMN task_dir_name TEXT DEFAULT ''`)
if err := r.migrateTaskEnvironmentsRemoveAgentExecutionID(); err != nil {
return err
}
if err := r.migrateTaskEnvironmentReposAllowMultiBranch(); err != nil {
return err
}
r.migrate.Apply("workflows.sort_order", `ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`)
r.migrate.Apply("workflows.agent_profile_id", `ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`)
r.migrate.Apply("workflows.hidden", `ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`)
r.migrate.Apply("task_sessions.workspace_path", `ALTER TABLE task_sessions ADD COLUMN workspace_path TEXT DEFAULT ''`)
// User-supplied session tab label. Must run after the
// migrateSessionsRemoveAgentExecutionID rebuild above — that rebuild
// recreates task_sessions from an explicit column list and would drop a
// column added earlier.
r.migrate.Apply("task_sessions.name", `ALTER TABLE task_sessions ADD COLUMN name TEXT DEFAULT ''`)
r.migrate.Apply("repositories.copy_files", `ALTER TABLE repositories ADD COLUMN copy_files TEXT DEFAULT ''`)
r.migrate.Apply("repositories.remote_url", `ALTER TABLE repositories ADD COLUMN remote_url TEXT DEFAULT ''`)
r.migrate.Apply("repositories.provider_host", `ALTER TABLE repositories ADD COLUMN provider_host TEXT DEFAULT ''`)
r.migrate.Apply("repositories.provider_host.github_backfill", `
UPDATE repositories
SET provider_host = 'https://github.com'
WHERE LOWER(TRIM(provider)) = 'github'
AND TRIM(COALESCE(provider_host, '')) = ''`)
r.migrate.Apply("repositories.worktree_branch_template", `ALTER TABLE repositories ADD COLUMN worktree_branch_template TEXT DEFAULT 'feature/{title}-{suffix}'`)
r.migrate.Apply("repositories.worktree_branch_template.backfill", `UPDATE repositories SET worktree_branch_template = COALESCE(NULLIF(TRIM(worktree_branch_prefix), ''), 'feature/') || '{title}-{suffix}'`)
r.migrate.Apply("task_plans.implementation_started_at", `ALTER TABLE task_plans ADD COLUMN implementation_started_at TIMESTAMP`)
r.migrate.Apply("task_plans.implementation_started_session_id", `ALTER TABLE task_plans ADD COLUMN implementation_started_session_id TEXT`)
r.migrate.Apply("task_plans.implementation_started_by", `ALTER TABLE task_plans ADD COLUMN implementation_started_by TEXT`)
// Authoritative per-message change signal (chat render-perf). SQLite forbids a
// non-constant default on ADD COLUMN, so the column is added nullable and
// existing rows are backfilled to created_at; new inserts/updates set it
// explicitly in CreateMessage/UpdateMessage. The backfill UPDATE is idempotent
// (WHERE updated_at IS NULL).
r.migrate.Apply("task_session_messages.updated_at", `ALTER TABLE task_session_messages ADD COLUMN updated_at TIMESTAMP`)
r.migrate.Apply("task_session_messages.updated_at.backfill", `UPDATE task_session_messages SET updated_at = created_at WHERE updated_at IS NULL`)
r.migrate.Apply("idx_messages_session_updated", `CREATE INDEX IF NOT EXISTS idx_messages_session_updated ON task_session_messages(task_session_id, updated_at)`)
// Backfill the per-session cost/token columns. Runs after the gated
// task_sessions rebuilds above so it repairs legacy DBs whose schema can no
// longer trigger a rebuild (see migrateSessionsAddCostColumns).
r.migrateSessionsAddCostColumns()
// Office task extensions - net-new columns on existing main tables.
// Idempotent ALTERs; main upgrades pick them up at first boot.
// The transient in-branch columns (requires_approval,
// execution_policy, execution_state, assignee_agent_profile_id,
// task_sessions.agent_instance_id) were never on main and are
// therefore not added or dropped here.
r.migrate.Apply("tasks.origin", `ALTER TABLE tasks ADD COLUMN origin TEXT DEFAULT 'manual'`)
r.migrate.Apply("tasks.project_id", `ALTER TABLE tasks ADD COLUMN project_id TEXT DEFAULT ''`)
r.migrate.Apply("tasks.labels", `ALTER TABLE tasks ADD COLUMN labels TEXT DEFAULT '[]'`)
r.migrate.Apply("tasks.identifier", `ALTER TABLE tasks ADD COLUMN identifier TEXT`)
// Office task-handoffs phase 6 - tag tasks archived as part of a cascade so
// unarchive can restore exactly the descendants that cascade archived.
r.migrate.Apply("tasks.archived_by_cascade_id", `ALTER TABLE tasks ADD COLUMN archived_by_cascade_id TEXT DEFAULT ''`)
// Office workspace extensions
r.migrate.Apply("workspaces.task_prefix", `ALTER TABLE workspaces ADD COLUMN task_prefix TEXT DEFAULT 'KAN'`)
r.migrate.Apply("workspaces.task_sequence", `ALTER TABLE workspaces ADD COLUMN task_sequence INTEGER DEFAULT 0`)
r.migrate.Apply("workspaces.office_workflow_id", `ALTER TABLE workspaces ADD COLUMN office_workflow_id TEXT DEFAULT ''`)
// Office session cost tracking extensions are declared in
// initSessionWorktreeSchema's CREATE TABLE (cost_subcents, tokens_in,
// tokens_out). task_sessions.agent_profile_id existed on main as
// NOT NULL; migrateSessionsRemoveAgentExecutionID rebuilds the table
// with the column nullable and the cost columns added.
r.migrate.Apply("workflows.is_system", `ALTER TABLE workflows ADD COLUMN is_system INTEGER DEFAULT 0`)
// Phase 2 (ADR-0004) - workflows.style is a UX hint for the frontend
// ("kanban" | "office" | "custom"). Backend code MUST NOT branch on
// this value. Idempotent ALTER; default "kanban" preserves the current
// presentation for existing workflows.
r.migrate.Apply("workflows.style", `ALTER TABLE workflows ADD COLUMN style TEXT NOT NULL DEFAULT 'kanban'`)
// Workflow-sync provenance: which system owns the workflow definition
// ("manual" | "github") and, for synced workflows, the repo-relative
// file path it was synced from.
r.migrate.Apply("workflows.source", `ALTER TABLE workflows ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
r.migrate.Apply("workflows.source_path", `ALTER TABLE workflows ADD COLUMN source_path TEXT NOT NULL DEFAULT ''`)
// Native code review — indexes for the task_review_* tables. Declared here
// rather than in initTaskReviewSchema because schema init is a no-op on an
// existing DB (see AGENTS.md "Schema & migrations").
r.migrate.Apply("idx_task_review_runs_task", `CREATE INDEX IF NOT EXISTS idx_task_review_runs_task ON task_review_runs(task_id, created_at)`)
r.migrate.Apply("idx_task_review_findings_run", `CREATE INDEX IF NOT EXISTS idx_task_review_findings_run ON task_review_findings(run_id)`)
r.migrate.Apply("idx_task_review_findings_task_status", `CREATE INDEX IF NOT EXISTS idx_task_review_findings_task_status ON task_review_findings(task_id, status)`)
r.migrate.Apply("idx_task_review_findings_anchor", `CREATE INDEX IF NOT EXISTS idx_task_review_findings_anchor ON task_review_findings(task_id, repository_name, file_path)`)
// ADR 0005 Wave F — ensure the runner-projection tables exist so
// task SELECTs that reference them via correlated subquery don't
// fail. Required for tests and any environment where the workflow
// repo hasn't run yet.
r.ensureRunnerProjectionTables()
return nil
}
// ensureTaskWorkspaceFoldersSchema upgrades databases created before durable
// folder attachments existed. CREATE TABLE/INDEX IF NOT EXISTS is replay-safe
// on SQLite and Postgres.
func (r *Repository) ensureTaskWorkspaceFoldersSchema() error {
if _, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS task_workspace_folders (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
local_path TEXT NOT NULL,
display_name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
UNIQUE(task_id, local_path),
UNIQUE(task_id, display_name)
);
CREATE INDEX IF NOT EXISTS idx_task_workspace_folders_task_position
ON task_workspace_folders(task_id, position);
`); err != nil {
return fmt.Errorf("create task workspace folders schema: %w", err)
}
return nil
}
// recreateTable checks whether tableName's DDL contains triggerPhrase and, if so,
// runs statements inside a transaction with FK enforcement disabled.
// This is the standard SQLite pattern for dropping columns or FK constraints,
// since SQLite has no ALTER TABLE DROP COLUMN / DROP CONSTRAINT.
// Note: PRAGMA statements cannot run inside a transaction in SQLite, so FK enforcement
// is toggled outside the transaction. The writer pool must have MaxOpenConns(1) so that
// the PRAGMA and the subsequent transaction use the same connection.
// Returns true if the migration actually ran (gate fired), false if it was a no-op.
func (r *Repository) recreateTable(tableName, triggerPhrase string, statements []string) (bool, error) {
if dialect.IsPostgres(r.db.DriverName()) {
return false, nil
}
var tableSql string
err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`, tableName).Scan(&tableSql)
if errors.Is(err, sql.ErrNoRows) {
return false, nil // Table doesn't exist yet; migration not applicable
}
if err != nil {
return false, fmt.Errorf("query %s schema: %w", tableName, err)
}
if !strings.Contains(tableSql, triggerPhrase) {
return false, nil // Trigger phrase absent; migration already applied or not needed
}
if _, err := r.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
return false, fmt.Errorf("disable foreign keys: %w", err)
}
defer func() { _, _ = r.db.Exec(`PRAGMA foreign_keys=ON`) }()
tx, err := r.db.Beginx()
if err != nil {
return false, fmt.Errorf("begin migration transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
return false, fmt.Errorf("migration %s failed: %w", tableName, err)
}
}
if err := tx.Commit(); err != nil {
return false, fmt.Errorf("commit migration transaction: %w", err)
}
return true, nil
}
// recreateTableNamed wraps recreateTable and logs "migration applied" when the
// gate fires (trigger phrase found and statements ran).
func (r *Repository) recreateTableNamed(name, tableName, triggerPhrase string, statements []string) error {
fired, err := r.recreateTable(tableName, triggerPhrase, statements)
if err != nil {
return err
}
if fired && r.log != nil {
r.log.Info("migration applied", zap.String("name", name))
}
return nil
}
// migrateTasksRemoveWorkflowFK removes the foreign key constraint on workflow_id
// to allow ephemeral tasks (quick chat) to have empty workflow_id.
func (r *Repository) migrateTasksRemoveWorkflowFK() error {
return r.recreateTableNamed("tasks.recreate_drop_workflow_fk", "tasks", "FOREIGN KEY (workflow_id)", []string{
`CREATE TABLE tasks_new (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
workflow_id TEXT NOT NULL DEFAULT '',
workflow_step_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
description TEXT DEFAULT '',
state TEXT DEFAULT 'TODO',
priority INTEGER DEFAULT 0,
position INTEGER DEFAULT 0,
metadata TEXT DEFAULT '{}',
is_ephemeral INTEGER NOT NULL DEFAULT 0,
parent_id TEXT DEFAULT '',
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)`,
`INSERT INTO tasks_new SELECT
id, workspace_id, workflow_id, workflow_step_id, title, description,
state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at
FROM tasks`,
`DROP TABLE tasks`,
`ALTER TABLE tasks_new RENAME TO tasks`,
`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)`,
})
}
// backfillExecutorsRunningFromTaskSessions creates an executors_running row for
// any session that has a non-empty task_sessions.agent_execution_id but no matching
// executors_running row. This preserves the data we're about to drop from
// task_sessions in the canonical executors_running table.
//
// Sessions with empty agent_execution_id are skipped intentionally — they were
// never launched (e.g. CREATED state, PR-watcher review tasks), and the new
// invariant says "executors_running row exists iff session was launched".
//
// Idempotent: rows that already exist on either side are left untouched.
func (r *Repository) backfillExecutorsRunningFromTaskSessions() error {
if dialect.IsPostgres(r.db.DriverName()) {
return nil
}
// Check whether task_sessions still has the column. If migration already ran,
// the column is gone and there's nothing to backfill.
var tableSql string
if err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='task_sessions'`).Scan(&tableSql); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return fmt.Errorf("backfill executors_running: read schema: %w", err)
}
if !strings.Contains(tableSql, "agent_execution_id") {
return nil
}
now := time.Now().UTC().Format(time.RFC3339Nano)
// SELECT … LEFT JOIN to find sessions with execution data but no executors_running row.
// Insert with the minimum field set; runtime/status are best-effort defaults
// (subsequent Launch / Resume will overwrite via the lifecycle manager's persistence).
if _, err := r.db.Exec(`
INSERT INTO executors_running (
id, session_id, task_id, executor_id, runtime, status, resumable,
resume_token, last_message_uuid, agent_execution_id, container_id,
agentctl_url, agentctl_port, pid, worktree_id, worktree_path, worktree_branch,
error_message, metadata, created_at, updated_at
)
-- executors_running.id mirrors session_id (both columns must hold the same UUID
-- so the row is self-referential by design — the dupword linter complaint is
-- a false positive on the SQL projection list).
SELECT
ts.id AS er_id,
ts.id AS er_session_id,
ts.task_id, ts.executor_id, '', 'unknown', 1,
'', '', ts.agent_execution_id, ts.container_id,
'', 0, 0, '', '', '',
'', '{}', ts.started_at, ?
FROM task_sessions ts
LEFT JOIN executors_running er ON er.session_id = ts.id
WHERE COALESCE(ts.agent_execution_id, '') != '' AND er.id IS NULL
`, now); err != nil {
return fmt.Errorf("backfill executors_running: %w", err)
}
return nil
}
// migrateSessionsRemoveAgentExecutionID drops the agent_execution_id and
// container_id columns from task_sessions. After this migration, executors_running
// is the single source of truth for both fields — no more denormalization.
//
// Must run after backfillExecutorsRunningFromTaskSessions so any data we're about
// to drop is preserved on the executors_running side.
//
// The trigger phrase "agent_execution_id" detects when the migration hasn't yet
// run (column still present); recreateTable is a no-op once the column is gone.
func (r *Repository) migrateSessionsRemoveAgentExecutionID() error {
return r.recreateTableNamed("task_sessions.recreate_drop_agent_execution_id", "task_sessions", "agent_execution_id", []string{
`CREATE TABLE task_sessions_new (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
agent_profile_id TEXT,
execution_profile_id TEXT NOT NULL DEFAULT '',
executor_id TEXT DEFAULT '',
executor_profile_id TEXT DEFAULT '',
environment_id TEXT DEFAULT '',
repository_id TEXT DEFAULT '',
base_branch TEXT DEFAULT '',
agent_profile_snapshot TEXT DEFAULT '{}',
executor_snapshot TEXT DEFAULT '{}',
environment_snapshot TEXT DEFAULT '{}',
repository_snapshot TEXT DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'CREATED',
error_message TEXT DEFAULT '',
metadata TEXT DEFAULT '{}',
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL,
is_primary INTEGER DEFAULT 0,
is_passthrough INTEGER DEFAULT 0,
review_status TEXT DEFAULT '',
base_commit_sha TEXT DEFAULT '',
task_environment_id TEXT DEFAULT '',
cost_subcents INTEGER NOT NULL DEFAULT 0,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
)`,
`INSERT INTO task_sessions_new SELECT
id, task_id, agent_profile_id, execution_profile_id,
executor_id, executor_profile_id, environment_id, repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, is_passthrough, review_status,
COALESCE(base_commit_sha, ''), COALESCE(task_environment_id, ''),
0, 0, 0
FROM task_sessions`,
`DROP TABLE task_sessions`,
`ALTER TABLE task_sessions_new RENAME TO task_sessions`,
`CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state)`,
`CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state)`,
})
}
// migrateTaskEnvironmentsRemoveAgentExecutionID drops the agent_execution_id
// column from task_environments. Like task_sessions, this column was a stale
// denormalized copy that drifted from the in-memory store. The orchestrator
// now reads execution state from executors_running only.
func (r *Repository) migrateTaskEnvironmentsRemoveAgentExecutionID() error {
return r.recreateTableNamed("task_environments.recreate_drop_agent_execution_id", "task_environments", "agent_execution_id", []string{
`CREATE TABLE task_environments_new (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
repository_id TEXT DEFAULT '',
executor_type TEXT NOT NULL DEFAULT '',
executor_id TEXT DEFAULT '',
executor_profile_id TEXT DEFAULT '',
control_port INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'creating',
worktree_id TEXT DEFAULT '',
worktree_path TEXT DEFAULT '',
worktree_branch TEXT DEFAULT '',
workspace_path TEXT DEFAULT '',
container_id TEXT DEFAULT '',
sandbox_id TEXT DEFAULT '',
task_dir_name TEXT DEFAULT '',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
)`,
`INSERT INTO task_environments_new SELECT
id, task_id, repository_id, executor_type, executor_id, executor_profile_id,
control_port, status, worktree_id, worktree_path, worktree_branch,
workspace_path, container_id, sandbox_id,
COALESCE(task_dir_name, ''), created_at, updated_at
FROM task_environments`,
`DROP TABLE task_environments`,
`ALTER TABLE task_environments_new RENAME TO task_environments`,
`CREATE INDEX IF NOT EXISTS idx_task_environments_task_id ON task_environments(task_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_environments_status ON task_environments(status)`,
// uniq_task_environments_task_id is created by ensureTaskEnvironmentTaskUniqueIndex
// AFTER healDuplicateTaskEnvironments collapses any pre-existing duplicates.
// Creating it here would fail on databases that still have duplicate task_id rows.
})
}
func (r *Repository) migrateTaskEnvironmentReposAllowMultiBranch() error {
if dialect.IsPostgres(r.db.DriverName()) {
return r.migrateTaskEnvironmentReposAllowMultiBranchPostgres()
}
return r.recreateTableNamed(
"task_environment_repos.recreate_allow_multi_branch",
"task_environment_repos",
"UNIQUE(task_environment_id, repository_id)",
[]string{
`CREATE TABLE task_environment_repos_new (
id TEXT PRIMARY KEY,
task_environment_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
branch_slug TEXT NOT NULL DEFAULT '',
worktree_id TEXT DEFAULT '',
worktree_path TEXT DEFAULT '',
worktree_branch TEXT DEFAULT '',
position INTEGER DEFAULT 0,
error_message TEXT DEFAULT '',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE,
UNIQUE(task_environment_id, repository_id, branch_slug)
)`,
`INSERT INTO task_environment_repos_new SELECT
id, task_environment_id, repository_id, '',
worktree_id, worktree_path, worktree_branch,
position, error_message, created_at, updated_at
FROM task_environment_repos`,
`DROP TABLE task_environment_repos`,
`ALTER TABLE task_environment_repos_new RENAME TO task_environment_repos`,
`CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id)`,
},
)
}
func (r *Repository) migrateTaskEnvironmentReposAllowMultiBranchPostgres() error {
if _, err := r.db.Exec(`ALTER TABLE task_environment_repos ADD COLUMN IF NOT EXISTS branch_slug TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("add task_environment_repos.branch_slug: %w", err)
}
if _, err := r.db.Exec(`
DO $$
DECLARE
old_constraint_name text;
BEGIN
SELECT con.conname INTO old_constraint_name
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
WHERE rel.relname = 'task_environment_repos'
AND nsp.nspname = current_schema()
AND con.contype = 'u'
AND (
SELECT array_agg(attr.attname::text ORDER BY cols.ordinality)
FROM unnest(con.conkey) WITH ORDINALITY AS cols(attnum, ordinality)
JOIN pg_attribute attr ON attr.attrelid = con.conrelid AND attr.attnum = cols.attnum
) = ARRAY['task_environment_id', 'repository_id'];
IF old_constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE task_environment_repos DROP CONSTRAINT %I', old_constraint_name);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
WHERE rel.relname = 'task_environment_repos'
AND nsp.nspname = current_schema()
AND con.contype = 'u'
AND (
SELECT array_agg(attr.attname::text ORDER BY cols.ordinality)
FROM unnest(con.conkey) WITH ORDINALITY AS cols(attnum, ordinality)
JOIN pg_attribute attr ON attr.attrelid = con.conrelid AND attr.attnum = cols.attnum
) = ARRAY['task_environment_id', 'repository_id', 'branch_slug']
) THEN
ALTER TABLE task_environment_repos
ADD CONSTRAINT task_environment_repos_env_repo_branch_key
UNIQUE (task_environment_id, repository_id, branch_slug);
END IF;
END $$;
`); err != nil {
return fmt.Errorf("migrate task_environment_repos unique constraint: %w", err)
}
return nil
}
// migrateTaskRepositoriesAllowMultiBranch swaps the legacy
// UNIQUE(task_id, repository_id) constraint on task_repositories for
// UNIQUE(task_id, repository_id, base_branch, checkout_branch). The wider
// key lets the same repo coexist on N branches per task, including the
// worktree-executor case where the branch lives in base_branch and
// checkout_branch is empty. The trigger phrase matches both the legacy
// two-column constraint and the intermediate three-column variant added in
// the first multi-branch landing; the recreate becomes a no-op once the
// four-column constraint is in place.
func (r *Repository) migrateTaskRepositoriesAllowMultiBranch() error {
if err := r.recreateTaskRepositoriesForMultiBranch("UNIQUE(task_id, repository_id)\n"); err != nil {
return err
}
return r.recreateTaskRepositoriesForMultiBranch("UNIQUE(task_id, repository_id, checkout_branch)")
}
func (r *Repository) recreateTaskRepositoriesForMultiBranch(trigger string) error {
return r.recreateTableNamed(
"task_repositories.recreate_allow_multi_branch",
"task_repositories",
trigger,
[]string{
`CREATE TABLE task_repositories_new (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
base_branch TEXT DEFAULT '',
checkout_branch TEXT DEFAULT '',
position INTEGER DEFAULT 0,
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
UNIQUE(task_id, repository_id, base_branch, checkout_branch)
)`,
`INSERT INTO task_repositories_new SELECT
id, task_id, repository_id, base_branch,
COALESCE(checkout_branch, ''),
position, metadata, created_at, updated_at
FROM task_repositories`,
`DROP TABLE task_repositories`,
`ALTER TABLE task_repositories_new RENAME TO task_repositories`,
`CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id)`,
},
)
}
// migrateSessionsRemoveWorkflowStepID removes the deprecated workflow_step_id column
// from task_sessions. Workflow step is now tracked on the task, not the session.
func (r *Repository) migrateSessionsRemoveWorkflowStepID() error {
return r.recreateTableNamed("task_sessions.recreate_drop_workflow_step_id", "task_sessions", "workflow_step_id", []string{
`CREATE TABLE task_sessions_new (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
agent_execution_id TEXT NOT NULL DEFAULT '',
container_id TEXT NOT NULL DEFAULT '',
agent_profile_id TEXT,
execution_profile_id TEXT NOT NULL DEFAULT '',
executor_id TEXT DEFAULT '',
executor_profile_id TEXT DEFAULT '',
environment_id TEXT DEFAULT '',
repository_id TEXT DEFAULT '',
base_branch TEXT DEFAULT '',
agent_profile_snapshot TEXT DEFAULT '{}',
executor_snapshot TEXT DEFAULT '{}',
environment_snapshot TEXT DEFAULT '{}',
repository_snapshot TEXT DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'CREATED',
error_message TEXT DEFAULT '',
metadata TEXT DEFAULT '{}',
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL,
is_primary INTEGER DEFAULT 0,
is_passthrough INTEGER DEFAULT 0,
review_status TEXT DEFAULT '',
base_commit_sha TEXT DEFAULT '',
task_environment_id TEXT DEFAULT '',
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
)`,
`INSERT INTO task_sessions_new SELECT
id, task_id, agent_execution_id, container_id, agent_profile_id, execution_profile_id,
executor_id, executor_profile_id, environment_id, repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, is_passthrough, review_status,
COALESCE(base_commit_sha, ''), COALESCE(task_environment_id, '')
FROM task_sessions`,
`DROP TABLE task_sessions`,
`ALTER TABLE task_sessions_new RENAME TO task_sessions`,
`CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state)`,
`CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state)`,
})
}
type backfillRow struct {
taskID, executorID, executorProfileID string
repositoryID, containerID string
startedAt string
}
// backfillTaskEnvironments creates TaskEnvironment records for historical tasks
// that have sessions but no environment, and links orphaned sessions.
// Idempotent: tasks with existing environments are skipped.
func (r *Repository) backfillTaskEnvironments() error {
if dialect.IsPostgres(r.db.DriverName()) {
return nil
}
orphaned, err := r.findOrphanedTasks()
if err != nil {
return err
}
if len(orphaned) == 0 {
return nil
}
tx, err := r.db.Begin()
if err != nil {
return fmt.Errorf("backfill: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
for _, row := range orphaned {
if err := r.backfillSingleTask(tx, row); err != nil {
return err
}
}
return tx.Commit()
}
// findOrphanedTasks returns tasks that have sessions but no task_environments row.
//
// Pre-refactor this also read ts.container_id; that column was dropped from
// task_sessions when executors_running became the source of truth. Historical
// orphaned envs for already-launched sessions get container_id from the
// executors_running row via the LEFT JOIN; sessions without a row have empty
// container_id (they were never launched, so no container to track).
func (r *Repository) findOrphanedTasks() ([]backfillRow, error) {
rows, err := r.db.Query(`
SELECT ts.task_id,
MIN(COALESCE(ts.executor_id, '')),
MIN(COALESCE(ts.executor_profile_id, '')),
MIN(COALESCE(ts.repository_id, '')),
MIN(COALESCE(er.container_id, '')),
MIN(ts.started_at)
FROM task_sessions ts
LEFT JOIN task_environments te ON te.task_id = ts.task_id
LEFT JOIN executors_running er ON er.session_id = ts.id
WHERE te.id IS NULL
GROUP BY ts.task_id
`)
if err != nil {
return nil, fmt.Errorf("backfill: query orphaned tasks: %w", err)
}
defer func() { _ = rows.Close() }()
var orphaned []backfillRow
for rows.Next() {
var row backfillRow
if err := rows.Scan(&row.taskID, &row.executorID, &row.executorProfileID,
&row.repositoryID, &row.containerID, &row.startedAt); err != nil {
return nil, fmt.Errorf("backfill: scan: %w", err)
}
orphaned = append(orphaned, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("backfill: rows: %w", err)
}
return orphaned, nil
}
// healTaskEnvironmentWorkspacePaths backfills workspace_path on worktree-mode
// envs that have a worktree_path set but an empty workspace_path. Such rows
// trigger ErrSessionWorkspaceNotReady forever in GetOrEnsureExecutionForEnvironment
// and leave shell terminals stuck on "Connecting terminal...".
//
// It also repairs rows where workspace_path was the task-root parent of
// worktree_path — a pre-fix value left by the legacy computeWorkspacePath
// that collapsed single-repo worktree paths via filepath.Dir. After the fix,
// workspace_path must equal worktree_path (the agent process cwd) so ACP
// session/load on cold start hits the same sanitized-cwd jsonl folder the
// agent wrote on hot start. Without this repair, existing single-repo
// Worktree tasks keep failing with -32002 after upgrade.
//
// Idempotent — once workspace_path == worktree_path nothing more is changed.
func (r *Repository) healTaskEnvironmentWorkspacePaths() error {
// substr(...) prefix match is safer than LIKE here: paths may contain
// "_" or "%", both of which are LIKE wildcards in SQLite.
rows, err := r.db.Query(`
SELECT id, worktree_path
FROM task_environments
WHERE executor_type = 'worktree'
AND COALESCE(worktree_path, '') != ''
AND COALESCE(workspace_path, '') != worktree_path
AND (
COALESCE(workspace_path, '') = ''
OR (length(workspace_path) < length(worktree_path)
AND substr(worktree_path, 1, length(workspace_path)) = workspace_path)
)
`)
if err != nil {
return fmt.Errorf("heal workspace_path: query: %w", err)
}
type healRow struct{ id, worktreePath string }
var pending []healRow
for rows.Next() {
var hr healRow
if err := rows.Scan(&hr.id, &hr.worktreePath); err != nil {
_ = rows.Close()
return fmt.Errorf("heal workspace_path: scan: %w", err)
}
pending = append(pending, hr)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("heal workspace_path: rows: %w", err)
}
_ = rows.Close()
if len(pending) == 0 {
return nil
}
for _, hr := range pending {
if _, err := r.db.Exec(
`UPDATE task_environments SET workspace_path = ?, updated_at = datetime('now') WHERE id = ?`,
hr.worktreePath, hr.id,
); err != nil {
return fmt.Errorf("heal workspace_path: update %s: %w", hr.id, err)
}
}
return nil
}
// healDuplicateTaskEnvironments collapses rows where a single task has more
// than one task_environments row (race in lazy create). Keeps the most recently
// updated row and re-points any sessions still referring to the loser.
//
// Runs before ensureTaskEnvironmentTaskUniqueIndex so the unique constraint
// can be added cleanly. Idempotent — a no-op once the data is healed.
func (r *Repository) healDuplicateTaskEnvironments() error {
tx, err := r.db.Begin()
if err != nil {
return fmt.Errorf("heal duplicate envs: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
rows, err := tx.Query(`
SELECT task_id
FROM task_environments
GROUP BY task_id
HAVING COUNT(*) > 1
`)
if err != nil {
return fmt.Errorf("heal duplicate envs: list duplicates: %w", err)
}
var taskIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return fmt.Errorf("heal duplicate envs: scan: %w", err)
}
taskIDs = append(taskIDs, id)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("heal duplicate envs: rows: %w", err)
}
_ = rows.Close()
for _, taskID := range taskIDs {
if err := healDuplicateTaskEnvForTask(tx, taskID); err != nil {
return err
}
}
return tx.Commit()
}
// healDuplicateTaskEnvForTask keeps the most recently updated env for a task,
// re-points sessions on the loser rows to the winner, then deletes losers.
func healDuplicateTaskEnvForTask(tx *sql.Tx, taskID string) error {
var winnerID string
if err := tx.QueryRow(`
SELECT id FROM task_environments
WHERE task_id = ?
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
`, taskID).Scan(&winnerID); err != nil {
return fmt.Errorf("heal duplicate envs: find winner for task %s: %w", taskID, err)
}
if _, err := tx.Exec(`
UPDATE task_sessions
SET task_environment_id = ?
WHERE task_id = ?
AND task_environment_id != ?
`, winnerID, taskID, winnerID); err != nil {
return fmt.Errorf("heal duplicate envs: relink sessions for task %s: %w", taskID, err)
}
if _, err := tx.Exec(`
DELETE FROM task_environments
WHERE task_id = ?
AND id != ?
`, taskID, winnerID); err != nil {
return fmt.Errorf("heal duplicate envs: delete losers for task %s: %w", taskID, err)
}
return nil
}
// ensureTaskEnvironmentTaskUniqueIndex adds a UNIQUE index on
// task_environments(task_id) so that a future race in env creation fails loud
// instead of silently producing two rows for the same task. Must run AFTER
// healDuplicateTaskEnvironments, which collapses any pre-existing duplicates.
func (r *Repository) ensureTaskEnvironmentTaskUniqueIndex() error {
_, err := r.db.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS uniq_task_environments_task_id
ON task_environments(task_id)
`)
return err
}
// healSessionTaskEnvironmentIDs backfills task_sessions.task_environment_id
// for any session whose task already has a task_environments row. Sessions
// created via paths that don't write the FK leave shell ops broken because
// every user-shell RPC is env-keyed and the frontend can't resolve session→env
// without this column. Idempotent: rows that already point at the env are
// untouched.
//
// Must run AFTER backfillTaskEnvironments + healDuplicateTaskEnvironments +
// ensureTaskEnvironmentTaskUniqueIndex so each task has exactly one env to
// link to.
func (r *Repository) healSessionTaskEnvironmentIDs() error {
// LIMIT 1 is defensive — the unique index added by
// ensureTaskEnvironmentTaskUniqueIndex guarantees ≤1 row per task at
// runtime, but the SQL reads as non-deterministic in isolation. Belt
// and suspenders.
if _, err := r.db.Exec(`
UPDATE task_sessions
SET task_environment_id = (
SELECT te.id FROM task_environments te WHERE te.task_id = task_sessions.task_id LIMIT 1
)
WHERE (task_environment_id = '' OR task_environment_id IS NULL)
AND EXISTS (
SELECT 1 FROM task_environments te WHERE te.task_id = task_sessions.task_id
)
`); err != nil {
return fmt.Errorf("heal session env id: update: %w", err)
}
return nil
}
// backfillSingleTask creates a task_environment and links sessions for one orphaned task.
func (r *Repository) backfillSingleTask(tx *sql.Tx, row backfillRow) error {
envID := uuid.New().String()
// Look up executor type from executors table. Default to "local_pc" ONLY
// when the executor row genuinely doesn't exist (e.g. legacy session whose
// executor was deleted). Any other scan error — driver failure, schema
// mismatch, type assertion bug — must abort the migration so the operator
// sees the underlying problem instead of every backfilled environment
// silently getting the wrong executor_type.
var executorType string
if err := tx.QueryRow(`SELECT type FROM executors WHERE id = ?`, row.executorID).Scan(&executorType); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("backfill: lookup executor type for task %s: %w", row.taskID, err)
}
executorType = "local_pc"
}
// Look up worktree info from task_session_worktrees (best effort)
var wtID, wtPath, wtBranch string
_ = tx.QueryRow(`
SELECT w.worktree_id, w.worktree_path, w.worktree_branch
FROM task_session_worktrees w
JOIN task_sessions ts ON ts.id = w.session_id
WHERE ts.task_id = ?
LIMIT 1
`, row.taskID).Scan(&wtID, &wtPath, &wtBranch)
// Insert task_environment with status "stopped" (historical, agentctl not running).
// Pre-refactor this also wrote agent_execution_id; that column is gone from
// task_environments (executors_running is the only carrier of execution state now).
if _, err := tx.Exec(`
INSERT INTO task_environments (
id, task_id, repository_id, executor_type, executor_id,
executor_profile_id, control_port, status,
worktree_id, worktree_path, worktree_branch, workspace_path,
container_id, sandbox_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 0, 'stopped', ?, ?, ?, '', ?, '', ?, datetime('now'))
`, envID, row.taskID, row.repositoryID, executorType, row.executorID,
row.executorProfileID, wtID, wtPath, wtBranch, row.containerID, row.startedAt); err != nil {
return fmt.Errorf("backfill: insert env for task %s: %w", row.taskID, err)
}
// Link all sessions for this task that lack task_environment_id
if _, err := tx.Exec(`
UPDATE task_sessions
SET task_environment_id = ?
WHERE task_id = ? AND (task_environment_id = '' OR task_environment_id IS NULL)
`, envID, row.taskID); err != nil {
return fmt.Errorf("backfill: link sessions for task %s: %w", row.taskID, err)
}
return nil
}
// backfillTaskEnvironmentRepos populates task_environment_repos from the legacy
// single-repo fields on task_environments. One row per environment that has a
// non-empty repository_id and no existing task_environment_repos row.
// Idempotent.
func (r *Repository) backfillTaskEnvironmentRepos() error {
rows, err := r.db.Query(`
SELECT te.id,
te.repository_id,
COALESCE(te.worktree_id, ''),
COALESCE(te.worktree_path, ''),
COALESCE(te.worktree_branch, ''),
te.created_at
FROM task_environments te
LEFT JOIN task_environment_repos ter ON ter.task_environment_id = te.id
WHERE te.repository_id != '' AND ter.id IS NULL
`)
if err != nil {
return fmt.Errorf("backfill repos: query: %w", err)
}
defer func() { _ = rows.Close() }()
type envRepoRow struct {
envID, repoID, wtID, wtPath, wtBranch, createdAt string
}
var pending []envRepoRow
for rows.Next() {
var row envRepoRow
if err := rows.Scan(&row.envID, &row.repoID, &row.wtID, &row.wtPath, &row.wtBranch, &row.createdAt); err != nil {
return fmt.Errorf("backfill repos: scan: %w", err)
}
pending = append(pending, row)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("backfill repos: rows: %w", err)
}
if len(pending) == 0 {
return nil
}
tx, err := r.db.Begin()
if err != nil {
return fmt.Errorf("backfill repos: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
for _, row := range pending {
if _, err := tx.Exec(`
INSERT INTO task_environment_repos (
id, task_environment_id, repository_id, branch_slug,
worktree_id, worktree_path, worktree_branch,
position, error_message, created_at, updated_at
) VALUES (?, ?, ?, '', ?, ?, ?, 0, '', ?, ?)
`, uuid.New().String(), row.envID, row.repoID,
row.wtID, row.wtPath, row.wtBranch,
row.createdAt, row.createdAt); err != nil {
return fmt.Errorf("backfill repos: insert env %s: %w", row.envID, err)
}
}
return tx.Commit()
}
// Startup healing of orphaned workflow_step_id values was removed: a raw SQL
// UPDATE reassigning tasks to a workflow's start step bypasses every
// domain-level invariant the task service enforces on a move (WIP limits,
// task-state sync, position bookkeeping, session/on_exit/on_enter handling,
// transition history, and event publication). The Kanban/Pipeline "Needs
// Reassignment" fallback column now keeps orphaned tasks visible without any
// automatic mutation, and any real repair should go through
// task.Service.MoveTask (or a dedicated, explicit reassignment operation)
// rather than a migration-time SQL statement.