765 lines
24 KiB
Go
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
|
|
}
|