Files
kandev/apps/backend/internal/task/repository/sqlite/executor.go
T

765 lines
24 KiB
Go

package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/recoveryclaim"
)
// Executor operations
func (r *Repository) CreateExecutor(ctx context.Context, executor *models.Executor) error {
if executor.ID == "" {
executor.ID = uuid.New().String()
}
now := time.Now().UTC()
executor.CreatedAt = now
executor.UpdatedAt = now
configJSON, err := json.Marshal(executor.Config)
if err != nil {
return fmt.Errorf("failed to serialize executor config: %w", err)
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), executor.ID, executor.Name, executor.Type, executor.Status, dialect.BoolToInt(executor.IsSystem), dialect.BoolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt)
return err
}
func (r *Repository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) {
executor := &models.Executor{}
var configJSON string
var isSystem int
var resumable int
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at
FROM executors WHERE id = ? AND deleted_at IS NULL
`), id).Scan(
&executor.ID, &executor.Name, &executor.Type, &executor.Status,
&isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: %s", models.ErrExecutorNotFound, id)
}
if err != nil {
return nil, err
}
executor.IsSystem = isSystem == 1
executor.Resumable = resumable == 1
if configJSON != "" && configJSON != "{}" {
if err := json.Unmarshal([]byte(configJSON), &executor.Config); err != nil {
return nil, fmt.Errorf("failed to deserialize executor config: %w", err)
}
}
return executor, nil
}
func (r *Repository) UpdateExecutor(ctx context.Context, executor *models.Executor) error {
executor.UpdatedAt = time.Now().UTC()
configJSON, err := json.Marshal(executor.Config)
if err != nil {
return fmt.Errorf("failed to serialize executor config: %w", err)
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, resumable = ?, config = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`), executor.Name, executor.Type, executor.Status, dialect.BoolToInt(executor.IsSystem), dialect.BoolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("executor not found: %s", executor.ID)
}
return nil
}
func (r *Repository) DeleteExecutor(ctx context.Context, id string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE executors SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL
`), now, now, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("executor not found: %s", id)
}
return nil
}
func (r *Repository) ListExecutors(ctx context.Context) ([]*models.Executor, error) {
rows, err := r.ro.QueryContext(ctx, `
SELECT id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at
FROM executors WHERE deleted_at IS NULL ORDER BY created_at ASC
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.Executor
for rows.Next() {
executor := &models.Executor{}
var configJSON string
var isSystem int
var resumable int
if err := rows.Scan(
&executor.ID, &executor.Name, &executor.Type, &executor.Status,
&isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt,
); err != nil {
return nil, err
}
executor.IsSystem = isSystem == 1
executor.Resumable = resumable == 1
if configJSON != "" && configJSON != "{}" {
if err := json.Unmarshal([]byte(configJSON), &executor.Config); err != nil {
return nil, fmt.Errorf("failed to deserialize executor config: %w", err)
}
}
result = append(result, executor)
}
return result, rows.Err()
}
func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error {
if running == nil {
return fmt.Errorf("executor running is nil")
}
if running.SessionID == "" {
return fmt.Errorf("session_id is required")
}
if running.ID == "" {
running.ID = running.SessionID
}
now := time.Now().UTC()
if running.CreatedAt.IsZero() {
running.CreatedAt = now
}
running.UpdatedAt = now
metadataJSON := "{}"
if running.Metadata != nil {
b, err := json.Marshal(running.Metadata)
if err != nil {
return fmt.Errorf("failed to serialize executor running metadata: %w", err)
}
metadataJSON = string(b)
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := lockTaskSessionRow(ctx, tx, running.SessionID); err != nil {
return err
}
var environmentID sql.NullString
if queryErr := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT COALESCE(task_environment_id, '') FROM task_sessions WHERE id = ?`), running.SessionID).Scan(&environmentID); queryErr != nil && queryErr != sql.ErrNoRows {
return queryErr
}
if environmentID.Valid {
if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, environmentID.String); err != nil {
return err
}
}
_, err = tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO executors_running (
id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token,
last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid,
worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
id = excluded.id,
task_id = excluded.task_id,
execution_profile_id = excluded.execution_profile_id,
executor_id = excluded.executor_id,
runtime = excluded.runtime,
status = excluded.status,
resumable = excluded.resumable,
resume_token = excluded.resume_token,
last_message_uuid = excluded.last_message_uuid,
agent_execution_id = excluded.agent_execution_id,
container_id = excluded.container_id,
agentctl_url = excluded.agentctl_url,
agentctl_port = excluded.agentctl_port,
pid = excluded.pid,
local_pid = excluded.local_pid,
worktree_id = excluded.worktree_id,
worktree_path = excluded.worktree_path,
worktree_branch = excluded.worktree_branch,
last_seen_at = excluded.last_seen_at,
error_message = excluded.error_message,
metadata = excluded.metadata,
updated_at = excluded.updated_at
`),
running.ID,
running.SessionID,
running.TaskID,
running.ExecutionProfileID,
running.ExecutorID,
running.Runtime,
running.Status,
dialect.BoolToInt(running.Resumable),
running.ResumeToken,
running.LastMessageUUID,
running.AgentExecutionID,
running.ContainerID,
running.AgentctlURL,
running.AgentctlPort,
running.PID,
running.LocalPID,
running.WorktreeID,
running.WorktreePath,
running.WorktreeBranch,
running.LastSeenAt,
running.ErrorMessage,
metadataJSON,
running.CreatedAt,
running.UpdatedAt,
)
if err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) {
rows, err := r.ro.QueryContext(ctx, `
SELECT id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token,
last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid,
worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata,
created_at, updated_at
FROM executors_running
ORDER BY updated_at DESC
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return scanExecutorRunningRows(rows)
}
// ListExecutorsRunningIdle returns rows that can be candidates for periodic
// stale-runtime recovery. Filtering in SQL avoids rescanning preserved stopped
// rows and recent executions on every reaper tick.
func (r *Repository) ListExecutorsRunningIdle(ctx context.Context, cutoff time.Time) ([]*models.ExecutorRunning, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token,
last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid,
worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata,
created_at, updated_at
FROM executors_running
WHERE status <> ? AND updated_at <= ?
ORDER BY updated_at DESC
`), models.ExecutorRunningStatusStopped, cutoff)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return scanExecutorRunningRows(rows)
}
func (r *Repository) ListExecutorsRunningByTaskID(ctx context.Context, taskID string) ([]*models.ExecutorRunning, error) {
if taskID == "" {
return nil, fmt.Errorf("task_id is required")
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token,
last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid,
worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata,
created_at, updated_at
FROM executors_running
WHERE task_id = ?
ORDER BY updated_at DESC
`), taskID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return scanExecutorRunningRows(rows)
}
func (r *Repository) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) {
if sessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
running := &models.ExecutorRunning{}
var resumable int
var lastSeen sql.NullTime
var metadataJSON string
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token,
last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid,
worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata,
created_at, updated_at
FROM executors_running
WHERE session_id = ?
`), sessionID).Scan(
&running.ID,
&running.SessionID,
&running.TaskID,
&running.ExecutionProfileID,
&running.ExecutorID,
&running.Runtime,
&running.Status,
&resumable,
&running.ResumeToken,
&running.LastMessageUUID,
&running.AgentExecutionID,
&running.ContainerID,
&running.AgentctlURL,
&running.AgentctlPort,
&running.PID,
&running.LocalPID,
&running.WorktreeID,
&running.WorktreePath,
&running.WorktreeBranch,
&lastSeen,
&running.ErrorMessage,
&metadataJSON,
&running.CreatedAt,
&running.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
if err != nil {
return nil, err
}
running.Resumable = resumable == 1
if lastSeen.Valid {
running.LastSeenAt = &lastSeen.Time
}
if metadataJSON != "" && metadataJSON != "{}" {
if jsonErr := json.Unmarshal([]byte(metadataJSON), &running.Metadata); jsonErr != nil {
return nil, fmt.Errorf("failed to deserialize executor running metadata: %w", jsonErr)
}
}
return running, nil
}
func scanExecutorRunningRows(rows *sql.Rows) ([]*models.ExecutorRunning, error) {
var results []*models.ExecutorRunning
for rows.Next() {
running := &models.ExecutorRunning{}
var (
resumable int
lastSeen sql.NullTime
metadataJSON string
)
if scanErr := rows.Scan(
&running.ID,
&running.SessionID,
&running.TaskID,
&running.ExecutionProfileID,
&running.ExecutorID,
&running.Runtime,
&running.Status,
&resumable,
&running.ResumeToken,
&running.LastMessageUUID,
&running.AgentExecutionID,
&running.ContainerID,
&running.AgentctlURL,
&running.AgentctlPort,
&running.PID,
&running.LocalPID,
&running.WorktreeID,
&running.WorktreePath,
&running.WorktreeBranch,
&lastSeen,
&running.ErrorMessage,
&metadataJSON,
&running.CreatedAt,
&running.UpdatedAt,
); scanErr != nil {
return nil, scanErr
}
running.Resumable = resumable == 1
if lastSeen.Valid {
running.LastSeenAt = &lastSeen.Time
}
if metadataJSON != "" && metadataJSON != "{}" {
if jsonErr := json.Unmarshal([]byte(metadataJSON), &running.Metadata); jsonErr != nil {
return nil, fmt.Errorf("failed to deserialize executor running metadata: %w", jsonErr)
}
}
results = append(results, running)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
func (r *Repository) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.ensureExecutorRunningAvailableTx(ctx, tx, sessionID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM executors_running WHERE session_id = ?`), sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
return tx.Commit()
}
// ensureExecutorRunningAvailableTx applies the environment recovery claim to
// every mutation of an executors_running row. A session without an environment
// is still allowed because initial materialization creates the environment
// before it can be used for recovery.
func (r *Repository) ensureExecutorRunningAvailableTx(ctx context.Context, tx *sqlx.Tx, sessionID string) error {
var environmentID sql.NullString
err := tx.QueryRowContext(ctx, r.db.Rebind(`
SELECT COALESCE(task_environment_id, '') FROM task_sessions WHERE id = ?
`), sessionID).Scan(&environmentID)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if !environmentID.Valid || environmentID.String == "" {
return nil
}
return recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, environmentID.String)
}
func (r *Repository) executorRunningRowExistsTx(ctx context.Context, tx *sqlx.Tx, sessionID string) (bool, error) {
var exists int
err := tx.QueryRowContext(ctx, r.db.Rebind(`
SELECT 1 FROM executors_running WHERE session_id = ? LIMIT 1
`), sessionID).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return exists == 1, err
}
// HasExecutorRunningRow returns true if an executors_running row exists for sessionID.
// Used as the canonical "session has been launched" check, replacing reads of the
// (now removed) task_sessions.agent_execution_id != "" pattern.
func (r *Repository) HasExecutorRunningRow(ctx context.Context, sessionID string) (bool, error) {
if sessionID == "" {
return false, fmt.Errorf("session_id is required")
}
var exists int
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT 1 FROM executors_running WHERE session_id = ? LIMIT 1
`), sessionID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// UpdateResumeToken performs a CAS update of resume_token + last_message_uuid keyed on
// the row's current agent_execution_id. If the row has rotated to a different execution
// since expectedExecID was observed, the update affects 0 rows and returns
// models.ErrExecutionRotated — the caller's write came from a defunct execution and
// must be discarded.
//
// expectedExecID == "" means "match any agent_execution_id" (used during initial
// row population when the caller doesn't know/care). Prefer passing the actual ID.
func (r *Repository) UpdateResumeToken(ctx context.Context, sessionID, expectedExecID, resumeToken, lastMessageUUID string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
now := time.Now().UTC()
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.ensureExecutorRunningAvailableTx(ctx, tx, sessionID); err != nil {
return err
}
var (
result sql.Result
)
if expectedExecID == "" {
result, err = tx.ExecContext(ctx, r.db.Rebind(`
UPDATE executors_running
SET resume_token = ?, last_message_uuid = ?, updated_at = ?
WHERE session_id = ?
`), resumeToken, lastMessageUUID, now, sessionID)
} else {
result, err = tx.ExecContext(ctx, r.db.Rebind(`
UPDATE executors_running
SET resume_token = ?, last_message_uuid = ?, updated_at = ?
WHERE session_id = ?
AND agent_execution_id = ?
`), resumeToken, lastMessageUUID, now, sessionID, expectedExecID)
}
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
// Distinguish "no row at all" from "row exists but rotated".
exists, hasErr := r.executorRunningRowExistsTx(ctx, tx, sessionID)
if hasErr != nil {
return hasErr
}
if !exists {
return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
return models.ErrExecutionRotated
}
return tx.Commit()
}
// RepairExecutorRunningDead repairs a row in place to reflect that its backing
// process is gone, WITHOUT deleting it. The resume-safety invariant requires a
// resumable / non-terminal row be repaired rather than pruned
// (#1597 resume-safety invariant): flip status to "stopped", clear the local
// liveness handle (local_pid = 0) so the row no longer claims a live process,
// and re-stamp last_seen_at as a fresh liveness observation. resume_token,
// worktree, and endpoint columns are intentionally preserved so the session
// stays resumable.
//
// Returns ErrExecutorRunningNotFound when no row exists for the session.
func (r *Repository) RepairExecutorRunningDead(ctx context.Context, sessionID string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
now := time.Now().UTC()
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.ensureExecutorRunningAvailableTx(ctx, tx, sessionID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE executors_running
SET status = ?, local_pid = 0, last_seen_at = ?, updated_at = ?
WHERE session_id = ?
`), models.ExecutorRunningStatusStopped, now, now, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
return tx.Commit()
}
// RepairExecutorRunningDeadIfCurrent is the compare-and-set variant used by
// concurrent cleanup. The row must still belong to expectedExecID and, when
// supplied, must have the same UpdatedAt value observed by the caller.
func (r *Repository) RepairExecutorRunningDeadIfCurrent(
ctx context.Context,
sessionID, expectedExecID string,
expectedUpdatedAt time.Time,
) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
now := time.Now().UTC()
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.ensureExecutorRunningAvailableTx(ctx, tx, sessionID); err != nil {
return err
}
query := `
UPDATE executors_running
SET status = ?, local_pid = 0, last_seen_at = ?, updated_at = ?
WHERE session_id = ? AND agent_execution_id = ?
`
args := []interface{}{
models.ExecutorRunningStatusStopped, now, now, sessionID, expectedExecID,
}
if !expectedUpdatedAt.IsZero() {
query += " AND updated_at = ?\n"
args = append(args, expectedUpdatedAt)
}
result, err := tx.ExecContext(ctx, r.db.Rebind(query), args...)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows > 0 {
return tx.Commit()
}
exists, err := r.executorRunningRowExistsTx(ctx, tx, sessionID)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
return models.ErrExecutionRotated
}
// DeleteExecutorRunningIfCurrent deletes a row only when its execution
// identity and observed timestamp still match. It prevents cleanup of a row
// that a successor launch has already replaced.
func (r *Repository) DeleteExecutorRunningIfCurrent(
ctx context.Context,
sessionID, expectedExecID string,
expectedUpdatedAt time.Time,
) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.ensureExecutorRunningAvailableTx(ctx, tx, sessionID); err != nil {
return err
}
query := `DELETE FROM executors_running WHERE session_id = ? AND agent_execution_id = ?`
args := []interface{}{sessionID, expectedExecID}
if !expectedUpdatedAt.IsZero() {
query += " AND updated_at = ?\n"
args = append(args, expectedUpdatedAt)
}
result, err := tx.ExecContext(ctx, r.db.Rebind(query), args...)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows > 0 {
return tx.Commit()
}
exists, err := r.executorRunningRowExistsTx(ctx, tx, sessionID)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
return models.ErrExecutionRotated
}
// UpdateExecutorRunningStatus narrowly updates the status column.
// Returns ErrExecutorRunningNotFound when no row exists for the session.
func (r *Repository) UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
now := time.Now().UTC()
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.ensureExecutorRunningAvailableTx(ctx, tx, sessionID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE executors_running
SET status = ?, updated_at = ?
WHERE session_id = ?
`), status, now, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
return tx.Commit()
}
// UpdateExecutorRunningWorktreeBranch narrowly updates the branch snapshot for
// a live execution. The execution ID is part of the CAS so a rotated execution
// cannot overwrite the successor's branch snapshot.
func (r *Repository) UpdateExecutorRunningWorktreeBranch(ctx context.Context, sessionID, expectedExecID, branch string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
if expectedExecID == "" {
return fmt.Errorf("expected execution_id is required")
}
now := time.Now().UTC()
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := r.ensureExecutorRunningAvailableTx(ctx, tx, sessionID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE executors_running
SET worktree_branch = ?, updated_at = ?
WHERE session_id = ?
AND agent_execution_id = ?
`), branch, now, sessionID, expectedExecID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
exists, hasErr := r.executorRunningRowExistsTx(ctx, tx, sessionID)
if hasErr != nil {
return hasErr
}
if !exists {
return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
return models.ErrExecutionRotated
}
return tx.Commit()
}
func (r *Repository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) {
var exists int
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT 1 FROM task_sessions
WHERE executor_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
LIMIT 1
`), executorID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}