fix: distinguish archived automation runs from cancelled ones (#1860)

This commit is contained in:
Clément Fiere
2026-07-24 10:50:36 +02:00
committed by GitHub
parent 93c6987bf5
commit 62919efbdc
12 changed files with 576 additions and 63 deletions
+17 -5
View File
@@ -45,13 +45,25 @@ const (
RunStatusSucceeded RunStatus = "succeeded"
RunStatusFailed RunStatus = "failed"
RunStatusSkipped RunStatus = "skipped"
// RunStatusArchived is a read-time-derived terminal status: it is never
// written by a trigger-firing code path, only computed by ListRuns when
// a task_created run's generated task has been archived (archived_at
// set) — whether archived through the UI or by the agent itself (e.g.
// via an "archive this task" instruction). The task's outcome may still
// be known (succeeded/failed already recorded); this only overrides the
// still-open task_created status, never a real terminal one.
RunStatusArchived RunStatus = "archived"
// RunStatusCancelled is a read-time-derived terminal status: it is never
// written by a trigger-firing code path, only computed by ListRuns (and
// implied by CountActiveRuns' filter) when a task_created run's
// generated task has been archived or no longer exists. The task's
// outcome is unknown at that point — the user closed it out some other
// way — so this is deliberately distinct from succeeded/failed rather
// than guessing one.
// implied by CountActiveRuns' filter) for a task_created run whose
// generated task either no longer exists (deleted — outcome
// unrecoverable) or whose task's *current* session (task_sessions row
// with is_primary = 1) was explicitly put into the CANCELLED state —
// set only when an agent run was manually stopped (a deliberate
// cancellation, distinct from archiving; the task's own state is not
// touched by a stop and so isn't consulted here). Archived_at takes
// precedence over the cancelled-session check when both apply — see
// listRunsWithTaskState.
RunStatusCancelled RunStatus = "cancelled"
)
+52 -15
View File
@@ -14,6 +14,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
taskmodels "github.com/kandev/kandev/internal/task/models"
)
// Store provides SQLite persistence for automations.
@@ -438,12 +439,13 @@ func (s *Store) updateRunTerminalStatus(ctx context.Context, taskID string, stat
}
// ListRuns returns recent runs for an automation. A task_created run whose
// generated task has been archived or no longer exists is reported as
// cancelled — the task's outcome is unknown and it's no longer outstanding
// work — without touching runs that already reached a real terminal
// status. Falls back to the raw stored status when the tasks table isn't
// present (isolated automation-only tests; production always has it,
// migrated by the task repository before automation triggers can fire).
// generated task has been archived is reported as archived; one whose task
// is gone entirely or explicitly cancelled is reported as cancelled — see
// listRunsWithTaskState for the full precedence — without touching runs
// that already reached a real terminal status. Falls back to the raw
// stored status when the tasks table isn't present (isolated
// automation-only tests; production always has it, migrated by the task
// repository before automation triggers can fire).
func (s *Store) ListRuns(ctx context.Context, automationID string, limit int) ([]*AutomationRun, error) {
if limit <= 0 {
limit = 50
@@ -470,11 +472,36 @@ func (s *Store) listRunsWithTaskState(ctx context.Context, automationID string,
// and displays as cancelled rather than its raw stored status —
// reachable today only through the e2e run-seeding endpoint, never in
// production.
//
// Three read-time overrides of a still-open task_created run, in
// priority order: (1) the task row is gone entirely — deleted, outcome
// unrecoverable — shown as cancelled; (2) the task was archived
// (archived_at set, via the UI or by the agent itself, e.g. an
// "archive this task" instruction) — shown as archived, which takes
// precedence over the session check below; (3) the task's current
// (is_primary) session is CANCELLED — set only when an agent run was
// manually stopped (coordinator/MCP stop_task, or the UI Stop button)
// — a deliberate cancellation, shown as cancelled. Task.state is
// deliberately NOT consulted here: stopping an agent leaves the task
// itself at whatever state the stop caller chose (e.g. REVIEW) and
// only ever marks the *session* CANCELLED — see
// orchestrator.handleAgentStopped's "we do NOT update task state
// here" note. Filtering on is_primary picks the task's current
// session, so a resumed-and-completed task isn't misclassified by a
// stale cancelled session left over from an earlier stop. EXISTS
// (rather than a LEFT JOIN) keeps the query correct even if the
// "at most one is_primary=1 row per task" invariant is ever violated
// — a join would fan out and duplicate the run.
var runs []*AutomationRun
err := s.ro.SelectContext(ctx, &runs, `
SELECT ar.id, ar.automation_id, ar.trigger_id, ar.trigger_type, ar.task_id,
CASE
WHEN ar.status = ? AND (t.id IS NULL OR t.archived_at IS NOT NULL) THEN ?
WHEN ar.status = ? AND t.id IS NULL THEN ?
WHEN ar.status = ? AND t.archived_at IS NOT NULL THEN ?
WHEN ar.status = ? AND EXISTS (
SELECT 1 FROM task_sessions ts
WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 AND ts.state = ?
) THEN ?
ELSE ar.status
END AS status,
ar.dedup_key, ar.trigger_data, ar.error_message, ar.created_at
@@ -482,7 +509,10 @@ func (s *Store) listRunsWithTaskState(ctx context.Context, automationID string,
LEFT JOIN tasks t ON t.id = ar.task_id
WHERE ar.automation_id = ?
ORDER BY ar.created_at DESC LIMIT ?`,
string(RunStatusTaskCreated), string(RunStatusCancelled), automationID, limit)
string(RunStatusTaskCreated), string(RunStatusCancelled),
string(RunStatusTaskCreated), string(RunStatusArchived),
string(RunStatusTaskCreated), string(taskmodels.TaskSessionStateCancelled), string(RunStatusCancelled),
automationID, limit)
return runs, err
}
@@ -508,11 +538,11 @@ func (s *Store) HasRunWithDedupKey(ctx context.Context, automationID, dedupKey s
// CountActiveRuns returns the number of runs with task_created status for
// an automation whose generated task is still open. A task_created run
// whose task was archived or deleted no longer represents outstanding
// work — the user closed it out some other way — so it must not keep
// counting against max_concurrent_runs forever. Falls back to a plain
// count when the tasks table isn't present (isolated automation-only
// tests; production always has it).
// whose task was archived, deleted, or explicitly cancelled no longer
// represents outstanding work — the user (or agent) closed it out some
// other way — so it must not keep counting against max_concurrent_runs
// forever. Falls back to a plain count when the tasks table isn't present
// (isolated automation-only tests; production always has it).
func (s *Store) CountActiveRuns(ctx context.Context, automationID string) (int, error) {
count, err := s.countActiveRunsWithTaskState(ctx, automationID)
if db.IsMissingTableError(err) {
@@ -525,13 +555,20 @@ func (s *Store) countActiveRunsWithTaskState(ctx context.Context, automationID s
// Same non-empty-task_id assumption as listRunsWithTaskState above: an
// empty ar.task_id never matches a real task row either, so such a run
// silently falls out of the active count below instead of erroring.
// See listRunsWithTaskState for why the current (is_primary) session's
// state, not the task's own state, is the cancellation signal, and why
// NOT EXISTS rather than a LEFT JOIN.
var count int
err := s.ro.GetContext(ctx, &count, `
SELECT COUNT(*) FROM automation_runs ar
LEFT JOIN tasks t ON t.id = ar.task_id
WHERE ar.automation_id = ? AND ar.status = ?
AND t.id IS NOT NULL AND t.archived_at IS NULL`,
automationID, string(RunStatusTaskCreated))
AND t.id IS NOT NULL AND t.archived_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM task_sessions ts
WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 AND ts.state = ?
)`,
automationID, string(RunStatusTaskCreated), string(taskmodels.TaskSessionStateCancelled))
return count, err
}
+98 -26
View File
@@ -301,16 +301,21 @@ func TestDeleteAllRuns(t *testing.T) {
}
// createTasksTable adds a minimal shadow of the task repository's `tasks`
// table (id, archived_at) to the store's DB. The automation package never
// owns this table — apps/backend/internal/task/repository/sqlite is the
// canonical owner — so only tests that exercise the CountActiveRuns/ListRuns
// task-state join create it; production always has the real table already
// migrated by the task repository before automation triggers can fire.
// and `task_sessions` tables (id/archived_at, and task_id/is_primary/state
// respectively) to the store's DB. The automation package never owns
// these tables — apps/backend/internal/task/repository/sqlite is the
// canonical owner — so only tests that exercise the CountActiveRuns/
// ListRuns task-state join create them; production always has the real
// tables already migrated by the task repository before automation
// triggers can fire.
func createTasksTable(t *testing.T, store *Store) {
t.Helper()
if _, err := store.db.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, archived_at DATETIME)`); err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`CREATE TABLE task_sessions (id TEXT PRIMARY KEY, task_id TEXT NOT NULL, is_primary INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'CREATED')`); err != nil {
t.Fatal(err)
}
}
func insertTask(t *testing.T, store *Store, id string, archived bool) {
@@ -324,15 +329,47 @@ func insertTask(t *testing.T, store *Store, id string, archived bool) {
}
}
// TestCountActiveRuns_ExcludesArchivedOrMissingTask reproduces the reported
// bug: an automation-generated task that gets archived (manually, via
// auto-archive, or via cascade) leaves its automation run stuck at
// task_created forever unless CountActiveRuns checks the task's current
// state. A run whose task is archived, gone entirely, or never recorded
// (empty task_id — see countActiveRunsWithTaskState's docstring in
// store.go) no longer represents outstanding work and must not count
// against max_concurrent_runs.
func TestCountActiveRuns_ExcludesArchivedOrMissingTask(t *testing.T) {
// insertPrimarySession seeds the given task's *current* session (is_primary
// = 1) with an explicit models.TaskSessionState string (e.g. "CANCELLED"),
// so tests can exercise the genuine-cancellation branch of
// listRunsWithTaskState/countActiveRunsWithTaskState independently of
// archived_at. Mirrors production: a task has at most one is_primary = 1
// session at a time (SetPrimarySession unsets the rest on resume).
func insertPrimarySession(t *testing.T, store *Store, taskID, state string) {
t.Helper()
if _, err := store.db.Exec(
`INSERT INTO task_sessions (id, task_id, is_primary, state) VALUES (?, ?, 1, ?)`,
taskID+"-session", taskID, state,
); err != nil {
t.Fatal(err)
}
}
// insertStaleSession seeds a non-primary (is_primary = 0) session for the
// given task — e.g. a CANCELLED session left over from a stop, before the
// task was resumed and its is_primary flag moved to a fresh session. Used
// to prove the current-session filter isn't fooled by cancellation
// history that no longer reflects the task's live state.
func insertStaleSession(t *testing.T, store *Store, taskID, state string) {
t.Helper()
if _, err := store.db.Exec(
`INSERT INTO task_sessions (id, task_id, is_primary, state) VALUES (?, ?, 0, ?)`,
taskID+"-stale-session", taskID, state,
); err != nil {
t.Fatal(err)
}
}
// TestCountActiveRuns_ExcludesArchivedCancelledOrMissingTask reproduces the
// reported bug: an automation-generated task that gets archived (manually,
// via auto-archive, via cascade, or by the agent itself) or is explicitly
// cancelled leaves its automation run stuck at task_created forever unless
// CountActiveRuns checks the task's current state. A run whose task is
// archived, cancelled, gone entirely, or never recorded (empty task_id —
// see countActiveRunsWithTaskState's docstring in store.go) no longer
// represents outstanding work and must not count against
// max_concurrent_runs.
func TestCountActiveRuns_ExcludesArchivedCancelledOrMissingTask(t *testing.T) {
store := setupTestStore(t)
createTasksTable(t, store)
ctx := context.Background()
@@ -344,11 +381,18 @@ func TestCountActiveRuns_ExcludesArchivedOrMissingTask(t *testing.T) {
insertTask(t, store, "task-active", false)
insertTask(t, store, "task-archived", true)
insertTask(t, store, "task-cancelled", false)
insertPrimarySession(t, store, "task-cancelled", "CANCELLED")
// Resumed after a stop: the stale CANCELLED session is no longer
// primary, so this task must still count as active.
insertTask(t, store, "task-resumed", false)
insertStaleSession(t, store, "task-resumed", "CANCELLED")
insertPrimarySession(t, store, "task-resumed", "RUNNING")
// "task-missing" deliberately has no row in tasks at all; "" (the
// task_id column default) exercises the same no-live-task branch
// through a different route.
for _, tid := range []string{"task-active", "task-archived", "task-missing", ""} {
for _, tid := range []string{"task-active", "task-archived", "task-cancelled", "task-resumed", "task-missing", ""} {
if err := store.CreateRun(ctx, &AutomationRun{
AutomationID: a.ID,
TriggerType: TriggerTypeScheduled,
@@ -364,8 +408,8 @@ func TestCountActiveRuns_ExcludesArchivedOrMissingTask(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected 1 active run (only task-active is open), got %d", count)
if count != 2 {
t.Fatalf("expected 2 active runs (task-active and task-resumed are open), got %d", count)
}
}
@@ -400,12 +444,16 @@ func TestCountActiveRuns_FallsBackWhenTasksTableAbsent(t *testing.T) {
}
}
// TestListRuns_ArchivedOrMissingTaskShowsCancelled ensures the "Recent Runs"
// list the settings UI reads stops labeling a run "Running" once its
// generated task is archived, gone, or never recorded (empty task_id — see
// listRunsWithTaskState's docstring in store.go), without touching the
// stored status of runs that already reached a real terminal outcome.
func TestListRuns_ArchivedOrMissingTaskShowsCancelled(t *testing.T) {
// TestListRuns_DerivesArchivedCancelledAndActiveStatus ensures the "Recent
// Runs" list the settings UI reads stops labeling a run "Running" once its
// generated task is archived, cancelled, gone, or never recorded (empty
// task_id — see listRunsWithTaskState's docstring in store.go). Archived
// (regardless of whether the UI or the agent itself triggered it) must be
// visually distinct from a genuine user cancellation (task state
// CANCELLED), and archived_at takes precedence when a task is both
// cancelled and archived. Runs that already reached a real terminal
// outcome are left untouched.
func TestListRuns_DerivesArchivedCancelledAndActiveStatus(t *testing.T) {
store := setupTestStore(t)
createTasksTable(t, store)
ctx := context.Background()
@@ -416,13 +464,28 @@ func TestListRuns_ArchivedOrMissingTaskShowsCancelled(t *testing.T) {
}
insertTask(t, store, "task-active", false)
insertTask(t, store, "task-archived", true)
insertTask(t, store, "task-cancelled", false)
insertPrimarySession(t, store, "task-cancelled", "CANCELLED")
insertTask(t, store, "task-cancelled-and-archived", true)
insertPrimarySession(t, store, "task-cancelled-and-archived", "CANCELLED")
// Stopped once (stale, non-primary CANCELLED session), then resumed
// with a fresh primary session that completed. Must read as its raw
// stored status, not cancelled — the stale session no longer reflects
// the task's live state.
insertTask(t, store, "task-resumed", false)
insertStaleSession(t, store, "task-resumed", "CANCELLED")
insertPrimarySession(t, store, "task-resumed", "COMPLETED")
active := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-active", TriggerData: json.RawMessage(`{}`)}
archived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-archived", TriggerData: json.RawMessage(`{}`)}
cancelled := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-cancelled", TriggerData: json.RawMessage(`{}`)}
cancelledAndArchived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-cancelled-and-archived", TriggerData: json.RawMessage(`{}`)}
resumed := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-resumed", TriggerData: json.RawMessage(`{}`)}
missing := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-missing", TriggerData: json.RawMessage(`{}`)}
emptyTaskID := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "", TriggerData: json.RawMessage(`{}`)}
succeededOnArchived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusSucceeded, TaskID: "task-archived", TriggerData: json.RawMessage(`{}`)}
for _, r := range []*AutomationRun{active, archived, missing, emptyTaskID, succeededOnArchived} {
allRuns := []*AutomationRun{active, archived, cancelled, cancelledAndArchived, resumed, missing, emptyTaskID, succeededOnArchived}
for _, r := range allRuns {
if err := store.CreateRun(ctx, r); err != nil {
t.Fatal(err)
}
@@ -439,8 +502,17 @@ func TestListRuns_ArchivedOrMissingTaskShowsCancelled(t *testing.T) {
if s := statusByID[active.ID]; s != RunStatusTaskCreated {
t.Errorf("active task run: expected task_created, got %q", s)
}
if s := statusByID[archived.ID]; s != RunStatusCancelled {
t.Errorf("archived task run: expected cancelled, got %q", s)
if s := statusByID[archived.ID]; s != RunStatusArchived {
t.Errorf("archived task run: expected archived, got %q", s)
}
if s := statusByID[cancelled.ID]; s != RunStatusCancelled {
t.Errorf("cancelled task run: expected cancelled, got %q", s)
}
if s := statusByID[cancelledAndArchived.ID]; s != RunStatusArchived {
t.Errorf("cancelled-and-archived task run: expected archived_at to take precedence (archived), got %q", s)
}
if s := statusByID[resumed.ID]; s != RunStatusTaskCreated {
t.Errorf("resumed-after-cancel task run: expected task_created (stale session ignored), got %q", s)
}
if s := statusByID[missing.ID]; s != RunStatusCancelled {
t.Errorf("missing task run: expected cancelled, got %q", s)
@@ -13,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
taskmodels "github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
)
@@ -49,6 +50,10 @@ func registerE2EResetRoutes(
api.POST("/automations", handleE2ECreateAutomation(automationSvc, log))
api.POST("/automation-runs", handleE2ECreateAutomationRun(automationSvc, log))
}
// Seeds a task_sessions row directly (e.g. a CANCELLED primary session)
// so tests can assert on session-state-derived behavior without a full
// agent stop/resume cycle.
api.POST("/task-sessions", handleE2ECreateTaskSession(repo, log))
log.Info("registered E2E endpoints (test-only)")
}
@@ -321,3 +326,59 @@ func handleE2ECreateAutomationRun(svc *automation.Service, log *logger.Logger) g
})
}
}
type e2eCreateTaskSessionRequest struct {
TaskID string `json:"task_id"`
State string `json:"state"`
// IsPrimary defaults to true: tests seeding a single session almost
// always want it to be the task's *current* session (the one
// listRunsWithTaskState/countActiveRunsWithTaskState read). Set to
// false to seed a stale, superseded session for resume scenarios.
IsPrimary *bool `json:"is_primary"`
}
// handleE2ECreateTaskSession seeds a task_sessions row directly for E2E
// tests that need to assert on session-state-derived behavior (e.g. the
// automation Recent Runs "Cancelled" status, which is keyed off the task's
// primary session state, not the task's own state — see
// internal/automation.Store.listRunsWithTaskState) without driving a real
// agent through a full stop/resume cycle.
func handleE2ECreateTaskSession(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
var body e2eCreateTaskSessionRequest
if err := c.ShouldBindJSON(&body); err != nil || body.TaskID == "" || body.State == "" {
c.JSON(http.StatusBadRequest, gin.H{errKey: "task_id and state are required"})
return
}
isPrimary := true
if body.IsPrimary != nil {
isPrimary = *body.IsPrimary
}
session := &taskmodels.TaskSession{
TaskID: body.TaskID,
State: taskmodels.TaskSessionState(body.State),
}
if err := repo.CreateTaskSession(c.Request.Context(), session); err != nil {
log.Error("e2e: failed to create task session", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
return
}
// SetSessionPrimary clears is_primary on every other session for this
// task before setting it here, mirroring production's resume path
// (Repository.SetSessionPrimary) so seeding a second primary session
// for a task that already has one can't leave two is_primary = 1 rows
// behind — that would corrupt the run-status query's read of the
// task's "current" session (see listRunsWithTaskState).
if isPrimary {
if err := repo.SetSessionPrimary(c.Request.Context(), session.ID); err != nil {
log.Error("e2e: failed to set primary task session", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
return
}
session.IsPrimary = true
}
c.JSON(http.StatusCreated, gin.H{
"id": session.ID, taskIDPayloadKey: session.TaskID, statusKey: session.State,
})
}
}
@@ -771,3 +771,54 @@ func TestListWorktreesBySessionIDs_ChunksOverPlaceholderLimit(t *testing.T) {
t.Errorf("expected empty map for non-existent session IDs, got %d entries", len(got))
}
}
// TestSetSessionPrimary_DemotesOthersAndPromotesExactlyOne pins the core
// invariant SetSessionPrimary exists to maintain: after promoting a second
// session, the first is demoted and exactly one session is primary. See
// internal/automation.Store.listRunsWithTaskState, which reads a task's
// current session via is_primary = 1 and would misclassify a run if two
// sessions were ever left primary.
func TestSetSessionPrimary_DemotesOthersAndPromotesExactlyOne(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
ctx := context.Background()
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-primary", Name: "WF"}); err != nil {
t.Fatalf("CreateWorkflow: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "task-primary", WorkflowID: "wf-primary", WorkflowStepID: "step-1", Title: "T",
}); err != nil {
t.Fatalf("CreateTask: %v", err)
}
s1 := &models.TaskSession{ID: "sess-primary-1", TaskID: "task-primary", State: models.TaskSessionStateCancelled}
if err := repo.CreateTaskSession(ctx, s1); err != nil {
t.Fatalf("CreateTaskSession s1: %v", err)
}
s2 := &models.TaskSession{ID: "sess-primary-2", TaskID: "task-primary", State: models.TaskSessionStateRunning}
if err := repo.CreateTaskSession(ctx, s2); err != nil {
t.Fatalf("CreateTaskSession s2: %v", err)
}
if err := repo.SetSessionPrimary(ctx, s1.ID); err != nil {
t.Fatalf("SetSessionPrimary(s1): %v", err)
}
if err := repo.SetSessionPrimary(ctx, s2.ID); err != nil {
t.Fatalf("SetSessionPrimary(s2): %v", err)
}
got1, err := repo.GetTaskSession(ctx, s1.ID)
if err != nil {
t.Fatalf("GetTaskSession(s1): %v", err)
}
got2, err := repo.GetTaskSession(ctx, s2.ID)
if err != nil {
t.Fatalf("GetTaskSession(s2): %v", err)
}
if got1.IsPrimary {
t.Errorf("s1 still primary after promoting s2 — exactly-one-primary invariant broken")
}
if !got2.IsPrimary {
t.Errorf("s2 not primary after being promoted")
}
}
@@ -2,9 +2,16 @@ package sqlite
import (
"context"
"fmt"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/testutil"
@@ -314,3 +321,124 @@ func TestPostgresTaskEnvironmentReposMultiBranchMigration(t *testing.T) {
t.Fatal("expected duplicate env/repo/branch insert to fail")
}
}
// openIsolatedPostgresMultiConn is like testutil.OpenIsolatedPostgres but
// supports a real multi-connection pool. OpenIsolatedPostgres scopes its
// isolated schema via a session-level `SET search_path` issued on one
// connection — fine for its single-connection pool, but a second pooled
// connection never sees that SET and falls back to the default "public"
// schema. Baking search_path into the DSN's libpq `options` param instead
// makes every new connection resolve unqualified table names against the
// isolated schema, so the pool can be sized for genuine concurrency tests.
func openIsolatedPostgresMultiConn(t *testing.T, dsn string, maxConns int) *sqlx.DB {
t.Helper()
schema := "kandev_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
setup, err := sqlx.Open("pgx", dsn)
if err != nil {
t.Fatalf("open postgres (schema setup): %v", err)
}
if _, err := setup.Exec("CREATE SCHEMA " + schema); err != nil {
_ = setup.Close()
t.Fatalf("create postgres schema %s: %v", schema, err)
}
_ = setup.Close()
// Register schema-drop cleanup immediately: any t.Fatalf between here and
// the end of this function (e.g. sqlx.Open below failing) must still not
// leak the schema on the Postgres test instance.
t.Cleanup(func() {
if cleanup, cerr := sqlx.Open("pgx", dsn); cerr == nil {
_, _ = cleanup.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
_ = cleanup.Close()
}
})
// dsn is either a URL ("postgres://user:pass@host/db?sslmode=...") or a
// libpq keyword/value string ("host=... port=... sslmode=disable" —
// what CI's KANDEV_TEST_POSTGRES_DSN uses). Appending "?options=..." to
// the latter corrupts the last keyword's value instead of adding a new
// one, so the two forms need different separators.
var scopedDSN string
if strings.Contains(dsn, "://") {
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
scopedDSN = dsn + sep + "options=" + url.QueryEscape("-c search_path="+schema)
} else {
scopedDSN = dsn + " options='-c search_path=" + schema + "'"
}
db, err := sqlx.Open("pgx", scopedDSN)
if err != nil {
t.Fatalf("open postgres (scoped, %d conns): %v", maxConns, err)
}
db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(maxConns)
t.Cleanup(func() { _ = db.Close() })
return db
}
// TestPostgresSetSessionPrimary_ConcurrentPromotionsLeaveExactlyOnePrimary
// exercises the race Greptile flagged on PR #1860: SetSessionPrimary demotes
// every other session for a task and promotes the target in one
// transaction, plus a `SELECT ... FOR UPDATE` row lock on the owning task
// so concurrent promotions on separate Postgres connections serialize
// instead of interleaving their demote/promote pairs. Uses
// openIsolatedPostgresMultiConn (not testutil.OpenIsolatedPostgres, which
// caps the pool at one connection) because genuine cross-connection
// concurrency is the whole point — a one-connection pool would trivially
// serialize even the pre-fix code and prove nothing.
func TestPostgresSetSessionPrimary_ConcurrentPromotionsLeaveExactlyOnePrimary(t *testing.T) {
const concurrency = 8
db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), concurrency)
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
ctx := context.Background()
now := time.Now().UTC()
if _, err := db.Exec(db.Rebind(`
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`), "task-primary-race", "ws-primary-race", "Task primary race", now, now); err != nil {
t.Fatalf("seed task: %v", err)
}
sessionIDs := make([]string, concurrency)
for i := range sessionIDs {
sessionIDs[i] = fmt.Sprintf("session-primary-race-%d", i)
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: sessionIDs[i], TaskID: "task-primary-race", State: models.TaskSessionStateRunning,
}); err != nil {
t.Fatalf("CreateTaskSession(%s): %v", sessionIDs[i], err)
}
}
var wg sync.WaitGroup
errs := make([]error, concurrency)
for i, sessionID := range sessionIDs {
wg.Add(1)
go func(i int, sessionID string) {
defer wg.Done()
errs[i] = repo.SetSessionPrimary(ctx, sessionID)
}(i, sessionID)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("SetSessionPrimary(%s) concurrent call failed: %v", sessionIDs[i], err)
}
}
var primaryCount int
if err := db.Get(&primaryCount, db.Rebind(
`SELECT COUNT(*) FROM task_sessions WHERE task_id = ? AND is_primary = 1`,
), "task-primary-race"); err != nil {
t.Fatalf("count primary sessions: %v", err)
}
if primaryCount != 1 {
t.Errorf("expected exactly 1 primary session after %d concurrent promotions, got %d — FOR UPDATE lock not serializing across connections", concurrency, primaryCount)
}
}
@@ -1914,13 +1914,32 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs
return result, rows.Err()
}
// SetSessionPrimary marks a session as primary and clears primary flag on other sessions for the same task
// SetSessionPrimary marks a session as primary and clears the primary flag
// on every other session for the same task, atomically. Both writes go
// through a single transaction so a concurrent caller can't observe (or
// write) a half-applied state — e.g. two sessions racing on is_primary=1,
// or a reader seeing zero primary sessions mid-swap.
//
// On SQLite the writer pool is a single connection (see
// internal/db.NewSQLiteDB), so only one transaction can hold it at a time —
// the transaction alone fully serializes concurrent callers. On Postgres,
// separate connections could otherwise run two of these transactions truly
// concurrently, each seeing zero primary sessions and both promoting; to
// close that window we take an exclusive row lock on the owning task
// (`SELECT ... FOR UPDATE`) before touching its sessions, so a second
// concurrent promotion for the same task blocks until the first commits.
func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) error {
now := time.Now().UTC()
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// First, get the task_id for this session
var taskID string
err := r.db.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id FROM task_sessions WHERE id = ?`), sessionID).Scan(&taskID)
err = tx.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id FROM task_sessions WHERE id = ?`), sessionID).Scan(&taskID)
if err == sql.ErrNoRows {
return fmt.Errorf("session not found: %s", sessionID)
}
@@ -1928,8 +1947,19 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er
return err
}
// Serialize concurrent promotions for the same task across Postgres
// connections. SQLite has no FOR UPDATE / row-level locking and doesn't
// need it — the single-connection writer pool already serializes here.
if dialect.IsPostgres(r.db.DriverName()) {
var lockedTaskID string
err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT id FROM tasks WHERE id = ? FOR UPDATE`), taskID).Scan(&lockedTaskID)
if err != nil && err != sql.ErrNoRows {
return err
}
}
// Clear primary flag on all sessions for this task
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
_, err = tx.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET is_primary = 0, updated_at = ? WHERE task_id = ?
`), now, taskID)
if err != nil {
@@ -1937,7 +1967,7 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er
}
// Set primary flag on the specified session
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
result, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE task_sessions SET is_primary = 1, updated_at = ? WHERE id = ?
`), now, sessionID)
if err != nil {
@@ -1948,5 +1978,5 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er
if rows == 0 {
return fmt.Errorf("session not found: %s", sessionID)
}
return nil
return tx.Commit()
}
@@ -0,0 +1,76 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AutomationRun } from "@/lib/types/automation";
const mockUseAutomationRuns = vi.fn();
vi.mock("@/hooks/domains/settings/use-automation-runs", () => ({
useAutomationRuns: (...args: unknown[]) => mockUseAutomationRuns(...args),
}));
const mockPush = vi.fn();
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push: mockPush }),
}));
import { RunsSection } from "./runs-section";
function mkRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
return {
id: "run-1",
automation_id: "auto-1",
trigger_id: "trig-1",
trigger_type: "scheduled",
task_id: "task-1",
status: "task_created",
dedup_key: "",
trigger_data: {},
error_message: "",
created_at: new Date().toISOString(),
...overrides,
};
}
function setup(runs: AutomationRun[]) {
mockUseAutomationRuns.mockReturnValue({
runs,
loading: false,
refresh: vi.fn(),
deleteRun: vi.fn(),
deleteAllRuns: vi.fn(),
});
render(<RunsSection automationId="auto-1" executionMode="task" workspaceId="ws-1" />);
// The runs table only renders once the "Recent Runs" disclosure is expanded.
fireEvent.click(screen.getByText(/Recent Runs/));
}
describe("RunsSection status badges", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("labels an archived-task run as Archived, not Cancelled", () => {
setup([mkRun({ id: "run-archived", status: "archived" })]);
expect(screen.getByText("Archived")).toBeTruthy();
expect(screen.queryByText("Cancelled")).toBeNull();
});
it("labels a genuinely cancelled-task run as Cancelled", () => {
setup([mkRun({ id: "run-cancelled", status: "cancelled" })]);
expect(screen.getByText("Cancelled")).toBeTruthy();
expect(screen.queryByText("Archived")).toBeNull();
});
it("distinguishes archived and cancelled runs side by side", () => {
setup([
mkRun({ id: "run-archived", status: "archived" }),
mkRun({ id: "run-cancelled", status: "cancelled" }),
]);
expect(screen.getByText("Archived")).toBeTruthy();
expect(screen.getByText("Cancelled")).toBeTruthy();
});
});
@@ -37,9 +37,14 @@ const STATUS_BADGE: Record<
succeeded: { variant: "default", label: "Succeeded" },
failed: { variant: "destructive", label: "Failed" },
skipped: { variant: "outline", label: "Skipped" },
// The generating task was archived or no longer exists — its outcome is
// unknown, so this is deliberately distinct from succeeded/failed rather
// than guessing one. See internal/automation.RunStatusCancelled.
// The generating task was archived — via the UI or by the agent itself
// (e.g. an "archive this task" instruction). Distinct from a genuine
// user cancellation: archiving just closes the task out, it doesn't
// mean the run's work was rejected. See internal/automation.RunStatusArchived.
archived: { variant: "outline", label: "Archived" },
// The generating task no longer exists, or its current primary session
// is CANCELLED — a real cancellation, distinct from archived.
// See internal/automation.RunStatusCancelled.
cancelled: { variant: "outline", label: "Cancelled" },
};
+20
View File
@@ -2446,6 +2446,26 @@ export class ApiClient {
task_id: taskId ?? "",
});
}
/**
* Seed a task_sessions row directly via the E2E HTTP endpoint —
* deterministic state seeding, not a substitute for driving a real
* agent stop/resume cycle. Used to assert on session-state-derived
* behavior (e.g. the automation Recent Runs "Cancelled" status, which
* is keyed off the task's *primary* session state, not the task's own
* state). Only works when KANDEV_MOCK_AGENT is active.
*/
async seedAutomationTaskSession(
taskId: string,
state: string,
isPrimary = true,
): Promise<{ id: string; task_id: string; status: string }> {
return this.request("POST", "/api/v1/e2e/task-sessions", {
task_id: taskId,
state,
is_primary: isPrimary,
});
}
}
// --- Jira / Linear mock payload types ---
@@ -358,17 +358,21 @@ test.describe("Automations settings page", () => {
await expect(testPage.getByText("No runs yet")).toBeVisible({ timeout: 5_000 });
});
test("archived task's run shows Cancelled instead of Running", async ({
test("archived task's run shows Archived, cancelled task's run shows Cancelled", async ({
testPage,
seedData,
apiClient,
}) => {
// Regression test: an automation-generated task that gets archived
// (manually, via auto-archive, or via cascade) before its run is
// otherwise finalized used to leave the run stuck at "task_created"
// forever, displayed as "Running" and permanently pinned against
// max_concurrent_runs. See internal/automation.Store.CountActiveRuns /
// ListRuns.
// (manually, via auto-archive, via cascade, or by the agent itself)
// before its run is otherwise finalized used to leave the run stuck at
// "task_created" forever, displayed as "Running" and permanently
// pinned against max_concurrent_runs. It also used to be
// indistinguishable from a genuine user cancellation (the task's
// primary session state going CANCELLED — the signal a real Stop
// leaves behind; the task's own state is untouched by a stop), both
// showing as "Cancelled". See internal/automation.Store.CountActiveRuns
// / ListRuns.
const automation = await apiClient.seedAutomation({
workspaceId: seedData.workspaceId,
name: "Archived Task Run Test",
@@ -381,13 +385,20 @@ test.describe("Automations settings page", () => {
"Archived Automation Task",
{ workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId },
);
const cancelledTask = await apiClient.createTask(
seedData.workspaceId,
"Cancelled Automation Task",
{ workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId },
);
const openTask = await apiClient.createTask(seedData.workspaceId, "Open Automation Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.seedAutomationRun(automation.id, "task_created", archivedTask.id);
await apiClient.seedAutomationRun(automation.id, "task_created", cancelledTask.id);
await apiClient.seedAutomationRun(automation.id, "task_created", openTask.id);
await apiClient.archiveTask(archivedTask.id);
await apiClient.seedAutomationTaskSession(cancelledTask.id, "CANCELLED");
await testPage.goto(`/settings/workspace/${seedData.workspaceId}/automations/${automation.id}`);
await testPage.getByTestId("automation-editor").waitFor({ state: "visible", timeout: 15_000 });
@@ -401,13 +412,22 @@ test.describe("Automations settings page", () => {
const tbody = testPage.locator("table tbody");
await tbody.waitFor({ state: "visible", timeout: 5_000 });
await expect(tbody.locator("tr")).toHaveCount(2, { timeout: 10_000 });
await expect(tbody.locator("tr")).toHaveCount(3, { timeout: 10_000 });
// The archived task's run is no longer outstanding work.
// The archived task's run is no longer outstanding work, and reads
// "Archived" rather than being conflated with a real cancellation.
const archivedRow = testPage.locator("table tbody tr", {
hasText: archivedTask.id.slice(0, 8),
});
await expect(archivedRow.getByText("Cancelled", { exact: true })).toBeVisible();
await expect(archivedRow.getByText("Archived", { exact: true })).toBeVisible();
// A task whose current (primary) session was genuinely cancelled —
// the signal a real Stop leaves behind — reads "Cancelled", distinct
// from "Archived".
const cancelledRow = testPage.locator("table tbody tr", {
hasText: cancelledTask.id.slice(0, 8),
});
await expect(cancelledRow.getByText("Cancelled", { exact: true })).toBeVisible();
// The still-open task's run is unaffected.
const openRow = testPage.locator("table tbody tr", { hasText: openTask.id.slice(0, 8) });
+1
View File
@@ -8,6 +8,7 @@ export type RunStatus =
| "succeeded"
| "failed"
| "skipped"
| "archived"
| "cancelled";
// ExecutionMode controls whether an automation firing creates a visible