From 0c8479a02a3ac953a8985bce66f1fac102f69434 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 11:20:48 +0800 Subject: [PATCH 01/41] feat(issue-status): Phase 1 schema + built-in status seeding (MUL-4809) First stageable slice of the Custom Issue Status plan: rollback-safe schema and idempotent seeding, with no behavior change to existing status handling. - Add issue_status catalog table (per-workspace, 5 immutable Categories as the only machine semantics; name/icon/color/description human-facing). Built-ins carry a stable system_key; custom statuses are NULL. No FKs (CLAUDE.md). - Four indexes, each CONCURRENTLY in its own migration: three partial catalog uniqueness indexes (name-active, system_key, at-most-one category default) plus a non-partial workspace_id index covering the delete/cleanup path (archived rows included). - Add nullable issue.status_id + its index (unused this phase; legacy issue.status TEXT stays authoritative until Phase 2 double-write). - internal/issuestatus.Ensure idempotently seeds the 7 built-ins with an explicit ON CONFLICT (workspace_id, system_key) arbiter and a FOR KEY SHARE existence gate, so a re-run is a no-op and a since-deleted workspace is never re-seeded. Wired into workspace creation inside its tx. - internal/issuestatus.Backfill reconciles pre-existing workspaces on boot, advisory-locked so one replica walks during a rolling deploy. - DeleteWorkspace sweeps issue_status in the same CTE (no-FK integrity), so a deleted workspace leaves no orphan status rows. Migrations renumbered to 200-206 after rebasing onto main (197-199 taken). Verified on pg17: full migrate up + down/up roundtrip; DB tests cover seed correctness, one-default-per-category, idempotency, backfill, delete cleanup, and the orphan-race guard; workspace handler tests still pass. Co-authored-by: multica-agent --- server/cmd/server/main.go | 14 ++ server/internal/handler/workspace.go | 8 + server/internal/issuestatus/backfill.go | 63 +++++ server/internal/issuestatus/ensure.go | 32 +++ .../internal/issuestatus/issuestatus_test.go | 223 ++++++++++++++++++ server/migrations/200_issue_status.down.sql | 1 + server/migrations/200_issue_status.up.sql | 39 +++ .../201_issue_status_name_index.down.sql | 1 + .../201_issue_status_name_index.up.sql | 6 + ...202_issue_status_system_key_index.down.sql | 1 + .../202_issue_status_system_key_index.up.sql | 10 + ...sue_status_category_default_index.down.sql | 1 + ...issue_status_category_default_index.up.sql | 11 + ...4_issue_status_workspace_id_index.down.sql | 1 + ...204_issue_status_workspace_id_index.up.sql | 8 + .../205_issue_status_id_column.down.sql | 1 + .../205_issue_status_id_column.up.sql | 6 + .../206_issue_status_id_index.down.sql | 1 + .../206_issue_status_id_index.up.sql | 3 + server/pkg/db/generated/issue.sql.go | 42 ++-- server/pkg/db/generated/issue_property.sql.go | 6 +- server/pkg/db/generated/issue_status.sql.go | 128 ++++++++++ server/pkg/db/generated/models.go | 17 ++ server/pkg/db/generated/workspace.sql.go | 41 +++- server/pkg/db/queries/issue_status.sql | 61 +++++ server/pkg/db/queries/workspace.sql | 20 +- 26 files changed, 721 insertions(+), 24 deletions(-) create mode 100644 server/internal/issuestatus/backfill.go create mode 100644 server/internal/issuestatus/ensure.go create mode 100644 server/internal/issuestatus/issuestatus_test.go create mode 100644 server/migrations/200_issue_status.down.sql create mode 100644 server/migrations/200_issue_status.up.sql create mode 100644 server/migrations/201_issue_status_name_index.down.sql create mode 100644 server/migrations/201_issue_status_name_index.up.sql create mode 100644 server/migrations/202_issue_status_system_key_index.down.sql create mode 100644 server/migrations/202_issue_status_system_key_index.up.sql create mode 100644 server/migrations/203_issue_status_category_default_index.down.sql create mode 100644 server/migrations/203_issue_status_category_default_index.up.sql create mode 100644 server/migrations/204_issue_status_workspace_id_index.down.sql create mode 100644 server/migrations/204_issue_status_workspace_id_index.up.sql create mode 100644 server/migrations/205_issue_status_id_column.down.sql create mode 100644 server/migrations/205_issue_status_id_column.up.sql create mode 100644 server/migrations/206_issue_status_id_index.down.sql create mode 100644 server/migrations/206_issue_status_id_index.up.sql create mode 100644 server/pkg/db/generated/issue_status.sql.go create mode 100644 server/pkg/db/queries/issue_status.sql diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 1d753f6b191..9627c28c310 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "github.com/multica-ai/multica/server/internal/daemonws" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" + "github.com/multica-ai/multica/server/internal/issuestatus" "github.com/multica-ai/multica/server/internal/logger" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" "github.com/multica-ai/multica/server/internal/realtime" @@ -384,6 +385,19 @@ func main() { liveness = handler.NewRedisLivenessStore(storeRedis) } + // One-shot boot reconcile: seed built-in issue statuses for workspaces + // created before the catalog shipped (MUL-4809). Advisory-locked so a + // single replica does the walk during a rolling deploy; new workspaces are + // seeded at creation time. + go func() { + n, err := issuestatus.Backfill(sweepCtx, pool) + if err != nil { + slog.Error("issue status backfill failed", "error", err) + return + } + slog.Info("issue status backfill complete", "workspaces", n) + }() + // Start background sweeper to mark stale runtimes as offline. go runRuntimeSweeper(sweepCtx, queries, liveness, taskSvc, bus) go heartbeatScheduler.Run(sweepCtx) diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index 08480bf3d47..28fa891c718 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/analytics" + "github.com/multica-ai/multica/server/internal/issuestatus" "github.com/multica-ai/multica/server/internal/logger" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" db "github.com/multica-ai/multica/server/pkg/db/generated" @@ -215,6 +216,13 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { return } + // Seed the workspace's built-in issue statuses in the same tx so the + // catalog exists atomically with the workspace (MUL-4809). + if err := issuestatus.Ensure(r.Context(), qtx, ws.ID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to seed issue statuses: "+err.Error()) + return + } + // NOTE: CreateWorkspace deliberately does NOT mark the user as // onboarded. The `onboarded_at` flag is owned by CompleteOnboarding // (Step 3 of the flow) and by AcceptInvitation (invitee joining an diff --git a/server/internal/issuestatus/backfill.go b/server/internal/issuestatus/backfill.go new file mode 100644 index 00000000000..905c80e9091 --- /dev/null +++ b/server/internal/issuestatus/backfill.go @@ -0,0 +1,63 @@ +package issuestatus + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// backfillAdvisoryLockKey serializes the boot-time reconcile across replicas. +// During a rolling deploy several new pods start at once; the winner walks +// every workspace while the losers observe the held lock and skip. The exact +// value is arbitrary — it just has to be stable and not collide with other +// advisory-lock users (e.g. taskusagebackfill's 4246). It encodes MUL-4809. +const backfillAdvisoryLockKey int64 = 4809 + +// Backfill ensures every existing workspace carries its built-in issue +// statuses. It is a one-shot, idempotent reconcile meant to run once at server +// boot so workspaces created before this feature shipped get seeded; new +// workspaces are seeded at creation time by Ensure. +// +// A Postgres session-level advisory lock keeps a single replica doing the walk; +// other replicas return (0, nil) immediately. The lock is released on the same +// pinned connection before it returns to the pool. Returns the number of +// workspaces reconciled (0 when another replica held the lock). +func Backfill(ctx context.Context, pool *pgxpool.Pool) (int, error) { + conn, err := pool.Acquire(ctx) + if err != nil { + return 0, fmt.Errorf("acquire conn: %w", err) + } + // LIFO: unlock runs before Release, so the advisory lock is dropped on this + // same session before the connection goes back to the pool. A background + // context keeps the unlock alive even if ctx was cancelled mid-walk. + defer conn.Release() + + var locked bool + if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", backfillAdvisoryLockKey).Scan(&locked); err != nil { + return 0, fmt.Errorf("try advisory lock: %w", err) + } + if !locked { + return 0, nil + } + defer func() { + _, _ = conn.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", backfillAdvisoryLockKey) + }() + + q := db.New(conn) + ids, err := q.ListAllWorkspaceIDs(ctx) + if err != nil { + return 0, fmt.Errorf("list workspaces: %w", err) + } + // The id snapshot may go stale (a workspace deleted between the list and its + // Ensure), but that cannot orphan statuses: Ensure takes FOR KEY SHARE on the + // workspace row and inserts nothing when the row is already gone. So each + // per-workspace seed is self-guarding; no workspace-level lock is needed here. + for _, id := range ids { + if err := Ensure(ctx, q, id); err != nil { + return 0, err + } + } + return len(ids), nil +} diff --git a/server/internal/issuestatus/ensure.go b/server/internal/issuestatus/ensure.go new file mode 100644 index 00000000000..d86895cec96 --- /dev/null +++ b/server/internal/issuestatus/ensure.go @@ -0,0 +1,32 @@ +// Package issuestatus owns the per-workspace issue-status catalog (MUL-4809). +// +// Phase 1 scope: idempotently seed the 7 built-in system statuses into every +// workspace. Category is the only machine-readable semantics; name / icon / +// color / description are human-facing. The seed is safe to run inside the +// workspace-create transaction and repeatedly during a rolling deploy: the +// underlying statement takes FOR KEY SHARE on the workspace row (so it inserts +// nothing for an already-deleted workspace, never leaving orphans) and uses ON +// CONFLICT (workspace_id, system_key) DO NOTHING against the explicit arbiter +// index. No foreign keys (CLAUDE.md): workspace_id is an application-level +// reference, and DeleteWorkspace sweeps the catalog in the same transaction. +package issuestatus + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// Ensure idempotently seeds the built-in system issue statuses for a single +// workspace. Call it on a *db.Queries bound to the workspace-create tx so the +// catalog commits atomically with the workspace, or on a pool-bound Queries +// for backfill. Re-running is a no-op, and seeding a workspace that no longer +// exists is a no-op (the FOR KEY SHARE existence gate inserts nothing). +func Ensure(ctx context.Context, q *db.Queries, workspaceID pgtype.UUID) error { + if err := q.EnsureWorkspaceSystemIssueStatuses(ctx, workspaceID); err != nil { + return fmt.Errorf("ensure workspace issue statuses: %w", err) + } + return nil +} diff --git a/server/internal/issuestatus/issuestatus_test.go b/server/internal/issuestatus/issuestatus_test.go new file mode 100644 index 00000000000..1a270b7a84e --- /dev/null +++ b/server/internal/issuestatus/issuestatus_test.go @@ -0,0 +1,223 @@ +package issuestatus + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +var testPool *pgxpool.Pool + +func TestMain(m *testing.M) { + ctx := context.Background() + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable" + } + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + fmt.Printf("Skipping issuestatus tests: could not connect to database: %v\n", err) + os.Exit(0) + } + if err := pool.Ping(ctx); err != nil { + fmt.Printf("Skipping issuestatus tests: database not reachable: %v\n", err) + pool.Close() + os.Exit(0) + } + testPool = pool + code := m.Run() + pool.Close() + os.Exit(code) +} + +// wantSystemStatuses maps each built-in system_key to its immutable Category. +var wantSystemStatuses = map[string]string{ + "backlog": "backlog", + "todo": "todo", + "in_progress": "in_progress", + "in_review": "in_progress", + "blocked": "in_progress", + "done": "done", + "cancelled": "cancelled", +} + +// freshWorkspace creates a real workspace row (the seed's FOR KEY SHARE gate +// only seeds workspaces that exist) and registers idempotent cleanup of both +// the catalog and the workspace. Tests that delete the workspace themselves are +// fine: the cleanup DELETEs simply affect zero rows. +func freshWorkspace(ctx context.Context, t *testing.T) pgtype.UUID { + t.Helper() + q := db.New(testPool) + var slug string + if err := testPool.QueryRow(ctx, "SELECT 'ist-' || replace(gen_random_uuid()::text, '-', '')").Scan(&slug); err != nil { + t.Fatalf("generate slug: %v", err) + } + ws, err := q.CreateWorkspace(ctx, db.CreateWorkspaceParams{ + Name: "issuestatus-test", + Slug: slug, + IssuePrefix: "IST", + }) + if err != nil { + t.Fatalf("create workspace: %v", err) + } + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), "DELETE FROM issue_status WHERE workspace_id = $1", ws.ID) + _, _ = testPool.Exec(context.Background(), "DELETE FROM workspace WHERE id = $1", ws.ID) + }) + return ws.ID +} + +func rawStatusCount(ctx context.Context, t *testing.T, wsID pgtype.UUID) int64 { + t.Helper() + var n int64 + if err := testPool.QueryRow(ctx, "SELECT COUNT(*) FROM issue_status WHERE workspace_id = $1", wsID).Scan(&n); err != nil { + t.Fatalf("raw status count: %v", err) + } + return n +} + +func TestEnsureSeedsBuiltinStatuses(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("Ensure: %v", err) + } + + statuses, err := q.ListWorkspaceIssueStatuses(ctx, db.ListWorkspaceIssueStatusesParams{WorkspaceID: wsID}) + if err != nil { + t.Fatalf("list statuses: %v", err) + } + if len(statuses) != len(wantSystemStatuses) { + t.Fatalf("want %d seeded statuses, got %d", len(wantSystemStatuses), len(statuses)) + } + + seenKeys := map[string]int{} + defaultsByCategory := map[string]int{} + for _, s := range statuses { + if !s.SystemKey.Valid { + t.Errorf("seeded status %q has NULL system_key (built-ins must carry a stable key)", s.Name) + continue + } + seenKeys[s.SystemKey.String]++ + wantCat, ok := wantSystemStatuses[s.SystemKey.String] + if !ok { + t.Errorf("unexpected system_key %q", s.SystemKey.String) + continue + } + if s.Category != wantCat { + t.Errorf("system_key %q: want category %q, got %q", s.SystemKey.String, wantCat, s.Category) + } + if s.Name == "" || s.Icon == "" || s.Color == "" { + t.Errorf("system_key %q: name/icon/color must be non-empty, got name=%q icon=%q color=%q", s.SystemKey.String, s.Name, s.Icon, s.Color) + } + if s.IsDefault { + defaultsByCategory[s.Category]++ + } + } + for key := range wantSystemStatuses { + if seenKeys[key] != 1 { + t.Errorf("system_key %q seeded %d times, want exactly 1", key, seenKeys[key]) + } + } + // The seed must establish exactly one active default per Category — the "at + // least one" side of the invariant the partial unique index cannot enforce. + for _, cat := range []string{"backlog", "todo", "in_progress", "done", "cancelled"} { + if defaultsByCategory[cat] != 1 { + t.Errorf("category %q has %d default statuses, want exactly 1", cat, defaultsByCategory[cat]) + } + } +} + +func TestEnsureIsIdempotent(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + + for i := 0; i < 3; i++ { + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("Ensure call %d: %v", i+1, err) + } + } + + n, err := q.CountWorkspaceIssueStatuses(ctx, wsID) + if err != nil { + t.Fatalf("count statuses: %v", err) + } + if n != int64(len(wantSystemStatuses)) { + t.Fatalf("want %d statuses after repeated Ensure, got %d (seed is not idempotent)", len(wantSystemStatuses), n) + } +} + +// TestEnsureAfterWorkspaceDeletedInsertsNothing reproduces the Backfill race: +// the workspace id is snapshotted, the workspace is then deleted, and Ensure +// runs afterward. The FOR KEY SHARE existence gate must make that seed a no-op +// so no orphan statuses are created. +func TestEnsureAfterWorkspaceDeletedInsertsNothing(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + + if err := q.DeleteWorkspace(ctx, wsID); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("Ensure on deleted workspace should be a no-op, got: %v", err) + } + if n := rawStatusCount(ctx, t, wsID); n != 0 { + t.Fatalf("Ensure seeded %d statuses for a deleted workspace; want 0 (orphan guard failed)", n) + } +} + +// TestDeleteWorkspaceRemovesStatuses proves the no-FK cleanup: DeleteWorkspace +// sweeps the status catalog in the same statement as the workspace row. +func TestDeleteWorkspaceRemovesStatuses(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("Ensure: %v", err) + } + if n := rawStatusCount(ctx, t, wsID); n != int64(len(wantSystemStatuses)) { + t.Fatalf("precondition: want %d statuses, got %d", len(wantSystemStatuses), n) + } + + if err := q.DeleteWorkspace(ctx, wsID); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + + if n := rawStatusCount(ctx, t, wsID); n != 0 { + t.Fatalf("DeleteWorkspace left %d orphan issue_status rows; want 0", n) + } +} + +func TestBackfillSeedsExistingWorkspace(t *testing.T) { + ctx := context.Background() + wsID := freshWorkspace(ctx, t) + + // The workspace exists but has no status catalog yet (Backfill is what seeds + // pre-existing workspaces). + if n := rawStatusCount(ctx, t, wsID); n != 0 { + t.Fatalf("workspace unexpectedly pre-seeded: got %d statuses", n) + } + + n, err := Backfill(ctx, testPool) + if err != nil { + t.Fatalf("Backfill: %v", err) + } + if n < 1 { + t.Fatalf("Backfill walked %d workspaces, want >= 1", n) + } + + if after := rawStatusCount(ctx, t, wsID); after != int64(len(wantSystemStatuses)) { + t.Fatalf("Backfill did not seed the workspace: got %d statuses, want %d", after, len(wantSystemStatuses)) + } +} diff --git a/server/migrations/200_issue_status.down.sql b/server/migrations/200_issue_status.down.sql new file mode 100644 index 00000000000..f55bb7a2be8 --- /dev/null +++ b/server/migrations/200_issue_status.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS issue_status; diff --git a/server/migrations/200_issue_status.up.sql b/server/migrations/200_issue_status.up.sql new file mode 100644 index 00000000000..d413bc828eb --- /dev/null +++ b/server/migrations/200_issue_status.up.sql @@ -0,0 +1,39 @@ +-- Custom issue statuses (MUL-4809), Phase 1: rollback-safe schema. +-- +-- Per-workspace catalog of issue statuses. Each status belongs to exactly one +-- of 5 immutable Categories (backlog | todo | in_progress | done | cancelled), +-- which is the ONLY machine-readable semantics; name / icon / color / +-- description are human-facing only. Seven built-in statuses carry a stable +-- system_key and are seeded per workspace by internal/issuestatus.Ensure; +-- custom statuses have system_key = NULL. +-- +-- No foreign keys / cascades (CLAUDE.md): workspace_id is an application-level +-- reference; tenant consistency and cleanup are enforced in app code (the +-- workspace-delete path removes these rows in the same transaction). All +-- indexes are created CONCURRENTLY in their own single-statement follow-up +-- migrations (201-204) because Postgres forbids CREATE INDEX CONCURRENTLY +-- inside this table-create transaction. +CREATE TABLE issue_status ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL, + color TEXT NOT NULL, + category TEXT NOT NULL CHECK ( + category IN ('backlog', 'todo', 'in_progress', 'done', 'cancelled') + ), + -- Built-in statuses have a stable system_key; custom statuses are NULL. + -- category and system_key are immutable after creation (enforced in app). + system_key TEXT CHECK ( + system_key IS NULL OR system_key IN ( + 'backlog', 'todo', 'in_progress', 'in_review', + 'blocked', 'done', 'cancelled' + ) + ), + is_default BOOLEAN NOT NULL DEFAULT FALSE, + position DOUBLE PRECISION NOT NULL DEFAULT 0, + archived_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/server/migrations/201_issue_status_name_index.down.sql b/server/migrations/201_issue_status_name_index.down.sql new file mode 100644 index 00000000000..8e5844064b3 --- /dev/null +++ b/server/migrations/201_issue_status_name_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS issue_status_workspace_name_active_uidx; diff --git a/server/migrations/201_issue_status_name_index.up.sql b/server/migrations/201_issue_status_name_index.up.sql new file mode 100644 index 00000000000..1ac727627b0 --- /dev/null +++ b/server/migrations/201_issue_status_name_index.up.sql @@ -0,0 +1,6 @@ +-- Case-insensitive status-name uniqueness per workspace, active rows only. +-- Keep this as the migration's only statement: PostgreSQL rejects CREATE INDEX +-- CONCURRENTLY inside a transaction or multi-command string. +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS issue_status_workspace_name_active_uidx + ON issue_status (workspace_id, LOWER(name)) + WHERE archived_at IS NULL; diff --git a/server/migrations/202_issue_status_system_key_index.down.sql b/server/migrations/202_issue_status_system_key_index.down.sql new file mode 100644 index 00000000000..8afb60ee9e8 --- /dev/null +++ b/server/migrations/202_issue_status_system_key_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS issue_status_workspace_system_key_uidx; diff --git a/server/migrations/202_issue_status_system_key_index.up.sql b/server/migrations/202_issue_status_system_key_index.up.sql new file mode 100644 index 00000000000..1ba1bbd2c39 --- /dev/null +++ b/server/migrations/202_issue_status_system_key_index.up.sql @@ -0,0 +1,10 @@ +-- One row per (workspace, built-in system_key). This is the explicit arbiter +-- for the idempotent ON CONFLICT (workspace_id, system_key) DO NOTHING seed in +-- internal/issuestatus.Ensure, which makes seeding safe to call at workspace +-- creation and repeatedly during a rolling deploy. Naming the arbiter means any +-- OTHER unique violation surfaces loudly instead of silently dropping a +-- built-in. Custom statuses (system_key IS NULL) are excluded by the partial +-- predicate. Single-statement migration (CONCURRENTLY). +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS issue_status_workspace_system_key_uidx + ON issue_status (workspace_id, system_key) + WHERE system_key IS NOT NULL; diff --git a/server/migrations/203_issue_status_category_default_index.down.sql b/server/migrations/203_issue_status_category_default_index.down.sql new file mode 100644 index 00000000000..541ba77b12d --- /dev/null +++ b/server/migrations/203_issue_status_category_default_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS issue_status_workspace_category_default_uidx; diff --git a/server/migrations/203_issue_status_category_default_index.up.sql b/server/migrations/203_issue_status_category_default_index.up.sql new file mode 100644 index 00000000000..a24257c595e --- /dev/null +++ b/server/migrations/203_issue_status_category_default_index.up.sql @@ -0,0 +1,11 @@ +-- At MOST one active default status per (workspace, category). This partial +-- unique index enforces the upper bound only; it cannot guarantee that a +-- default exists. Ensuring at LEAST one default per Category is the service +-- layer's responsibility (the seed creates one, and later archival / default- +-- reassign flows must run in a transaction that never leaves a Category with +-- zero defaults). The default is what a Category alias (backlog | todo | +-- in_progress | done | cancelled) resolves to when writing an issue status. +-- Single-statement migration (CONCURRENTLY). +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS issue_status_workspace_category_default_uidx + ON issue_status (workspace_id, category) + WHERE is_default = TRUE AND archived_at IS NULL; diff --git a/server/migrations/204_issue_status_workspace_id_index.down.sql b/server/migrations/204_issue_status_workspace_id_index.down.sql new file mode 100644 index 00000000000..1e5d3f08071 --- /dev/null +++ b/server/migrations/204_issue_status_workspace_id_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS issue_status_workspace_id_idx; diff --git a/server/migrations/204_issue_status_workspace_id_index.up.sql b/server/migrations/204_issue_status_workspace_id_index.up.sql new file mode 100644 index 00000000000..5870ea6101b --- /dev/null +++ b/server/migrations/204_issue_status_workspace_id_index.up.sql @@ -0,0 +1,8 @@ +-- Non-partial (workspace_id) index covering ALL rows, including archived ones. +-- The three catalog uniqueness indexes (201-203) are partial (active rows / +-- non-NULL system_key), so none of them can serve the workspace-scoped +-- delete/cleanup path that must remove every issue_status row for a workspace +-- regardless of archived_at. Built CONCURRENTLY in its own single-statement +-- migration. +CREATE INDEX CONCURRENTLY IF NOT EXISTS issue_status_workspace_id_idx + ON issue_status (workspace_id); diff --git a/server/migrations/205_issue_status_id_column.down.sql b/server/migrations/205_issue_status_id_column.down.sql new file mode 100644 index 00000000000..959176c9200 --- /dev/null +++ b/server/migrations/205_issue_status_id_column.down.sql @@ -0,0 +1 @@ +ALTER TABLE issue DROP COLUMN IF EXISTS status_id; diff --git a/server/migrations/205_issue_status_id_column.up.sql b/server/migrations/205_issue_status_id_column.up.sql new file mode 100644 index 00000000000..9bc6aad7541 --- /dev/null +++ b/server/migrations/205_issue_status_id_column.up.sql @@ -0,0 +1,6 @@ +-- Phase 1: authoritative issue.status_id, nullable during the migration +-- window. No FK (CLAUDE.md); resolved to issue_status in app code. Nothing +-- reads or writes it yet -- the legacy issue.status TEXT column stays the +-- source of truth until the Phase 2 double-write lands. Its index is created +-- CONCURRENTLY in migration 206. +ALTER TABLE issue ADD COLUMN IF NOT EXISTS status_id UUID; diff --git a/server/migrations/206_issue_status_id_index.down.sql b/server/migrations/206_issue_status_id_index.down.sql new file mode 100644 index 00000000000..48ecf9b7981 --- /dev/null +++ b/server/migrations/206_issue_status_id_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS issue_status_id_idx; diff --git a/server/migrations/206_issue_status_id_index.up.sql b/server/migrations/206_issue_status_id_index.up.sql new file mode 100644 index 00000000000..a2f9c3f3ed5 --- /dev/null +++ b/server/migrations/206_issue_status_id_index.up.sql @@ -0,0 +1,3 @@ +-- Lookup / backfill index for issue.status_id. Built CONCURRENTLY because +-- issue is a hot table; kept in its own single-statement migration. +CREATE INDEX CONCURRENTLY IF NOT EXISTS issue_status_id_idx ON issue (status_id); diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 2c94c82b140..d2172ea80c9 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -179,7 +179,7 @@ INSERT INTO issue ( ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 -) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties +) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` type CreateIssueParams struct { @@ -248,6 +248,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } @@ -261,7 +262,7 @@ INSERT INTO issue ( ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 -) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties +) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` type CreateIssueWithOriginParams struct { @@ -334,6 +335,7 @@ func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWith &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } @@ -362,7 +364,7 @@ UPDATE issue SET metadata = metadata - $1::text, updated_at = now() WHERE id = $2 AND workspace_id = $3 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` type DeleteIssueMetadataKeyParams struct { @@ -403,12 +405,13 @@ func (q *Queries) DeleteIssueMetadataKey(ctx context.Context, arg DeleteIssueMet &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } const findActiveDuplicateIssue = `-- name: FindActiveDuplicateIssue :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') AND project_id IS NOT DISTINCT FROM $2::uuid @@ -460,12 +463,13 @@ func (q *Queries) FindActiveDuplicateIssue(ctx context.Context, arg FindActiveDu &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } const findRecentAutopilotDuplicateIssue = `-- name: FindRecentAutopilotDuplicateIssue :one -SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.origin_type, i.origin_id, i.first_executed_at, i.start_date, i.metadata, i.stage, i.properties FROM issue i +SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.origin_type, i.origin_id, i.first_executed_at, i.start_date, i.metadata, i.stage, i.properties, i.status_id FROM issue i WHERE i.workspace_id = $1 AND i.status NOT IN ('done', 'cancelled') AND i.origin_type = 'autopilot' @@ -528,12 +532,13 @@ func (q *Queries) FindRecentAutopilotDuplicateIssue(ctx context.Context, arg Fin &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } const getIssue = `-- name: GetIssue :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id FROM issue WHERE id = $1 ` @@ -567,12 +572,13 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) { &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } const getIssueByNumber = `-- name: GetIssueByNumber :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id FROM issue WHERE workspace_id = $1 AND number = $2 ` @@ -611,12 +617,13 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } const getIssueByOrigin = `-- name: GetIssueByOrigin :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id FROM issue WHERE workspace_id = $1 AND origin_type = $2 AND origin_id = $3 @@ -664,12 +671,13 @@ func (q *Queries) GetIssueByOrigin(ctx context.Context, arg GetIssueByOriginPara &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } const getIssueInWorkspace = `-- name: GetIssueInWorkspace :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id FROM issue WHERE id = $1 AND workspace_id = $2 ` @@ -708,12 +716,13 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } const listChildIssues = `-- name: ListChildIssues :many -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id FROM issue WHERE parent_issue_id = $1 ORDER BY number ASC ` @@ -760,6 +769,7 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ); err != nil { return nil, err } @@ -772,7 +782,7 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID } const listChildrenByParents = `-- name: ListChildrenByParents :many -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id FROM issue WHERE workspace_id = $1 AND parent_issue_id = ANY($2::uuid[]) ORDER BY parent_issue_id, number ASC @@ -826,6 +836,7 @@ func (q *Queries) ListChildrenByParents(ctx context.Context, arg ListChildrenByP &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ); err != nil { return nil, err } @@ -1197,7 +1208,7 @@ UPDATE issue SET metadata = jsonb_set(metadata, ARRAY[$1::text], $2::jsonb), updated_at = now() WHERE id = $3 AND workspace_id = $4 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` type SetIssueMetadataKeyParams struct { @@ -1246,6 +1257,7 @@ func (q *Queries) SetIssueMetadataKey(ctx context.Context, arg SetIssueMetadataK &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } @@ -1266,7 +1278,7 @@ UPDATE issue SET stage = $13, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` type UpdateIssueParams struct { @@ -1329,6 +1341,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } @@ -1338,7 +1351,7 @@ UPDATE issue SET status = $2, updated_at = now() WHERE id = $1 AND workspace_id = $3 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` type UpdateIssueStatusParams struct { @@ -1378,6 +1391,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } diff --git a/server/pkg/db/generated/issue_property.sql.go b/server/pkg/db/generated/issue_property.sql.go index 3dac7fb8590..e7ca2eb40fe 100644 --- a/server/pkg/db/generated/issue_property.sql.go +++ b/server/pkg/db/generated/issue_property.sql.go @@ -120,7 +120,7 @@ UPDATE issue SET properties = properties - $1::text, updated_at = now() WHERE id = $2::uuid AND workspace_id = $3::uuid -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` type DeleteIssuePropertyValueParams struct { @@ -159,6 +159,7 @@ func (q *Queries) DeleteIssuePropertyValue(ctx context.Context, arg DeleteIssueP &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } @@ -266,7 +267,7 @@ UPDATE issue SET properties = jsonb_set(properties, ARRAY[$1::text], $2::jsonb, true), updated_at = now() WHERE id = $3::uuid AND workspace_id = $4::uuid -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` type SetIssuePropertyValueParams struct { @@ -313,6 +314,7 @@ func (q *Queries) SetIssuePropertyValue(ctx context.Context, arg SetIssuePropert &i.Metadata, &i.Stage, &i.Properties, + &i.StatusID, ) return i, err } diff --git a/server/pkg/db/generated/issue_status.sql.go b/server/pkg/db/generated/issue_status.sql.go new file mode 100644 index 00000000000..e3afb84ede7 --- /dev/null +++ b/server/pkg/db/generated/issue_status.sql.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: issue_status.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countWorkspaceIssueStatuses = `-- name: CountWorkspaceIssueStatuses :one +SELECT COUNT(*) FROM issue_status +WHERE workspace_id = $1::uuid + AND archived_at IS NULL +` + +// Count of active statuses in a workspace (seed / backfill invariant checks). +func (q *Queries) CountWorkspaceIssueStatuses(ctx context.Context, workspaceID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countWorkspaceIssueStatuses, workspaceID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const ensureWorkspaceSystemIssueStatuses = `-- name: EnsureWorkspaceSystemIssueStatuses :exec +WITH ws AS ( + SELECT id FROM workspace WHERE id = $1::uuid FOR KEY SHARE +) +INSERT INTO issue_status ( + workspace_id, name, description, icon, color, category, system_key, is_default, position +) +SELECT ws.id, v.name, v.description, v.icon, v.color, v.category, v.system_key, v.is_default, v.position +FROM ws +CROSS JOIN (VALUES + ('Backlog', '', 'backlog', 'muted-foreground', 'backlog', 'backlog', TRUE, 0::double precision), + ('Todo', '', 'todo', 'muted-foreground', 'todo', 'todo', TRUE, 0), + ('In Progress', '', 'in_progress', 'warning', 'in_progress', 'in_progress', TRUE, 0), + ('In Review', '', 'in_review', 'success', 'in_progress', 'in_review', FALSE, 1), + ('Blocked', '', 'blocked', 'destructive', 'in_progress', 'blocked', FALSE, 2), + ('Done', '', 'done', 'info', 'done', 'done', TRUE, 0), + ('Cancelled', '', 'cancelled', 'muted-foreground', 'cancelled', 'cancelled', TRUE, 0) +) AS v(name, description, icon, color, category, system_key, is_default, position) +ON CONFLICT (workspace_id, system_key) WHERE system_key IS NOT NULL DO NOTHING +` + +// Idempotently seed the 7 built-in system statuses for a workspace (MUL-4809). +// +// The `WITH ws ... FOR KEY SHARE` clause is the no-FK integrity guard. It takes +// the same lock the workspace delete/create protocol uses +// (LockWorkspaceForChatSessionCreate), so a concurrent DeleteWorkspace (which +// holds FOR UPDATE) cannot interleave: if the workspace row is already gone, ws +// is empty and zero rows are inserted, so Backfill can never re-create orphan +// statuses for a workspace deleted mid-walk. Callers need no separate existence +// check. At workspace-create time the row is visible to the same transaction, +// so the seed proceeds normally. +// +// ON CONFLICT names the (workspace_id, system_key) partial unique index as the +// EXPLICIT arbiter, so a re-run is a no-op while any OTHER unique violation +// surfaces loudly instead of silently dropping a built-in. +// +// category is the only machine semantics; icon (the frontend renders a bespoke +// SVG keyed on the status) and color (a semantic UI token) match the current +// hardcoded status visuals. is_default marks the Category alias target for the +// five categories (in_review and blocked are non-default in_progress statuses). +func (q *Queries) EnsureWorkspaceSystemIssueStatuses(ctx context.Context, workspaceID pgtype.UUID) error { + _, err := q.db.Exec(ctx, ensureWorkspaceSystemIssueStatuses, workspaceID) + return err +} + +const listWorkspaceIssueStatuses = `-- name: ListWorkspaceIssueStatuses :many +SELECT id, workspace_id, name, description, icon, color, category, system_key, is_default, position, archived_at, created_at, updated_at FROM issue_status +WHERE workspace_id = $1::uuid + AND ($2::bool OR archived_at IS NULL) +ORDER BY + CASE category + WHEN 'backlog' THEN 0 + WHEN 'todo' THEN 1 + WHEN 'in_progress' THEN 2 + WHEN 'done' THEN 3 + WHEN 'cancelled' THEN 4 + END, + position ASC, + created_at ASC +` + +type ListWorkspaceIssueStatusesParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + IncludeArchived bool `json:"include_archived"` +} + +// Active statuses for a workspace, ordered by the fixed Category order then +// intra-category position. Used by tests and (later phases) the catalog API. +func (q *Queries) ListWorkspaceIssueStatuses(ctx context.Context, arg ListWorkspaceIssueStatusesParams) ([]IssueStatus, error) { + rows, err := q.db.Query(ctx, listWorkspaceIssueStatuses, arg.WorkspaceID, arg.IncludeArchived) + if err != nil { + return nil, err + } + defer rows.Close() + items := []IssueStatus{} + for rows.Next() { + var i IssueStatus + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.Icon, + &i.Color, + &i.Category, + &i.SystemKey, + &i.IsDefault, + &i.Position, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index b7f277e5409..b4152c78c77 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -578,6 +578,7 @@ type Issue struct { Metadata []byte `json:"metadata"` Stage pgtype.Int4 `json:"stage"` Properties []byte `json:"properties"` + StatusID pgtype.UUID `json:"status_id"` } type IssueDependency struct { @@ -632,6 +633,22 @@ type IssueReaction struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type IssueStatus struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Color string `json:"color"` + Category string `json:"category"` + SystemKey pgtype.Text `json:"system_key"` + IsDefault bool `json:"is_default"` + Position float64 `json:"position"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type IssueSubscriber struct { IssueID pgtype.UUID `json:"issue_id"` UserType string `json:"user_type"` diff --git a/server/pkg/db/generated/workspace.sql.go b/server/pkg/db/generated/workspace.sql.go index 456c0e34d4a..3a1aa759618 100644 --- a/server/pkg/db/generated/workspace.sql.go +++ b/server/pkg/db/generated/workspace.sql.go @@ -113,6 +113,12 @@ cleared_installations AS ( cleared_issue_properties AS ( DELETE FROM issue_property WHERE workspace_id = $1 ), +cleared_issue_statuses AS ( + -- issue_status has no FK to workspace (MUL-4809); sweep the whole catalog, + -- archived rows included, so a deleted workspace never leaves orphan status + -- definitions behind. + DELETE FROM issue_status WHERE workspace_id = $1 +), deleted_pending_check_suites AS ( DELETE FROM github_pending_check_suite WHERE workspace_id = $1 ) @@ -120,10 +126,10 @@ DELETE FROM workspace WHERE workspace.id = $1 ` // The channel_* tables (MUL-3515 §4), resource-label junctions, and custom issue -// property definitions carry NO FK to workspace, so — unlike the CASCADE-backed -// tables the DELETE below sweeps — they are not cleaned up implicitly. Remove -// their workspace-owned rows here so they commit or roll back atomically with -// the workspace row. +// property/status definitions carry NO FK to workspace, so — unlike the +// CASCADE-backed tables the DELETE below sweeps — they are not cleaned up +// implicitly. Remove their workspace-owned rows here so they commit or roll +// back atomically with the workspace row. func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error { _, err := q.db.Exec(ctx, deleteWorkspace, id) return err @@ -228,6 +234,33 @@ func (q *Queries) IncrementIssueCounter(ctx context.Context, id pgtype.UUID) (in return issue_counter, err } +const listAllWorkspaceIDs = `-- name: ListAllWorkspaceIDs :many +SELECT id FROM workspace ORDER BY created_at ASC +` + +// Operational scan of every workspace id, for server-side reconcile jobs such +// as the issue-status boot backfill (MUL-4809). No membership filter -- callers +// must be trusted server jobs, never request handlers. +func (q *Queries) ListAllWorkspaceIDs(ctx context.Context) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listAllWorkspaceIDs) + if err != nil { + return nil, err + } + defer rows.Close() + items := []pgtype.UUID{} + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listDaemonWorkspaces = `-- name: ListDaemonWorkspaces :many SELECT w.id, w.name FROM member m diff --git a/server/pkg/db/queries/issue_status.sql b/server/pkg/db/queries/issue_status.sql new file mode 100644 index 00000000000..afc7b6c7b4f --- /dev/null +++ b/server/pkg/db/queries/issue_status.sql @@ -0,0 +1,61 @@ +-- name: EnsureWorkspaceSystemIssueStatuses :exec +-- Idempotently seed the 7 built-in system statuses for a workspace (MUL-4809). +-- +-- The `WITH ws ... FOR KEY SHARE` clause is the no-FK integrity guard. It takes +-- the same lock the workspace delete/create protocol uses +-- (LockWorkspaceForChatSessionCreate), so a concurrent DeleteWorkspace (which +-- holds FOR UPDATE) cannot interleave: if the workspace row is already gone, ws +-- is empty and zero rows are inserted, so Backfill can never re-create orphan +-- statuses for a workspace deleted mid-walk. Callers need no separate existence +-- check. At workspace-create time the row is visible to the same transaction, +-- so the seed proceeds normally. +-- +-- ON CONFLICT names the (workspace_id, system_key) partial unique index as the +-- EXPLICIT arbiter, so a re-run is a no-op while any OTHER unique violation +-- surfaces loudly instead of silently dropping a built-in. +-- +-- category is the only machine semantics; icon (the frontend renders a bespoke +-- SVG keyed on the status) and color (a semantic UI token) match the current +-- hardcoded status visuals. is_default marks the Category alias target for the +-- five categories (in_review and blocked are non-default in_progress statuses). +WITH ws AS ( + SELECT id FROM workspace WHERE id = sqlc.arg('workspace_id')::uuid FOR KEY SHARE +) +INSERT INTO issue_status ( + workspace_id, name, description, icon, color, category, system_key, is_default, position +) +SELECT ws.id, v.name, v.description, v.icon, v.color, v.category, v.system_key, v.is_default, v.position +FROM ws +CROSS JOIN (VALUES + ('Backlog', '', 'backlog', 'muted-foreground', 'backlog', 'backlog', TRUE, 0::double precision), + ('Todo', '', 'todo', 'muted-foreground', 'todo', 'todo', TRUE, 0), + ('In Progress', '', 'in_progress', 'warning', 'in_progress', 'in_progress', TRUE, 0), + ('In Review', '', 'in_review', 'success', 'in_progress', 'in_review', FALSE, 1), + ('Blocked', '', 'blocked', 'destructive', 'in_progress', 'blocked', FALSE, 2), + ('Done', '', 'done', 'info', 'done', 'done', TRUE, 0), + ('Cancelled', '', 'cancelled', 'muted-foreground', 'cancelled', 'cancelled', TRUE, 0) +) AS v(name, description, icon, color, category, system_key, is_default, position) +ON CONFLICT (workspace_id, system_key) WHERE system_key IS NOT NULL DO NOTHING; + +-- name: ListWorkspaceIssueStatuses :many +-- Active statuses for a workspace, ordered by the fixed Category order then +-- intra-category position. Used by tests and (later phases) the catalog API. +SELECT * FROM issue_status +WHERE workspace_id = sqlc.arg('workspace_id')::uuid + AND (sqlc.arg('include_archived')::bool OR archived_at IS NULL) +ORDER BY + CASE category + WHEN 'backlog' THEN 0 + WHEN 'todo' THEN 1 + WHEN 'in_progress' THEN 2 + WHEN 'done' THEN 3 + WHEN 'cancelled' THEN 4 + END, + position ASC, + created_at ASC; + +-- name: CountWorkspaceIssueStatuses :one +-- Count of active statuses in a workspace (seed / backfill invariant checks). +SELECT COUNT(*) FROM issue_status +WHERE workspace_id = sqlc.arg('workspace_id')::uuid + AND archived_at IS NULL; diff --git a/server/pkg/db/queries/workspace.sql b/server/pkg/db/queries/workspace.sql index e8d65adff55..5e378220b27 100644 --- a/server/pkg/db/queries/workspace.sql +++ b/server/pkg/db/queries/workspace.sql @@ -7,6 +7,12 @@ JOIN workspace w ON w.id = m.workspace_id WHERE m.user_id = $1 ORDER BY w.created_at ASC; +-- name: ListAllWorkspaceIDs :many +-- Operational scan of every workspace id, for server-side reconcile jobs such +-- as the issue-status boot backfill (MUL-4809). No membership filter -- callers +-- must be trusted server jobs, never request handlers. +SELECT id FROM workspace ORDER BY created_at ASC; + -- name: ListDaemonWorkspaces :many -- Daemons only need the membership set and display name to discover which -- workspaces should have local runtimes. Keep this projection intentionally @@ -92,10 +98,10 @@ SELECT id FROM workspace WHERE id = $1 FOR KEY SHARE; -- name: DeleteWorkspace :exec -- The channel_* tables (MUL-3515 §4), resource-label junctions, and custom issue --- property definitions carry NO FK to workspace, so — unlike the CASCADE-backed --- tables the DELETE below sweeps — they are not cleaned up implicitly. Remove --- their workspace-owned rows here so they commit or roll back atomically with --- the workspace row. +-- property/status definitions carry NO FK to workspace, so — unlike the +-- CASCADE-backed tables the DELETE below sweeps — they are not cleaned up +-- implicitly. Remove their workspace-owned rows here so they commit or roll +-- back atomically with the workspace row. WITH ws_installations AS ( SELECT id FROM channel_installation WHERE workspace_id = $1 ), @@ -156,6 +162,12 @@ cleared_installations AS ( cleared_issue_properties AS ( DELETE FROM issue_property WHERE workspace_id = $1 ), +cleared_issue_statuses AS ( + -- issue_status has no FK to workspace (MUL-4809); sweep the whole catalog, + -- archived rows included, so a deleted workspace never leaves orphan status + -- definitions behind. + DELETE FROM issue_status WHERE workspace_id = $1 +), deleted_pending_check_suites AS ( DELETE FROM github_pending_check_suite WHERE workspace_id = $1 ) From aecfeaa4047b355a98de767654f41bc46a013507 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 13:11:54 +0800 Subject: [PATCH 02/41] feat(issue-status): alias resolver for Category/legacy/name inputs (MUL-4809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 foundation. Resolve() maps a status string to a workspace's issue_status row with a fixed priority order (plan §3.1): 1. Category alias (backlog|todo|in_progress|done|cancelled) -> that Category's current default status (survives renames of the default). 2. Legacy alias (in_review|blocked) -> the built-in with that system_key. 3. Exact active display name (case-insensitive). No fuzzy matching; anything else returns *InvalidStatusError enumerating the legal Category aliases, legacy aliases, and active names so the API/CLI can echo them back instead of leaving an agent to guess after a rename. Category aliases use underscores and never collide with space-rendered display names. Also exposes IsReservedStatusToken (the 7 tokens no custom status may use) and the Categories list, both consumed by the upcoming status-management API and double-write paths. DB-backed tests cover every branch, including plan example A (renamed Todo default still reached by `todo`, custom Todo reached only by exact name). Co-authored-by: multica-agent --- server/internal/issuestatus/resolver.go | 153 +++++++++++++++ server/internal/issuestatus/resolver_test.go | 190 +++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 server/internal/issuestatus/resolver.go create mode 100644 server/internal/issuestatus/resolver_test.go diff --git a/server/internal/issuestatus/resolver.go b/server/internal/issuestatus/resolver.go new file mode 100644 index 00000000000..1d7ea076b14 --- /dev/null +++ b/server/internal/issuestatus/resolver.go @@ -0,0 +1,153 @@ +package issuestatus + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// Categories are the 5 immutable machine-readable status categories. This is +// the ONLY status semantics any automation may branch on. +var Categories = []string{"backlog", "todo", "in_progress", "done", "cancelled"} + +// categoryAliasSet is the set of Category alias tokens. Each resolves to the +// workspace's current default status for that Category. +var categoryAliasSet = map[string]struct{}{ + "backlog": {}, "todo": {}, "in_progress": {}, "done": {}, "cancelled": {}, +} + +// legacyAliases maps the 2 legacy status tokens to the built-in system_key they +// resolve to. They survive display-name renames because they key on system_key, +// not on the (mutable) name. +var legacyAliases = map[string]string{ + "in_review": "in_review", + "blocked": "blocked", +} + +// ReservedStatusTokens are the 7 tokens that no custom status display name may +// take, because the alias resolver claims them first (5 Category aliases + 2 +// legacy aliases). The status-management API rejects a create/rename to any of +// these. +var ReservedStatusTokens = []string{ + "backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled", +} + +// IsReservedStatusToken reports whether name (case-insensitive, trimmed) is one +// of the reserved alias tokens. +func IsReservedStatusToken(name string) bool { + norm := strings.ToLower(strings.TrimSpace(name)) + if _, ok := categoryAliasSet[norm]; ok { + return true + } + _, ok := legacyAliases[norm] + return ok +} + +// InvalidStatusError is returned by Resolve when the input matches no Category +// alias, legacy alias, or active display name. It enumerates the currently +// legal values so the API/CLI can echo them back (plan §3.2) instead of leaving +// an agent to guess after a status has been renamed. It maps to HTTP 400 +// invalid_status at the handler boundary. +type InvalidStatusError struct { + Input string + CategoryAliases []string // the 5 Category alias tokens + LegacyAliases []string // in_review, blocked + Names []string // exact active display names +} + +func (e *InvalidStatusError) Error() string { + return fmt.Sprintf("invalid status %q: expected a Category alias (%s), a legacy alias (%s), or an exact status name (%s)", + e.Input, + strings.Join(e.CategoryAliases, ", "), + strings.Join(e.LegacyAliases, ", "), + strings.Join(e.Names, ", "), + ) +} + +// Resolve maps a status string to the workspace's issue_status row (MUL-4809, +// plan §3.1). Resolution is case-insensitive, trims surrounding whitespace, and +// applies a fixed priority order: +// +// 1. Category alias (backlog | todo | in_progress | done | cancelled) -> +// that Category's current default status. So `todo` keeps working even +// after the default Todo status is renamed. +// 2. Legacy alias (in_review | blocked) -> the built-in status with that +// system_key. Survives renames for the same reason. +// 3. Exact active display name (case-insensitive) -> that status. This is how +// a caller targets a specific workflow stage or a custom status. +// +// No fuzzy matching: anything else yields *InvalidStatusError carrying the +// legal values. Category aliases use underscores (`in_progress`) and never +// collide with display names, which render with spaces (`In Progress`). +func Resolve(ctx context.Context, q *db.Queries, workspaceID pgtype.UUID, input string) (db.IssueStatus, error) { + norm := strings.ToLower(strings.TrimSpace(input)) + + statuses, err := q.ListWorkspaceIssueStatuses(ctx, db.ListWorkspaceIssueStatusesParams{WorkspaceID: workspaceID}) + if err != nil { + return db.IssueStatus{}, fmt.Errorf("load workspace issue statuses: %w", err) + } + + if norm != "" { + // 1. Category alias -> current default for that Category. + if _, ok := categoryAliasSet[norm]; ok { + for _, s := range statuses { + if s.Category == norm && s.IsDefault { + return s, nil + } + } + // The one-default-per-category invariant is seeded and maintained in + // the service layer; a missing default is a data-integrity bug, not a + // user input error. + return db.IssueStatus{}, fmt.Errorf("workspace %s has no default status for category %q", uuidToString(workspaceID), norm) + } + + // 2. Legacy alias -> built-in status by system_key. + if systemKey, ok := legacyAliases[norm]; ok { + for _, s := range statuses { + if s.SystemKey.Valid && s.SystemKey.String == systemKey { + return s, nil + } + } + return db.IssueStatus{}, fmt.Errorf("workspace %s is missing built-in status %q", uuidToString(workspaceID), systemKey) + } + + // 3. Exact active display name (case-insensitive). + for _, s := range statuses { + if strings.ToLower(s.Name) == norm { + return s, nil + } + } + } + + return db.IssueStatus{}, newInvalidStatusError(input, statuses) +} + +// newInvalidStatusError builds the enumerated-options error from the current +// active catalog. +func newInvalidStatusError(input string, statuses []db.IssueStatus) *InvalidStatusError { + names := make([]string, 0, len(statuses)) + for _, s := range statuses { + names = append(names, s.Name) + } + sort.Strings(names) + return &InvalidStatusError{ + Input: input, + CategoryAliases: append([]string(nil), Categories...), + LegacyAliases: []string{"in_review", "blocked"}, + Names: names, + } +} + +// uuidToString renders a pgtype.UUID for error messages. Empty/invalid UUIDs +// render as the zero UUID rather than panicking. +func uuidToString(id pgtype.UUID) string { + if !id.Valid { + return "00000000-0000-0000-0000-000000000000" + } + b := id.Bytes + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/server/internal/issuestatus/resolver_test.go b/server/internal/issuestatus/resolver_test.go new file mode 100644 index 00000000000..1fd148de5d0 --- /dev/null +++ b/server/internal/issuestatus/resolver_test.go @@ -0,0 +1,190 @@ +package issuestatus + +import ( + "context" + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// seededWorkspace returns a workspace with the 7 built-in statuses already +// seeded, ready for resolution tests. +func seededWorkspace(ctx context.Context, t *testing.T) (pgtype.UUID, *db.Queries) { + t.Helper() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("seed: %v", err) + } + return wsID, q +} + +func TestResolveCategoryAlias(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + cases := map[string]string{ // input -> expected system_key of the category default + "backlog": "backlog", + "todo": "todo", + "in_progress": "in_progress", + "done": "done", + "cancelled": "cancelled", + " TODO ": "todo", // trimmed + case-insensitive + "In_Progress": "in_progress", // case-insensitive + } + for input, wantKey := range cases { + s, err := Resolve(ctx, q, wsID, input) + if err != nil { + t.Errorf("Resolve(%q): %v", input, err) + continue + } + if !s.IsDefault { + t.Errorf("Resolve(%q): category alias must resolve to a default status, got is_default=false (%q)", input, s.Name) + } + if !s.SystemKey.Valid || s.SystemKey.String != wantKey { + t.Errorf("Resolve(%q): want default system_key %q, got %v", input, wantKey, s.SystemKey) + } + } +} + +func TestResolveLegacyAlias(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + cases := map[string]struct{ key, category string }{ + "in_review": {"in_review", "in_progress"}, + "blocked": {"blocked", "in_progress"}, + "BLOCKED": {"blocked", "in_progress"}, + } + for input, want := range cases { + s, err := Resolve(ctx, q, wsID, input) + if err != nil { + t.Errorf("Resolve(%q): %v", input, err) + continue + } + if !s.SystemKey.Valid || s.SystemKey.String != want.key { + t.Errorf("Resolve(%q): want system_key %q, got %v", input, want.key, s.SystemKey) + } + if s.Category != want.category { + t.Errorf("Resolve(%q): want category %q, got %q", input, want.category, s.Category) + } + if s.IsDefault { + t.Errorf("Resolve(%q): in_review/blocked are non-default statuses, got is_default=true", input) + } + } +} + +func TestResolveExactName(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + // Display names render with spaces; the underscore alias is a separate path. + cases := map[string]string{ // name input -> expected system_key + "In Progress": "in_progress", + "in progress": "in_progress", // case-insensitive + "In Review": "in_review", + "Done": "done", + } + for input, wantKey := range cases { + s, err := Resolve(ctx, q, wsID, input) + if err != nil { + t.Errorf("Resolve(%q): %v", input, err) + continue + } + if !s.SystemKey.Valid || s.SystemKey.String != wantKey { + t.Errorf("Resolve(%q): want system_key %q, got %v", input, wantKey, s.SystemKey) + } + } +} + +// TestResolveCategoryAliasFollowsRenamedDefault is plan example A: renaming the +// default Todo status must not break the `todo` alias, and a non-default custom +// Todo status is reachable only by its exact name. +func TestResolveCategoryAliasFollowsRenamedDefault(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + if _, err := testPool.Exec(ctx, + "UPDATE issue_status SET name = $2 WHERE workspace_id = $1 AND system_key = 'todo'", + wsID, "待排期"); err != nil { + t.Fatalf("rename todo default: %v", err) + } + if _, err := testPool.Exec(ctx, + `INSERT INTO issue_status (workspace_id, name, description, icon, color, category, system_key, is_default, position) + VALUES ($1, '需求澄清', '', 'todo', 'muted-foreground', 'todo', NULL, FALSE, 10)`, + wsID); err != nil { + t.Fatalf("insert custom todo status: %v", err) + } + + // `todo` alias still lands on the (renamed) default. + got, err := Resolve(ctx, q, wsID, "todo") + if err != nil { + t.Fatalf("Resolve(todo): %v", err) + } + if got.Name != "待排期" || !got.IsDefault { + t.Fatalf("Resolve(todo): want renamed default 待排期, got name=%q is_default=%v", got.Name, got.IsDefault) + } + + // The custom non-default status is reachable by exact name only. + got, err = Resolve(ctx, q, wsID, "需求澄清") + if err != nil { + t.Fatalf("Resolve(需求澄清): %v", err) + } + if got.Name != "需求澄清" || got.IsDefault { + t.Fatalf("Resolve(需求澄清): want custom non-default status, got name=%q is_default=%v", got.Name, got.IsDefault) + } + + // And the renamed default is reachable by its new exact name too. + got, err = Resolve(ctx, q, wsID, "待排期") + if err != nil { + t.Fatalf("Resolve(待排期): %v", err) + } + if !got.SystemKey.Valid || got.SystemKey.String != "todo" { + t.Fatalf("Resolve(待排期): want the todo built-in, got system_key=%v", got.SystemKey) + } +} + +func TestResolveUnknownReturnsInvalidStatusError(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + _, err := Resolve(ctx, q, wsID, "no-such-status") + if err == nil { + t.Fatal("Resolve(no-such-status): want error, got nil") + } + var invalid *InvalidStatusError + if !errors.As(err, &invalid) { + t.Fatalf("Resolve(no-such-status): want *InvalidStatusError, got %T: %v", err, err) + } + if len(invalid.CategoryAliases) != 5 { + t.Errorf("want 5 category aliases in error, got %v", invalid.CategoryAliases) + } + if len(invalid.LegacyAliases) != 2 { + t.Errorf("want 2 legacy aliases in error, got %v", invalid.LegacyAliases) + } + if len(invalid.Names) != len(wantSystemStatuses) { + t.Errorf("want %d names in error, got %d (%v)", len(wantSystemStatuses), len(invalid.Names), invalid.Names) + } + + // Empty input is also invalid, not a silent match. + if _, err := Resolve(ctx, q, wsID, " "); !errors.As(err, &invalid) { + t.Errorf("Resolve(blank): want *InvalidStatusError, got %v", err) + } +} + +func TestIsReservedStatusToken(t *testing.T) { + reserved := []string{"backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled", " TODO ", "In_Review"} + for _, tok := range reserved { + if !IsReservedStatusToken(tok) { + t.Errorf("IsReservedStatusToken(%q) = false, want true", tok) + } + } + notReserved := []string{"待排期", "in review", "in progress", "custom", ""} + for _, tok := range notReserved { + if IsReservedStatusToken(tok) { + t.Errorf("IsReservedStatusToken(%q) = true, want false", tok) + } + } +} From 68d3ff62e67c214b550a6b7591204c1742968e5b Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 13:19:52 +0800 Subject: [PATCH 03/41] feat(issue-status): double-write status_id on issue write paths (MUL-4809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 double read/write, write side. Every issue write query that sets the legacy status token now also populates the authoritative issue.status_id from the built-in status with the matching system_key, scoped to the issue's workspace: - CreateIssue / CreateIssueWithOrigin: derive status_id in the INSERT. - UpdateIssueStatus: re-derive alongside the status write. - UpdateIssue: re-derive only when status is actually being changed. status stays the source of truth this phase; status_id is a mirror. The derivation is a scalar subquery on the (workspace_id, system_key) unique index, so it is cheap and returns NULL when the workspace catalog is not yet seeded (rolling deploy) — status_id simply stays NULL, no write fails. Subquery/target columns are table-qualified to avoid the issue/issue_status ambiguity. No new query params, so no call-site changes. DB tests assert status_id is written on create, re-derived on status update (both paths), left untouched on a non-status update, and NULL for an unseeded workspace. Full handler and service suites pass unchanged. Co-authored-by: multica-agent --- .../internal/issuestatus/doublewrite_test.go | 134 ++++++++++++++++++ server/pkg/db/generated/issue.sql.go | 29 +++- server/pkg/db/queries/issue.sql | 29 +++- 3 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 server/internal/issuestatus/doublewrite_test.go diff --git a/server/internal/issuestatus/doublewrite_test.go b/server/internal/issuestatus/doublewrite_test.go new file mode 100644 index 00000000000..ab29fe99106 --- /dev/null +++ b/server/internal/issuestatus/doublewrite_test.go @@ -0,0 +1,134 @@ +package issuestatus + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// These tests pin the Phase 2 double-write (MUL-4809): every issue write path +// that sets the legacy status token must also populate the authoritative +// issue.status_id from the built-in status with the matching system_key. + +func newUUID(ctx context.Context, t *testing.T) pgtype.UUID { + t.Helper() + var id pgtype.UUID + if err := testPool.QueryRow(ctx, "SELECT gen_random_uuid()").Scan(&id); err != nil { + t.Fatalf("generate uuid: %v", err) + } + return id +} + +func builtinStatusID(ctx context.Context, t *testing.T, q *db.Queries, wsID pgtype.UUID, systemKey string) pgtype.UUID { + t.Helper() + statuses, err := q.ListWorkspaceIssueStatuses(ctx, db.ListWorkspaceIssueStatusesParams{WorkspaceID: wsID}) + if err != nil { + t.Fatalf("list statuses: %v", err) + } + for _, s := range statuses { + if s.SystemKey.Valid && s.SystemKey.String == systemKey { + return s.ID + } + } + t.Fatalf("no built-in status with system_key %q", systemKey) + return pgtype.UUID{} +} + +func createTestIssue(ctx context.Context, t *testing.T, q *db.Queries, wsID pgtype.UUID, status string) db.Issue { + t.Helper() + iss, err := q.CreateIssue(ctx, db.CreateIssueParams{ + WorkspaceID: wsID, + Title: "double-write-test", + Status: status, + Priority: "none", + CreatorType: "member", + CreatorID: newUUID(ctx, t), + Number: 1, + }) + if err != nil { + t.Fatalf("create issue: %v", err) + } + return iss +} + +func TestCreateIssueDoubleWritesStatusID(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID, _ := seededWorkspace(ctx, t) + + iss := createTestIssue(ctx, t, q, wsID, "in_progress") + + want := builtinStatusID(ctx, t, q, wsID, "in_progress") + if !iss.StatusID.Valid || iss.StatusID != want { + t.Fatalf("CreateIssue did not double-write status_id: got %v, want %v", iss.StatusID, want) + } +} + +func TestUpdateIssueStatusDoubleWritesStatusID(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID, _ := seededWorkspace(ctx, t) + iss := createTestIssue(ctx, t, q, wsID, "todo") + + updated, err := q.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{ + ID: iss.ID, Status: "done", WorkspaceID: wsID, + }) + if err != nil { + t.Fatalf("update status: %v", err) + } + want := builtinStatusID(ctx, t, q, wsID, "done") + if !updated.StatusID.Valid || updated.StatusID != want { + t.Fatalf("UpdateIssueStatus did not re-derive status_id: got %v, want %v", updated.StatusID, want) + } +} + +func TestUpdateIssueReDerivesStatusIDOnlyOnStatusChange(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID, _ := seededWorkspace(ctx, t) + iss := createTestIssue(ctx, t, q, wsID, "todo") + + // Changing status re-derives status_id. + changed, err := q.UpdateIssue(ctx, db.UpdateIssueParams{ + ID: iss.ID, + Status: pgtype.Text{String: "done", Valid: true}, + }) + if err != nil { + t.Fatalf("update (status change): %v", err) + } + wantDone := builtinStatusID(ctx, t, q, wsID, "done") + if changed.StatusID != wantDone { + t.Fatalf("status change: got status_id %v, want %v", changed.StatusID, wantDone) + } + + // A title-only update (status narg NULL) must leave status_id untouched. + titleOnly, err := q.UpdateIssue(ctx, db.UpdateIssueParams{ + ID: iss.ID, + Title: pgtype.Text{String: "renamed", Valid: true}, + }) + if err != nil { + t.Fatalf("update (title only): %v", err) + } + if titleOnly.StatusID != wantDone { + t.Fatalf("title-only update changed status_id: got %v, want unchanged %v", titleOnly.StatusID, wantDone) + } +} + +// TestCreateIssueUnseededWorkspaceLeavesStatusIDNull covers the rolling-deploy +// window: before the workspace catalog is seeded, the derivation subquery +// returns no row and status_id stays NULL while status remains authoritative. +func TestCreateIssueUnseededWorkspaceLeavesStatusIDNull(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) // deliberately NOT seeded + + iss := createTestIssue(ctx, t, q, wsID, "todo") + if iss.StatusID.Valid { + t.Fatalf("unseeded workspace: status_id should be NULL, got %v", iss.StatusID) + } + if iss.Status != "todo" { + t.Fatalf("unseeded workspace: legacy status must still be written, got %q", iss.Status) + } +} diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index d2172ea80c9..a34fce4a29f 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -175,10 +175,15 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, start_date, due_date, number, project_id, - stage + stage, status_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16 + $16, + -- Phase 2 double-write (MUL-4809): mirror the legacy status token into the + -- authoritative status_id via its built-in system_key. Stays NULL until the + -- workspace catalog is seeded (rolling deploy), where status remains the + -- source of truth. + (SELECT issue_status.id FROM issue_status WHERE issue_status.workspace_id = $1 AND system_key = $4) ) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` @@ -258,10 +263,12 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, start_date, due_date, number, project_id, - origin_type, origin_id, stage + origin_type, origin_id, stage, status_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17, $18 + $16, $17, $18, + -- Phase 2 double-write (MUL-4809): see CreateIssue. + (SELECT issue_status.id FROM issue_status WHERE issue_status.workspace_id = $1 AND system_key = $4) ) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` @@ -1267,6 +1274,13 @@ UPDATE issue SET title = COALESCE($2, title), description = COALESCE($3, description), status = COALESCE($4, status), + -- Phase 2 double-write (MUL-4809): re-derive status_id only when status is + -- being changed, keyed on the built-in system_key; otherwise leave it. + status_id = CASE + WHEN $4::text IS NOT NULL + THEN (SELECT issue_status.id FROM issue_status WHERE issue_status.workspace_id = issue.workspace_id AND system_key = $4) + ELSE status_id + END, priority = COALESCE($5, priority), assignee_type = $6, assignee_id = $7, @@ -1277,7 +1291,7 @@ UPDATE issue SET project_id = $12, stage = $13, updated_at = now() -WHERE id = $1 +WHERE issue.id = $1 RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` @@ -1349,8 +1363,11 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue const updateIssueStatus = `-- name: UpdateIssueStatus :one UPDATE issue SET status = $2, + -- Phase 2 double-write (MUL-4809): mirror the legacy status into status_id + -- via its built-in system_key (scoped to the same workspace guard). + status_id = (SELECT issue_status.id FROM issue_status WHERE issue_status.workspace_id = $3 AND system_key = $2), updated_at = now() -WHERE id = $1 AND workspace_id = $3 +WHERE issue.id = $1 AND issue.workspace_id = $3 RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, status_id ` diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 0d603463200..8d2a39f46e4 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -74,10 +74,15 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, start_date, due_date, number, project_id, - stage + stage, status_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - sqlc.narg('stage') + sqlc.narg('stage'), + -- Phase 2 double-write (MUL-4809): mirror the legacy status token into the + -- authoritative status_id via its built-in system_key. Stays NULL until the + -- workspace catalog is seeded (rolling deploy), where status remains the + -- source of truth. + (SELECT issue_status.id FROM issue_status WHERE issue_status.workspace_id = $1 AND system_key = $4) ) RETURNING *; -- name: GetIssueByNumber :one @@ -89,6 +94,13 @@ UPDATE issue SET title = COALESCE(sqlc.narg('title'), title), description = COALESCE(sqlc.narg('description'), description), status = COALESCE(sqlc.narg('status'), status), + -- Phase 2 double-write (MUL-4809): re-derive status_id only when status is + -- being changed, keyed on the built-in system_key; otherwise leave it. + status_id = CASE + WHEN sqlc.narg('status')::text IS NOT NULL + THEN (SELECT issue_status.id FROM issue_status WHERE issue_status.workspace_id = issue.workspace_id AND system_key = sqlc.narg('status')) + ELSE status_id + END, priority = COALESCE(sqlc.narg('priority'), priority), assignee_type = sqlc.narg('assignee_type'), assignee_id = sqlc.narg('assignee_id'), @@ -99,15 +111,18 @@ UPDATE issue SET project_id = sqlc.narg('project_id'), stage = sqlc.narg('stage'), updated_at = now() -WHERE id = $1 +WHERE issue.id = $1 RETURNING *; -- name: UpdateIssueStatus :one -- Workspace_id in the WHERE clause is a SQL-layer tenant guard; see DeleteIssue. UPDATE issue SET status = $2, + -- Phase 2 double-write (MUL-4809): mirror the legacy status into status_id + -- via its built-in system_key (scoped to the same workspace guard). + status_id = (SELECT issue_status.id FROM issue_status WHERE issue_status.workspace_id = $3 AND system_key = $2), updated_at = now() -WHERE id = $1 AND workspace_id = $3 +WHERE issue.id = $1 AND issue.workspace_id = $3 RETURNING *; -- name: CreateIssueWithOrigin :one @@ -115,10 +130,12 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, start_date, due_date, number, project_id, - origin_type, origin_id, stage + origin_type, origin_id, stage, status_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - sqlc.narg('origin_type'), sqlc.narg('origin_id'), sqlc.narg('stage') + sqlc.narg('origin_type'), sqlc.narg('origin_id'), sqlc.narg('stage'), + -- Phase 2 double-write (MUL-4809): see CreateIssue. + (SELECT issue_status.id FROM issue_status WHERE issue_status.workspace_id = $1 AND system_key = $4) ) RETURNING *; -- name: LockIssueDuplicateKey :exec From 4fabe5914df17f6da1985bcd97f023224def687a Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 13:50:14 +0800 Subject: [PATCH 04/41] feat(issue-status): admin CRUD API for the status catalog (MUL-4809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET/POST/PATCH/DELETE /api/issue-statuses (plan §5): - GET returns the catalog plus the alias resolution table (5 Category aliases + in_review/blocked) and per-Category defaults; readable by any member/agent since it drives `issue status`. - POST creates a custom status (system_key NULL), validated against reserved alias tokens, the 5 Categories, an icon/color allowlist, a length cap, and a per-workspace active-custom cap. - PATCH edits name/description/icon/color/position and swaps the Category default; category/system_key/workspace_id are immutable (400). - DELETE archives (soft delete): system statuses and the current default are refused, and an in-use status requires a same-Category migrate_to_status_id, moving issues in the same tx. All writes run under a workspace advisory lock so the cap, name uniqueness, and the clear-then-set default swap stay atomic; the DB keeps at most one default per Category, this layer keeps at least one. No FK: workspace scoping is the WHERE guard. Adds issue_status:created/updated events and full handler test coverage. Co-authored-by: multica-agent --- server/cmd/server/router.go | 12 + server/internal/handler/issue_status.go | 627 ++++++++++++++++++ .../handler/issue_status_handler_test.go | 364 ++++++++++ server/pkg/db/generated/issue_status.sql.go | 289 ++++++++ server/pkg/db/queries/issue_status.sql | 83 +++ server/pkg/protocol/events.go | 5 + 6 files changed, 1380 insertions(+) create mode 100644 server/internal/handler/issue_status.go create mode 100644 server/internal/handler/issue_status_handler_test.go diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index eb2d70bb6c1..27460972b8f 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -1087,6 +1087,18 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus }) }) + // Custom issue status catalog (MUL-4809). GET is readable by any + // member/agent (the alias table drives `issue status`); writes are + // human owner/admin only. + r.Route("/api/issue-statuses", func(r chi.Router) { + r.Get("/", h.ListIssueStatuses) + r.Post("/", h.CreateIssueStatus) + r.Route("/{id}", func(r chi.Router) { + r.Patch("/", h.UpdateIssueStatus) + r.Delete("/", h.DeleteIssueStatus) + }) + }) + // Labels r.Route("/api/labels", func(r chi.Router) { r.Get("/", h.ListLabels) diff --git a/server/internal/handler/issue_status.go b/server/internal/handler/issue_status.go new file mode 100644 index 00000000000..4d0861ace2f --- /dev/null +++ b/server/internal/handler/issue_status.go @@ -0,0 +1,627 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "net/http" + "strings" + "unicode" + "unicode/utf8" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/issuestatus" + "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +// Custom issue status management (MUL-4809, plan §5). A per-workspace catalog of +// statuses, each pinned to one of the 5 immutable Categories (the only machine +// semantics). The 7 built-ins are seeded per workspace; admins add custom +// statuses and rename / recolor / reorder any of them, but Category and +// system_key are immutable and system statuses cannot be archived. +// +// Contract highlights (plan §5): +// - Definitions are managed by human owner/admin members only — agents change +// an issue's status but never the catalog (mirrors custom properties). +// - Category and system_key are immutable: PATCHing them returns 400 +// immutable_field rather than silently ignoring. To change a status's +// Category you create a new status and migrate issues explicitly. +// - The DB guarantees at most one default per Category (partial unique index); +// this layer maintains at least one by clearing-then-setting in one tx. +// - Archive (soft delete) requires a same-Category migration target when the +// status is still in use, and refuses to strand a Category with no default. +const ( + maxIssueStatusNameLen = 32 + maxIssueStatusDescriptionLen = 500 + maxActiveCustomIssueStatusesPerWorkspace = 24 +) + +// validIssueStatusColors is the allowlist of semantic color tokens a status may +// carry. These match the tokens the built-in statuses use and that the client +// theme already renders (STATUS_CONFIG in packages/core); keeping the allowlist +// at the API boundary stops arbitrary values from leaking into every surface +// that colors a status. +var validIssueStatusColors = map[string]struct{}{ + "muted-foreground": {}, "warning": {}, "success": {}, "info": {}, "destructive": {}, +} + +// validIssueStatusIcons is the allowlist of icon keys a status may carry. They +// are the built-in status-shape glyphs the client renders (StatusIcon in +// packages/views); a custom status reuses whichever shape best fits its +// Category. icon is human-facing only and never affects machine semantics. +var validIssueStatusIcons = map[string]struct{}{ + "backlog": {}, "todo": {}, "in_progress": {}, "in_review": {}, + "blocked": {}, "done": {}, "cancelled": {}, +} + +func issueStatusColorList() string { + return "muted-foreground, warning, success, info, destructive" +} + +func issueStatusIconList() string { + return "backlog, todo, in_progress, in_review, blocked, done, cancelled" +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type IssueStatusResponse struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Color string `json:"color"` + Category string `json:"category"` + SystemKey *string `json:"system_key"` + IsSystem bool `json:"is_system"` + IsDefault bool `json:"is_default"` + Position float64 `json:"position"` + Archived bool `json:"archived"` + ArchivedAt *string `json:"archived_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// IssueStatusCatalogResponse is what the catalog endpoint returns: the ordered +// statuses plus the alias resolution table an agent/CLI reads before calling +// `issue status` (plan §3.2). category_defaults maps each Category to its +// current default status id; aliases maps every alias token (5 Category + 2 +// legacy) to the status id it resolves to today, so a rename never leaves a +// caller guessing. +type IssueStatusCatalogResponse struct { + Statuses []IssueStatusResponse `json:"statuses"` + CategoryDefaults map[string]string `json:"category_defaults"` + Aliases map[string]string `json:"aliases"` + Total int `json:"total"` +} + +type CreateIssueStatusRequest struct { + Name string `json:"name"` + Category string `json:"category"` + Description string `json:"description"` + Icon string `json:"icon"` + Color string `json:"color"` + IsDefault bool `json:"is_default"` +} + +type UpdateIssueStatusRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Icon *string `json:"icon"` + Color *string `json:"color"` + Position *float64 `json:"position"` + IsDefault *bool `json:"is_default"` + // Immutable fields. They are decoded only to reject the request loudly if a + // caller sends them (plan §5.3), never applied. + Category *string `json:"category"` + SystemKey *string `json:"system_key"` + WorkspaceID *string `json:"workspace_id"` +} + +func issueStatusToResponse(s db.IssueStatus) IssueStatusResponse { + resp := IssueStatusResponse{ + ID: uuidToString(s.ID), + WorkspaceID: uuidToString(s.WorkspaceID), + Name: s.Name, + Description: s.Description, + Icon: s.Icon, + Color: s.Color, + Category: s.Category, + IsSystem: s.SystemKey.Valid, + IsDefault: s.IsDefault, + Position: s.Position, + Archived: s.ArchivedAt.Valid, + CreatedAt: timestampToString(s.CreatedAt), + UpdatedAt: timestampToString(s.UpdatedAt), + } + if s.SystemKey.Valid { + key := s.SystemKey.String + resp.SystemKey = &key + } + if s.ArchivedAt.Valid { + at := timestampToString(s.ArchivedAt) + resp.ArchivedAt = &at + } + return resp +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +// validateIssueStatusName trims and validates a display name. Beyond length and +// control-char rules it rejects the 7 reserved alias tokens: the alias resolver +// claims those first, so a status named "todo" or "in_review" could never be +// targeted by its own name (plan §3.1). +func validateIssueStatusName(raw string) (string, error) { + for _, r := range raw { + if unicode.IsControl(r) { + return "", errors.New("name cannot contain tabs, newlines, or control characters") + } + } + name := strings.TrimSpace(raw) + if name == "" { + return "", errors.New("name is required") + } + if utf8.RuneCountInString(name) > maxIssueStatusNameLen { + return "", fmt.Errorf("name must be %d characters or fewer", maxIssueStatusNameLen) + } + if issuestatus.IsReservedStatusToken(name) { + return "", fmt.Errorf("%q is a reserved status alias and cannot be a status name", name) + } + return name, nil +} + +func validateIssueStatusCategory(raw string) (string, error) { + c := strings.TrimSpace(raw) + for _, valid := range issuestatus.Categories { + if c == valid { + return c, nil + } + } + return "", fmt.Errorf("category must be one of: %s", strings.Join(issuestatus.Categories, ", ")) +} + +func validateIssueStatusColor(raw string) (string, error) { + c := strings.TrimSpace(raw) + if _, ok := validIssueStatusColors[c]; !ok { + return "", fmt.Errorf("color must be one of: %s", issueStatusColorList()) + } + return c, nil +} + +func validateIssueStatusIcon(raw string) (string, error) { + icon := strings.TrimSpace(raw) + if _, ok := validIssueStatusIcons[icon]; !ok { + return "", fmt.Errorf("icon must be one of: %s", issueStatusIconList()) + } + return icon, nil +} + +func validateIssueStatusDescription(raw string) (string, error) { + if utf8.RuneCountInString(raw) > maxIssueStatusDescriptionLen { + return "", fmt.Errorf("description must be %d characters or fewer", maxIssueStatusDescriptionLen) + } + return sanitizeNullBytes(strings.TrimSpace(raw)), nil +} + +// --------------------------------------------------------------------------- +// Admin gate +// --------------------------------------------------------------------------- + +// requireIssueStatusAdmin gates catalog writes: human owner/admin members only. +// Agent actors are rejected before the role check (mirror of +// requirePropertyAdmin) — an agent inherits its runtime owner's credentials, so +// without this an admin's agent could reshape the status catalog. Agents change +// an issue's status; they do not manage the catalog. +func (h *Handler) requireIssueStatusAdmin(w http.ResponseWriter, r *http.Request) (workspaceID, userID string, ok bool) { + workspaceID = h.resolveWorkspaceID(r) + userID, ok = requireUserID(w, r) + if !ok { + return "", "", false + } + if actorType, _ := h.resolveActor(r, userID, workspaceID); actorType == "agent" { + writeError(w, http.StatusForbidden, "agents cannot manage issue statuses") + return "", "", false + } + if _, roleOK := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !roleOK { + return "", "", false + } + return workspaceID, userID, true +} + +// withIssueStatusLock runs fn inside a transaction holding a workspace-scoped +// advisory lock, serializing catalog writes for the workspace: the active-count +// cap, name-uniqueness, and the clear-then-set default swap are all +// read-then-write and must not interleave. The lock is transaction-scoped. +func (h *Handler) withIssueStatusLock(r *http.Request, workspaceID string, fn func(q *db.Queries) error) error { + tx, err := h.TxStarter.Begin(r.Context()) + if err != nil { + return err + } + defer tx.Rollback(r.Context()) + if _, err := tx.Exec(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", "issuestatus:"+workspaceID); err != nil { + return err + } + if err := fn(h.Queries.WithTx(tx)); err != nil { + return err + } + return tx.Commit(r.Context()) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// ListIssueStatuses (GET /api/issue-statuses) returns the workspace catalog plus +// the alias resolution table. Readable by any workspace member and by agents — +// the alias table is exactly what an agent reads before calling `issue status`. +func (h *Handler) ListIssueStatuses(w http.ResponseWriter, r *http.Request) { + workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + includeArchived := r.URL.Query().Get("include_archived") == "true" + statuses, err := h.Queries.ListWorkspaceIssueStatuses(r.Context(), db.ListWorkspaceIssueStatusesParams{ + WorkspaceID: wsUUID, + IncludeArchived: includeArchived, + }) + if err != nil { + slog.Warn("ListWorkspaceIssueStatuses failed", append(logger.RequestAttrs(r), "error", err)...) + writeError(w, http.StatusInternalServerError, "failed to list issue statuses") + return + } + + resp := IssueStatusCatalogResponse{ + Statuses: make([]IssueStatusResponse, len(statuses)), + CategoryDefaults: make(map[string]string), + Aliases: make(map[string]string), + Total: len(statuses), + } + for i, s := range statuses { + resp.Statuses[i] = issueStatusToResponse(s) + if s.ArchivedAt.Valid { + continue + } + id := uuidToString(s.ID) + // Category alias -> current default; also the per-Category default map. + if s.IsDefault { + resp.CategoryDefaults[s.Category] = id + resp.Aliases[s.Category] = id + } + // Legacy aliases key on the immutable system_key, so they survive renames. + if s.SystemKey.Valid { + switch s.SystemKey.String { + case "in_review", "blocked": + resp.Aliases[s.SystemKey.String] = id + } + } + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) CreateIssueStatus(w http.ResponseWriter, r *http.Request) { + workspaceID, userID, ok := h.requireIssueStatusAdmin(w, r) + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + var req CreateIssueStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + name, err := validateIssueStatusName(req.Name) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + category, err := validateIssueStatusCategory(req.Category) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + icon, err := validateIssueStatusIcon(req.Icon) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + color, err := validateIssueStatusColor(req.Color) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + description, err := validateIssueStatusDescription(req.Description) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + var created db.IssueStatus + var httpStatus int + var httpMsg string + fail := func(status int, msg string) error { + httpStatus, httpMsg = status, msg + return errClientRejected + } + err = h.withIssueStatusLock(r, workspaceID, func(q *db.Queries) error { + active, err := q.CountActiveCustomIssueStatuses(r.Context(), wsUUID) + if err != nil { + return err + } + if active >= maxActiveCustomIssueStatusesPerWorkspace { + return fail(http.StatusBadRequest, fmt.Sprintf("a workspace cannot have more than %d custom statuses; archive unused ones first", maxActiveCustomIssueStatusesPerWorkspace)) + } + // Promoting to default must clear the Category's current default first, + // or the (workspace_id, category) partial unique index rejects the insert. + if req.IsDefault { + if err := q.ClearCategoryDefault(r.Context(), db.ClearCategoryDefaultParams{WorkspaceID: wsUUID, Category: category}); err != nil { + return err + } + } + created, err = q.CreateCustomIssueStatus(r.Context(), db.CreateCustomIssueStatusParams{ + WorkspaceID: wsUUID, + Name: name, + Description: description, + Icon: icon, + Color: color, + Category: category, + IsDefault: req.IsDefault, + }) + return err + }) + if err != nil { + if errors.Is(err, errClientRejected) { + writeError(w, httpStatus, httpMsg) + return + } + if isUniqueViolation(err) { + writeError(w, http.StatusConflict, "a status with that name already exists") + return + } + slog.Warn("CreateCustomIssueStatus failed", append(logger.RequestAttrs(r), "error", err)...) + writeError(w, http.StatusInternalServerError, "failed to create issue status") + return + } + resp := issueStatusToResponse(created) + h.publish(protocol.EventIssueStatusCreated, workspaceID, "member", userID, map[string]any{"status": resp}) + writeJSON(w, http.StatusCreated, resp) +} + +func (h *Handler) UpdateIssueStatus(w http.ResponseWriter, r *http.Request) { + workspaceID, userID, ok := h.requireIssueStatusAdmin(w, r) + if !ok { + return + } + idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "status id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + var req UpdateIssueStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + // Immutable fields are rejected loudly, not silently ignored (plan §5.3). + if req.Category != nil || req.SystemKey != nil || req.WorkspaceID != nil { + writeError(w, http.StatusBadRequest, "immutable_field: category, system_key, and workspace_id cannot be changed; create a new status and migrate issues instead") + return + } + + var updated db.IssueStatus + var httpStatus int + var httpMsg string + fail := func(status int, msg string) error { + httpStatus, httpMsg = status, msg + return errClientRejected + } + err := h.withIssueStatusLock(r, workspaceID, func(q *db.Queries) error { + existing, err := q.GetWorkspaceIssueStatus(r.Context(), db.GetWorkspaceIssueStatusParams{ID: idUUID, WorkspaceID: wsUUID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fail(http.StatusNotFound, "status not found") + } + return err + } + + params := db.UpdateIssueStatusFieldsParams{ID: idUUID, WorkspaceID: wsUUID} + if req.Name != nil { + name, err := validateIssueStatusName(*req.Name) + if err != nil { + return fail(http.StatusBadRequest, err.Error()) + } + params.Name = pgtype.Text{String: name, Valid: true} + } + if req.Description != nil { + description, err := validateIssueStatusDescription(*req.Description) + if err != nil { + return fail(http.StatusBadRequest, err.Error()) + } + params.Description = pgtype.Text{String: description, Valid: true} + } + if req.Icon != nil { + icon, err := validateIssueStatusIcon(*req.Icon) + if err != nil { + return fail(http.StatusBadRequest, err.Error()) + } + params.Icon = pgtype.Text{String: icon, Valid: true} + } + if req.Color != nil { + color, err := validateIssueStatusColor(*req.Color) + if err != nil { + return fail(http.StatusBadRequest, err.Error()) + } + params.Color = pgtype.Text{String: color, Valid: true} + } + if req.Position != nil { + if math.IsNaN(*req.Position) || math.IsInf(*req.Position, 0) { + return fail(http.StatusBadRequest, "position must be a finite number") + } + params.Position = pgtype.Float8{Float64: *req.Position, Valid: true} + } + + updated, err = q.UpdateIssueStatusFields(r.Context(), params) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fail(http.StatusNotFound, "status not found") + } + if isUniqueViolation(err) { + return fail(http.StatusConflict, "a status with that name already exists") + } + return err + } + + // Default swap, if requested, runs after the field update but in the + // same tx. Only promotion (true) is meaningful: the DB holds "at most + // one" default per Category, and this layer refuses to strand a + // Category with zero defaults, so demoting the current default is done + // by promoting another status, not by clearing this one. + if req.IsDefault != nil { + switch { + case *req.IsDefault: + if existing.ArchivedAt.Valid { + return fail(http.StatusBadRequest, "an archived status cannot be made the default") + } + if !updated.IsDefault { + if err := q.ClearCategoryDefault(r.Context(), db.ClearCategoryDefaultParams{WorkspaceID: wsUUID, Category: existing.Category}); err != nil { + return err + } + updated, err = q.SetIssueStatusDefault(r.Context(), db.SetIssueStatusDefaultParams{ID: idUUID, WorkspaceID: wsUUID, IsDefault: true}) + if err != nil { + return err + } + } + default: + if updated.IsDefault { + return fail(http.StatusBadRequest, "cannot unset the default of a category; promote another status to default instead") + } + } + } + return nil + }) + if err != nil { + if errors.Is(err, errClientRejected) { + writeError(w, httpStatus, httpMsg) + return + } + slog.Warn("UpdateIssueStatus failed", append(logger.RequestAttrs(r), "error", err)...) + writeError(w, http.StatusInternalServerError, "failed to update issue status") + return + } + resp := issueStatusToResponse(updated) + h.publish(protocol.EventIssueStatusUpdated, workspaceID, "member", userID, map[string]any{"status": resp}) + writeJSON(w, http.StatusOK, resp) +} + +// DeleteIssueStatus archives (soft-deletes) a custom status. System statuses +// cannot be archived. If issues still point at it, the caller must pass a +// same-Category migrate_to_status_id and the issues move over in the same tx. +// A default status cannot be archived until another status is promoted. +func (h *Handler) DeleteIssueStatus(w http.ResponseWriter, r *http.Request) { + workspaceID, userID, ok := h.requireIssueStatusAdmin(w, r) + if !ok { + return + } + idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "status id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + migrateTo := strings.TrimSpace(r.URL.Query().Get("migrate_to_status_id")) + + var httpStatus int + var httpMsg string + fail := func(status int, msg string) error { + httpStatus, httpMsg = status, msg + return errClientRejected + } + err := h.withIssueStatusLock(r, workspaceID, func(q *db.Queries) error { + existing, err := q.GetWorkspaceIssueStatus(r.Context(), db.GetWorkspaceIssueStatusParams{ID: idUUID, WorkspaceID: wsUUID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fail(http.StatusNotFound, "status not found") + } + return err + } + if existing.ArchivedAt.Valid { + return fail(http.StatusBadRequest, "status is already archived") + } + if existing.SystemKey.Valid { + return fail(http.StatusBadRequest, "built-in statuses cannot be archived") + } + if existing.IsDefault { + return fail(http.StatusBadRequest, "promote another status to this category's default before archiving it") + } + + inUse, err := q.CountIssuesUsingStatus(r.Context(), db.CountIssuesUsingStatusParams{WorkspaceID: wsUUID, StatusID: idUUID}) + if err != nil { + return err + } + if inUse > 0 { + if migrateTo == "" { + return fail(http.StatusConflict, fmt.Sprintf("status is used by %d issue(s); pass migrate_to_status_id to move them to another status in the same category first", inUse)) + } + targetUUID, perr := util.ParseUUID(migrateTo) + if perr != nil { + return fail(http.StatusBadRequest, "migrate_to_status_id must be a status id") + } + if targetUUID == idUUID { + return fail(http.StatusBadRequest, "migrate_to_status_id cannot be the status being archived") + } + target, err := q.GetWorkspaceIssueStatus(r.Context(), db.GetWorkspaceIssueStatusParams{ID: targetUUID, WorkspaceID: wsUUID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fail(http.StatusBadRequest, "migrate_to_status_id does not name a status in this workspace") + } + return err + } + if target.ArchivedAt.Valid { + return fail(http.StatusBadRequest, "migrate_to_status_id cannot be an archived status") + } + if target.Category != existing.Category { + return fail(http.StatusBadRequest, "migrate_to_status_id must be in the same category; changing category must be an explicit per-issue transition") + } + if err := q.ReassignIssuesStatus(r.Context(), db.ReassignIssuesStatusParams{ + WorkspaceID: wsUUID, + FromStatusID: idUUID, + ToStatusID: targetUUID, + }); err != nil { + return err + } + } + + _, err = q.ArchiveIssueStatus(r.Context(), db.ArchiveIssueStatusParams{ID: idUUID, WorkspaceID: wsUUID}) + return err + }) + if err != nil { + if errors.Is(err, errClientRejected) { + writeError(w, httpStatus, httpMsg) + return + } + slog.Warn("DeleteIssueStatus failed", append(logger.RequestAttrs(r), "error", err)...) + writeError(w, http.StatusInternalServerError, "failed to archive issue status") + return + } + h.publish(protocol.EventIssueStatusUpdated, workspaceID, "member", userID, map[string]any{"status_id": uuidToString(idUUID), "archived": true}) + writeJSON(w, http.StatusOK, map[string]any{"archived": true}) +} diff --git a/server/internal/handler/issue_status_handler_test.go b/server/internal/handler/issue_status_handler_test.go new file mode 100644 index 00000000000..908823869e4 --- /dev/null +++ b/server/internal/handler/issue_status_handler_test.go @@ -0,0 +1,364 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/multica-ai/multica/server/internal/issuestatus" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// seedTestWorkspaceStatuses seeds the 7 built-in statuses into the shared test +// workspace (the handler fixture inserts the workspace with raw SQL, so it has +// no catalog) and registers cleanup that wipes the whole catalog afterwards, so +// each status test starts from a known, hermetic state. +func seedTestWorkspaceStatuses(t *testing.T) { + t.Helper() + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + if err := issuestatus.Ensure(ctx, db.New(testPool), parseUUID(testWorkspaceID)); err != nil { + t.Fatalf("seed statuses: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE workspace_id = $1`, testWorkspaceID) + }) +} + +func getStatusCatalog(t *testing.T, includeArchived bool) IssueStatusCatalogResponse { + t.Helper() + path := "/api/issue-statuses" + if includeArchived { + path += "?include_archived=true" + } + w := httptest.NewRecorder() + testHandler.ListIssueStatuses(w, newRequest("GET", path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("ListIssueStatuses: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp IssueStatusCatalogResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode catalog: %v", err) + } + return resp +} + +func createStatus(t *testing.T, body map[string]any) (IssueStatusResponse, int, string) { + t.Helper() + w := httptest.NewRecorder() + testHandler.CreateIssueStatus(w, newRequest("POST", "/api/issue-statuses", body)) + var resp IssueStatusResponse + if w.Code == http.StatusCreated { + json.NewDecoder(w.Body).Decode(&resp) + } + return resp, w.Code, w.Body.String() +} + +func patchStatus(t *testing.T, id string, body map[string]any) (IssueStatusResponse, int, string) { + t.Helper() + w := httptest.NewRecorder() + req := withURLParam(newRequest("PATCH", "/api/issue-statuses/"+id, body), "id", id) + testHandler.UpdateIssueStatus(w, req) + var resp IssueStatusResponse + if w.Code == http.StatusOK { + json.NewDecoder(w.Body).Decode(&resp) + } + return resp, w.Code, w.Body.String() +} + +func deleteStatus(t *testing.T, id, migrateTo string) (int, string) { + t.Helper() + path := "/api/issue-statuses/" + id + if migrateTo != "" { + path += "?migrate_to_status_id=" + migrateTo + } + w := httptest.NewRecorder() + req := withURLParam(newRequest("DELETE", path, nil), "id", id) + testHandler.DeleteIssueStatus(w, req) + return w.Code, w.Body.String() +} + +func TestIssueStatusCatalogHasBuiltins(t *testing.T) { + seedTestWorkspaceStatuses(t) + cat := getStatusCatalog(t, false) + + if cat.Total != 7 { + t.Fatalf("expected 7 built-in statuses, got %d", cat.Total) + } + // Every Category alias resolves to its default; both legacy aliases resolve. + for _, c := range issuestatus.Categories { + if cat.Aliases[c] == "" { + t.Errorf("alias %q has no target", c) + } + if cat.CategoryDefaults[c] == "" { + t.Errorf("category %q has no default", c) + } + } + for _, legacy := range []string{"in_review", "blocked"} { + if cat.Aliases[legacy] == "" { + t.Errorf("legacy alias %q has no target", legacy) + } + } +} + +func TestCreateCustomStatus(t *testing.T) { + seedTestWorkspaceStatuses(t) + created, code, msg := createStatus(t, map[string]any{ + "name": "Needs Clarification", "category": "todo", "icon": "todo", "color": "warning", + }) + if code != http.StatusCreated { + t.Fatalf("create: expected 201, got %d: %s", code, msg) + } + if created.IsSystem { + t.Error("custom status should not be is_system") + } + if created.SystemKey != nil { + t.Error("custom status should have null system_key") + } + if created.Category != "todo" { + t.Errorf("category = %q, want todo", created.Category) + } + // Appears in the catalog and sorts after the built-in Todo (position > 0). + cat := getStatusCatalog(t, false) + if cat.Total != 8 { + t.Fatalf("expected 8 statuses, got %d", cat.Total) + } + if created.Position <= 0 { + t.Errorf("custom status position = %v, want > 0", created.Position) + } +} + +func TestCreateStatusRejectsReservedName(t *testing.T) { + seedTestWorkspaceStatuses(t) + // The reserved tokens are the underscore alias forms; " Todo " and "IN_PROGRESS" + // normalize (trim + lowercase) onto them. A display-name collision like + // "In Progress" is a different rejection (409 on the unique-name index). + for _, name := range []string{"todo", " Todo ", "IN_PROGRESS", "in_review", "BLOCKED"} { + _, code, _ := createStatus(t, map[string]any{"name": name, "category": "todo", "icon": "todo", "color": "warning"}) + if code != http.StatusBadRequest { + t.Errorf("reserved name %q: expected 400, got %d", name, code) + } + } +} + +func TestCreateStatusValidation(t *testing.T) { + seedTestWorkspaceStatuses(t) + cases := []struct { + name string + body map[string]any + }{ + {"bad category", map[string]any{"name": "X", "category": "review", "icon": "todo", "color": "warning"}}, + {"bad color", map[string]any{"name": "X", "category": "todo", "icon": "todo", "color": "#ff0000"}}, + {"bad icon", map[string]any{"name": "X", "category": "todo", "icon": "rocket", "color": "warning"}}, + {"empty name", map[string]any{"name": " ", "category": "todo", "icon": "todo", "color": "warning"}}, + } + for _, tc := range cases { + _, code, msg := createStatus(t, tc.body) + if code != http.StatusBadRequest { + t.Errorf("%s: expected 400, got %d: %s", tc.name, code, msg) + } + } +} + +func TestCreateStatusDuplicateName(t *testing.T) { + seedTestWorkspaceStatuses(t) + if _, code, msg := createStatus(t, map[string]any{"name": "Design Review", "category": "in_progress", "icon": "in_review", "color": "success"}); code != http.StatusCreated { + t.Fatalf("first create: expected 201, got %d: %s", code, msg) + } + // Case-insensitive collision against the active name index. + if _, code, _ := createStatus(t, map[string]any{"name": "design review", "category": "in_progress", "icon": "in_review", "color": "success"}); code != http.StatusConflict { + t.Errorf("duplicate create: expected 409, got %d", code) + } +} + +func TestCreateStatusAsDefaultSwapsCategoryDefault(t *testing.T) { + seedTestWorkspaceStatuses(t) + before := getStatusCatalog(t, false) + oldDefault := before.CategoryDefaults["todo"] + + created, code, msg := createStatus(t, map[string]any{ + "name": "Triage", "category": "todo", "icon": "todo", "color": "warning", "is_default": true, + }) + if code != http.StatusCreated { + t.Fatalf("create default: expected 201, got %d: %s", code, msg) + } + after := getStatusCatalog(t, false) + if after.CategoryDefaults["todo"] != created.ID { + t.Errorf("todo default = %q, want new status %q", after.CategoryDefaults["todo"], created.ID) + } + if after.CategoryDefaults["todo"] == oldDefault { + t.Error("old default should have been demoted") + } + // Exactly one default in the todo category. + defaults := 0 + for _, s := range after.Statuses { + if s.Category == "todo" && s.IsDefault { + defaults++ + } + } + if defaults != 1 { + t.Errorf("todo has %d defaults, want exactly 1", defaults) + } +} + +func TestUpdateStatusRejectsImmutableFields(t *testing.T) { + seedTestWorkspaceStatuses(t) + created, _, _ := createStatus(t, map[string]any{"name": "Staging", "category": "in_progress", "icon": "in_progress", "color": "warning"}) + for _, body := range []map[string]any{ + {"category": "done"}, + {"system_key": "in_review"}, + {"workspace_id": testWorkspaceID}, + } { + _, code, msg := patchStatus(t, created.ID, body) + if code != http.StatusBadRequest { + t.Errorf("immutable %v: expected 400, got %d: %s", body, code, msg) + } + } +} + +func TestUpdateStatusRenameAndRecolor(t *testing.T) { + seedTestWorkspaceStatuses(t) + created, _, _ := createStatus(t, map[string]any{"name": "QA", "category": "in_progress", "icon": "in_progress", "color": "warning"}) + updated, code, msg := patchStatus(t, created.ID, map[string]any{"name": "QA Review", "color": "success"}) + if code != http.StatusOK { + t.Fatalf("patch: expected 200, got %d: %s", code, msg) + } + if updated.Name != "QA Review" || updated.Color != "success" { + t.Errorf("got name=%q color=%q, want QA Review/success", updated.Name, updated.Color) + } +} + +func TestUpdateStatusPromoteAndCannotUnsetDefault(t *testing.T) { + seedTestWorkspaceStatuses(t) + before := getStatusCatalog(t, false) + systemTodoDefault := before.CategoryDefaults["todo"] + + custom, _, _ := createStatus(t, map[string]any{"name": "Grooming", "category": "todo", "icon": "todo", "color": "warning"}) + // Promote the custom status to default. + if _, code, msg := patchStatus(t, custom.ID, map[string]any{"is_default": true}); code != http.StatusOK { + t.Fatalf("promote: expected 200, got %d: %s", code, msg) + } + after := getStatusCatalog(t, false) + if after.CategoryDefaults["todo"] != custom.ID { + t.Errorf("todo default = %q, want %q", after.CategoryDefaults["todo"], custom.ID) + } + // Unsetting the sole default is refused — you promote another instead. + if _, code, _ := patchStatus(t, custom.ID, map[string]any{"is_default": false}); code != http.StatusBadRequest { + t.Errorf("unset default: expected 400, got %d", code) + } + // Re-promoting the built-in Todo restores it as default. + if _, code, _ := patchStatus(t, systemTodoDefault, map[string]any{"is_default": true}); code != http.StatusOK { + t.Errorf("re-promote system: expected 200, got %d", code) + } +} + +func TestArchiveCustomStatus(t *testing.T) { + seedTestWorkspaceStatuses(t) + created, _, _ := createStatus(t, map[string]any{"name": "Temp", "category": "backlog", "icon": "backlog", "color": "muted-foreground"}) + if code, msg := deleteStatus(t, created.ID, ""); code != http.StatusOK { + t.Fatalf("archive: expected 200, got %d: %s", code, msg) + } + // Gone from the active catalog, present with include_archived. + if active := getStatusCatalog(t, false); active.Total != 7 { + t.Errorf("active catalog = %d, want 7 after archive", active.Total) + } + found := false + for _, s := range getStatusCatalog(t, true).Statuses { + if s.ID == created.ID { + found = true + if !s.Archived { + t.Error("archived status should report archived=true") + } + } + } + if !found { + t.Error("archived status missing from include_archived catalog") + } +} + +func TestArchiveSystemStatusRejected(t *testing.T) { + seedTestWorkspaceStatuses(t) + cat := getStatusCatalog(t, false) + if code, _ := deleteStatus(t, cat.CategoryDefaults["done"], ""); code != http.StatusBadRequest { + t.Errorf("archive system status: expected 400, got %d", code) + } +} + +func TestArchiveDefaultStatusRejected(t *testing.T) { + seedTestWorkspaceStatuses(t) + created, _, _ := createStatus(t, map[string]any{"name": "Parked", "category": "backlog", "icon": "backlog", "color": "muted-foreground", "is_default": true}) + if code, _ := deleteStatus(t, created.ID, ""); code != http.StatusBadRequest { + t.Errorf("archive default status: expected 400, got %d", code) + } +} + +func TestArchiveInUseStatusRequiresMigration(t *testing.T) { + seedTestWorkspaceStatuses(t) + from, _, _ := createStatus(t, map[string]any{"name": "Legacy Stage", "category": "in_progress", "icon": "in_progress", "color": "warning"}) + to, _, _ := createStatus(t, map[string]any{"name": "New Stage", "category": "in_progress", "icon": "in_progress", "color": "warning"}) + otherCat, _, _ := createStatus(t, map[string]any{"name": "Wrong Cat", "category": "done", "icon": "done", "color": "info"}) + + // Point a real issue at the from-status via the authoritative status_id. + var issueID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO issue (workspace_id, title, status, status_id, priority, creator_type, creator_id, number) + VALUES ($1, 'uses custom status', 'in_progress', $2, 'none', 'member', $3, + COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1) + RETURNING id + `, testWorkspaceID, from.ID, testUserID).Scan(&issueID); err != nil { + t.Fatalf("insert issue: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) }) + + // No migrate target while in use -> 409. + if code, _ := deleteStatus(t, from.ID, ""); code != http.StatusConflict { + t.Errorf("archive in-use without migration: expected 409, got %d", code) + } + // Cross-category migrate target -> 400. + if code, _ := deleteStatus(t, from.ID, otherCat.ID); code != http.StatusBadRequest { + t.Errorf("cross-category migration: expected 400, got %d", code) + } + // Same-category migrate target -> 200 and the issue moves. + if code, msg := deleteStatus(t, from.ID, to.ID); code != http.StatusOK { + t.Fatalf("same-category migration: expected 200, got %d: %s", code, msg) + } + var movedTo string + if err := testPool.QueryRow(context.Background(), `SELECT status_id FROM issue WHERE id = $1`, issueID).Scan(&movedTo); err != nil { + t.Fatalf("read issue status_id: %v", err) + } + if movedTo != to.ID { + t.Errorf("issue status_id = %q, want migrated %q", movedTo, to.ID) + } +} + +func TestManageStatusRejectsAgents(t *testing.T) { + seedTestWorkspaceStatuses(t) + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issue-statuses", map[string]any{"name": "Agentic", "category": "todo", "icon": "todo", "color": "warning"}) + // Trusted server-set actor header: resolveActor returns an agent identity. + req.Header.Set("X-Actor-Source", "task_token") + req.Header.Set("X-Agent-ID", testUserID) + testHandler.CreateIssueStatus(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("agent create: expected 403, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCustomStatusCap(t *testing.T) { + seedTestWorkspaceStatuses(t) + for i := 0; i < maxActiveCustomIssueStatusesPerWorkspace; i++ { + if _, code, msg := createStatus(t, map[string]any{ + "name": fmt.Sprintf("Custom %d", i), "category": "in_progress", "icon": "in_progress", "color": "warning", + }); code != http.StatusCreated { + t.Fatalf("create %d: expected 201, got %d: %s", i, code, msg) + } + } + if _, code, _ := createStatus(t, map[string]any{"name": "One Too Many", "category": "in_progress", "icon": "in_progress", "color": "warning"}); code != http.StatusBadRequest { + t.Errorf("over-cap create: expected 400, got %d", code) + } +} diff --git a/server/pkg/db/generated/issue_status.sql.go b/server/pkg/db/generated/issue_status.sql.go index e3afb84ede7..aae130c88be 100644 --- a/server/pkg/db/generated/issue_status.sql.go +++ b/server/pkg/db/generated/issue_status.sql.go @@ -11,6 +11,90 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const archiveIssueStatus = `-- name: ArchiveIssueStatus :one +UPDATE issue_status SET archived_at = now(), updated_at = now() +WHERE id = $1 AND workspace_id = $2 +RETURNING id, workspace_id, name, description, icon, color, category, system_key, is_default, position, archived_at, created_at, updated_at +` + +type ArchiveIssueStatusParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) ArchiveIssueStatus(ctx context.Context, arg ArchiveIssueStatusParams) (IssueStatus, error) { + row := q.db.QueryRow(ctx, archiveIssueStatus, arg.ID, arg.WorkspaceID) + var i IssueStatus + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.Icon, + &i.Color, + &i.Category, + &i.SystemKey, + &i.IsDefault, + &i.Position, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const clearCategoryDefault = `-- name: ClearCategoryDefault :exec +UPDATE issue_status SET is_default = FALSE, updated_at = now() +WHERE workspace_id = $1::uuid + AND category = $2::text + AND is_default = TRUE + AND archived_at IS NULL +` + +type ClearCategoryDefaultParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Category string `json:"category"` +} + +// Drop is_default from every active status in a Category, so a new default can +// be set without tripping the (workspace_id, category) partial unique index. +// Run inside the same tx as the promote below (or a create-with-default). +func (q *Queries) ClearCategoryDefault(ctx context.Context, arg ClearCategoryDefaultParams) error { + _, err := q.db.Exec(ctx, clearCategoryDefault, arg.WorkspaceID, arg.Category) + return err +} + +const countActiveCustomIssueStatuses = `-- name: CountActiveCustomIssueStatuses :one +SELECT COUNT(*) FROM issue_status +WHERE workspace_id = $1 AND system_key IS NULL AND archived_at IS NULL +` + +// Active custom (non-system) statuses, for the per-workspace cap. System +// statuses never count against it — the 7 built-ins are always present. +func (q *Queries) CountActiveCustomIssueStatuses(ctx context.Context, workspaceID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countActiveCustomIssueStatuses, workspaceID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countIssuesUsingStatus = `-- name: CountIssuesUsingStatus :one +SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND status_id = $2 +` + +type CountIssuesUsingStatusParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + StatusID pgtype.UUID `json:"status_id"` +} + +// Issues currently pointing at a status via the authoritative status_id. +func (q *Queries) CountIssuesUsingStatus(ctx context.Context, arg CountIssuesUsingStatusParams) (int64, error) { + row := q.db.QueryRow(ctx, countIssuesUsingStatus, arg.WorkspaceID, arg.StatusID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countWorkspaceIssueStatuses = `-- name: CountWorkspaceIssueStatuses :one SELECT COUNT(*) FROM issue_status WHERE workspace_id = $1::uuid @@ -25,6 +109,66 @@ func (q *Queries) CountWorkspaceIssueStatuses(ctx context.Context, workspaceID p return count, err } +const createCustomIssueStatus = `-- name: CreateCustomIssueStatus :one +INSERT INTO issue_status ( + workspace_id, name, description, icon, color, category, system_key, is_default, position +) +SELECT $1::uuid, + $2::text, + $3::text, + $4::text, + $5::text, + $6::text, + NULL, + $7::bool, + COALESCE((SELECT MAX(position) FROM issue_status + WHERE workspace_id = $1::uuid + AND category = $6::text), 0) + 1 +RETURNING id, workspace_id, name, description, icon, color, category, system_key, is_default, position, archived_at, created_at, updated_at +` + +type CreateCustomIssueStatusParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Color string `json:"color"` + Category string `json:"category"` + IsDefault bool `json:"is_default"` +} + +// Custom statuses always have system_key = NULL and append to the end of their +// Category: position = max(position within category) + 1, so they sort after +// the built-ins of the same Category. +func (q *Queries) CreateCustomIssueStatus(ctx context.Context, arg CreateCustomIssueStatusParams) (IssueStatus, error) { + row := q.db.QueryRow(ctx, createCustomIssueStatus, + arg.WorkspaceID, + arg.Name, + arg.Description, + arg.Icon, + arg.Color, + arg.Category, + arg.IsDefault, + ) + var i IssueStatus + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.Icon, + &i.Color, + &i.Category, + &i.SystemKey, + &i.IsDefault, + &i.Position, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const ensureWorkspaceSystemIssueStatuses = `-- name: EnsureWorkspaceSystemIssueStatuses :exec WITH ws AS ( SELECT id FROM workspace WHERE id = $1::uuid FOR KEY SHARE @@ -70,6 +214,43 @@ func (q *Queries) EnsureWorkspaceSystemIssueStatuses(ctx context.Context, worksp return err } +const getWorkspaceIssueStatus = `-- name: GetWorkspaceIssueStatus :one + +SELECT id, workspace_id, name, description, icon, color, category, system_key, is_default, position, archived_at, created_at, updated_at FROM issue_status WHERE id = $1 AND workspace_id = $2 +` + +type GetWorkspaceIssueStatusParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +// --------------------------------------------------------------------------- +// Status-management API (MUL-4809, plan §5). Admin CRUD over the catalog. +// All writes are tenant-scoped by workspace_id (no FK; the WHERE clause is the +// application-level guard) and run under a workspace advisory lock so the cap, +// name-uniqueness, and single-default swaps stay atomic. +// --------------------------------------------------------------------------- +func (q *Queries) GetWorkspaceIssueStatus(ctx context.Context, arg GetWorkspaceIssueStatusParams) (IssueStatus, error) { + row := q.db.QueryRow(ctx, getWorkspaceIssueStatus, arg.ID, arg.WorkspaceID) + var i IssueStatus + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.Icon, + &i.Color, + &i.Category, + &i.SystemKey, + &i.IsDefault, + &i.Position, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const listWorkspaceIssueStatuses = `-- name: ListWorkspaceIssueStatuses :many SELECT id, workspace_id, name, description, icon, color, category, system_key, is_default, position, archived_at, created_at, updated_at FROM issue_status WHERE workspace_id = $1::uuid @@ -126,3 +307,111 @@ func (q *Queries) ListWorkspaceIssueStatuses(ctx context.Context, arg ListWorksp } return items, nil } + +const reassignIssuesStatus = `-- name: ReassignIssuesStatus :exec +UPDATE issue SET status_id = $1::uuid, updated_at = now() +WHERE workspace_id = $2::uuid + AND status_id = $3::uuid +` + +type ReassignIssuesStatusParams struct { + ToStatusID pgtype.UUID `json:"to_status_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + FromStatusID pgtype.UUID `json:"from_status_id"` +} + +// Move every issue off one status onto another during archive migration. The +// handler guarantees both are the same Category, so the legacy `status` +// projection (Category for custom statuses) is unchanged and is left as-is. +func (q *Queries) ReassignIssuesStatus(ctx context.Context, arg ReassignIssuesStatusParams) error { + _, err := q.db.Exec(ctx, reassignIssuesStatus, arg.ToStatusID, arg.WorkspaceID, arg.FromStatusID) + return err +} + +const setIssueStatusDefault = `-- name: SetIssueStatusDefault :one +UPDATE issue_status SET is_default = $3::bool, updated_at = now() +WHERE id = $1 AND workspace_id = $2 +RETURNING id, workspace_id, name, description, icon, color, category, system_key, is_default, position, archived_at, created_at, updated_at +` + +type SetIssueStatusDefaultParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IsDefault bool `json:"is_default"` +} + +func (q *Queries) SetIssueStatusDefault(ctx context.Context, arg SetIssueStatusDefaultParams) (IssueStatus, error) { + row := q.db.QueryRow(ctx, setIssueStatusDefault, arg.ID, arg.WorkspaceID, arg.IsDefault) + var i IssueStatus + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.Icon, + &i.Color, + &i.Category, + &i.SystemKey, + &i.IsDefault, + &i.Position, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateIssueStatusFields = `-- name: UpdateIssueStatusFields :one +UPDATE issue_status SET + name = COALESCE($3, name), + description = COALESCE($4, description), + icon = COALESCE($5, icon), + color = COALESCE($6, color), + position = COALESCE($7, position), + updated_at = now() +WHERE id = $1 AND workspace_id = $2 +RETURNING id, workspace_id, name, description, icon, color, category, system_key, is_default, position, archived_at, created_at, updated_at +` + +type UpdateIssueStatusFieldsParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Icon pgtype.Text `json:"icon"` + Color pgtype.Text `json:"color"` + Position pgtype.Float8 `json:"position"` +} + +// Mutable human-facing fields only. category / system_key / workspace_id are +// immutable and never appear here (the handler rejects them with 400). is_default +// is handled by the default-swap flow below, not this COALESCE update, so the +// one-default-per-Category invariant can be maintained across rows in one tx. +func (q *Queries) UpdateIssueStatusFields(ctx context.Context, arg UpdateIssueStatusFieldsParams) (IssueStatus, error) { + row := q.db.QueryRow(ctx, updateIssueStatusFields, + arg.ID, + arg.WorkspaceID, + arg.Name, + arg.Description, + arg.Icon, + arg.Color, + arg.Position, + ) + var i IssueStatus + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.Icon, + &i.Color, + &i.Category, + &i.SystemKey, + &i.IsDefault, + &i.Position, + &i.ArchivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/server/pkg/db/queries/issue_status.sql b/server/pkg/db/queries/issue_status.sql index afc7b6c7b4f..e771caafea3 100644 --- a/server/pkg/db/queries/issue_status.sql +++ b/server/pkg/db/queries/issue_status.sql @@ -59,3 +59,86 @@ ORDER BY SELECT COUNT(*) FROM issue_status WHERE workspace_id = sqlc.arg('workspace_id')::uuid AND archived_at IS NULL; + +-- --------------------------------------------------------------------------- +-- Status-management API (MUL-4809, plan §5). Admin CRUD over the catalog. +-- All writes are tenant-scoped by workspace_id (no FK; the WHERE clause is the +-- application-level guard) and run under a workspace advisory lock so the cap, +-- name-uniqueness, and single-default swaps stay atomic. +-- --------------------------------------------------------------------------- + +-- name: GetWorkspaceIssueStatus :one +SELECT * FROM issue_status WHERE id = $1 AND workspace_id = $2; + +-- name: CountActiveCustomIssueStatuses :one +-- Active custom (non-system) statuses, for the per-workspace cap. System +-- statuses never count against it — the 7 built-ins are always present. +SELECT COUNT(*) FROM issue_status +WHERE workspace_id = $1 AND system_key IS NULL AND archived_at IS NULL; + +-- name: CreateCustomIssueStatus :one +-- Custom statuses always have system_key = NULL and append to the end of their +-- Category: position = max(position within category) + 1, so they sort after +-- the built-ins of the same Category. +INSERT INTO issue_status ( + workspace_id, name, description, icon, color, category, system_key, is_default, position +) +SELECT sqlc.arg('workspace_id')::uuid, + sqlc.arg('name')::text, + sqlc.arg('description')::text, + sqlc.arg('icon')::text, + sqlc.arg('color')::text, + sqlc.arg('category')::text, + NULL, + sqlc.arg('is_default')::bool, + COALESCE((SELECT MAX(position) FROM issue_status + WHERE workspace_id = sqlc.arg('workspace_id')::uuid + AND category = sqlc.arg('category')::text), 0) + 1 +RETURNING *; + +-- name: UpdateIssueStatusFields :one +-- Mutable human-facing fields only. category / system_key / workspace_id are +-- immutable and never appear here (the handler rejects them with 400). is_default +-- is handled by the default-swap flow below, not this COALESCE update, so the +-- one-default-per-Category invariant can be maintained across rows in one tx. +UPDATE issue_status SET + name = COALESCE(sqlc.narg('name'), name), + description = COALESCE(sqlc.narg('description'), description), + icon = COALESCE(sqlc.narg('icon'), icon), + color = COALESCE(sqlc.narg('color'), color), + position = COALESCE(sqlc.narg('position'), position), + updated_at = now() +WHERE id = $1 AND workspace_id = $2 +RETURNING *; + +-- name: ClearCategoryDefault :exec +-- Drop is_default from every active status in a Category, so a new default can +-- be set without tripping the (workspace_id, category) partial unique index. +-- Run inside the same tx as the promote below (or a create-with-default). +UPDATE issue_status SET is_default = FALSE, updated_at = now() +WHERE workspace_id = sqlc.arg('workspace_id')::uuid + AND category = sqlc.arg('category')::text + AND is_default = TRUE + AND archived_at IS NULL; + +-- name: SetIssueStatusDefault :one +UPDATE issue_status SET is_default = sqlc.arg('is_default')::bool, updated_at = now() +WHERE id = $1 AND workspace_id = $2 +RETURNING *; + +-- name: ArchiveIssueStatus :one +UPDATE issue_status SET archived_at = now(), updated_at = now() +WHERE id = $1 AND workspace_id = $2 +RETURNING *; + +-- name: CountIssuesUsingStatus :one +-- Issues currently pointing at a status via the authoritative status_id. +SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND status_id = $2; + +-- name: ReassignIssuesStatus :exec +-- Move every issue off one status onto another during archive migration. The +-- handler guarantees both are the same Category, so the legacy `status` +-- projection (Category for custom statuses) is unchanged and is left as-is. +UPDATE issue SET status_id = sqlc.arg('to_status_id')::uuid, updated_at = now() +WHERE workspace_id = sqlc.arg('workspace_id')::uuid + AND status_id = sqlc.arg('from_status_id')::uuid; diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index d6e0d8d2fc8..c80e3b567b5 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -101,6 +101,11 @@ const ( EventPropertyUpdated = "property:updated" EventIssuePropertiesChanged = "issue_properties:changed" + // Custom issue status catalog events (MUL-4809). Statuses are archived, + // never hard-deleted, so archive arrives as issue_status:updated. + EventIssueStatusCreated = "issue_status:created" + EventIssueStatusUpdated = "issue_status:updated" + // Pin events EventPinCreated = "pin:created" EventPinDeleted = "pin:deleted" From 485184819a73074b3886e068b1ddd02258e66615 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 15:12:54 +0800 Subject: [PATCH 05/41] =?UTF-8?q?fix(issue-status):=20close=20status-API?= =?UTF-8?q?=20review=20blockers=20=E2=80=94=20locks,=20presence,=20gates?= =?UTF-8?q?=20(MUL-4809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the status-management API review (comment e5db7038). P0: - CreateCustomIssueStatus now takes a FOR KEY SHARE workspace existence gate (mirrors Ensure), so a create that races a workspace delete inserts zero rows instead of leaving an orphan status. Zero rows -> pgx.ErrNoRows -> 404. - Extract issuestatus.LockWorkspaceForStatusWrite / WorkspaceLockKey as the single canonical lock protocol, keyed on the canonical workspace UUID (not the raw request string), so differently-cased UUIDs can no longer take distinct advisory locks and bypass mutual exclusion. All catalog writes route through it; the archive census/reassign/archive stays under it. P1: - Reserved-alias rule applies to custom statuses only; a built-in may be renamed back to its reserved default name ("Todo", ...). - Immutable fields (category/system_key/workspace_id) are rejected on field presence in the raw JSON, so an explicit null is a 400, not a silent 200. - include_archived=true is gated to owner/admin (rejects agents); the active catalog stays readable by any member/agent. Tests: create-after-delete orphan gate, canonical-key equality, controlled- concurrency lock serialization + archive/assignment closure (no issue stranded on an archived status), immutable-null 400, built-in reserved rename, admin-only archived view, cross-workspace 404. Scope unchanged: status_id still not read on machine paths, no issue-write path is locked. Co-authored-by: multica-agent --- server/internal/handler/issue_status.go | 104 ++++++-- .../handler/issue_status_review_test.go | 209 +++++++++++++++ server/internal/issuestatus/lock.go | 45 ++++ server/internal/issuestatus/lock_test.go | 237 ++++++++++++++++++ server/pkg/db/generated/issue_status.sql.go | 28 ++- server/pkg/db/queries/issue_status.sql | 18 +- 6 files changed, 603 insertions(+), 38 deletions(-) create mode 100644 server/internal/handler/issue_status_review_test.go create mode 100644 server/internal/issuestatus/lock.go create mode 100644 server/internal/issuestatus/lock_test.go diff --git a/server/internal/handler/issue_status.go b/server/internal/handler/issue_status.go index 4d0861ace2f..7e219a8089a 100644 --- a/server/internal/handler/issue_status.go +++ b/server/internal/handler/issue_status.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "math" "net/http" @@ -113,6 +114,11 @@ type CreateIssueStatusRequest struct { IsDefault bool `json:"is_default"` } +// UpdateIssueStatusRequest carries only the mutable fields. The immutable fields +// (category / system_key / workspace_id) are intentionally absent here: their +// presence in the request body is detected separately on the raw JSON so an +// explicit `null` is still rejected (plan §5.3), which a pointer decode cannot +// distinguish from an omitted key. type UpdateIssueStatusRequest struct { Name *string `json:"name"` Description *string `json:"description"` @@ -120,13 +126,12 @@ type UpdateIssueStatusRequest struct { Color *string `json:"color"` Position *float64 `json:"position"` IsDefault *bool `json:"is_default"` - // Immutable fields. They are decoded only to reject the request loudly if a - // caller sends them (plan §5.3), never applied. - Category *string `json:"category"` - SystemKey *string `json:"system_key"` - WorkspaceID *string `json:"workspace_id"` } +// immutableIssueStatusFields are rejected whenever their key appears in a PATCH +// body, even as an explicit JSON null (plan §5.3). +var immutableIssueStatusFields = []string{"category", "system_key", "workspace_id"} + func issueStatusToResponse(s db.IssueStatus) IssueStatusResponse { resp := IssueStatusResponse{ ID: uuidToString(s.ID), @@ -158,11 +163,17 @@ func issueStatusToResponse(s db.IssueStatus) IssueStatusResponse { // Validation // --------------------------------------------------------------------------- -// validateIssueStatusName trims and validates a display name. Beyond length and -// control-char rules it rejects the 7 reserved alias tokens: the alias resolver -// claims those first, so a status named "todo" or "in_review" could never be -// targeted by its own name (plan §3.1). -func validateIssueStatusName(raw string) (string, error) { +// validateIssueStatusName trims and validates a display name. +// +// allowReserved controls the reserved-alias rule (plan §3.1): custom statuses +// (allowReserved=false) may not take one of the 7 reserved alias tokens, because +// the alias resolver claims those first, so a custom status named "todo" or +// "in_review" could never be targeted by its own name. Built-in statuses +// (allowReserved=true) are seeded with names like "Todo"/"Done"/"Blocked" that +// normalize onto those tokens, so renaming one and renaming it back to its +// original name must stay allowed — the reserved rule would otherwise brick the +// built-ins' own default names. Length and control-char rules always apply. +func validateIssueStatusName(raw string, allowReserved bool) (string, error) { for _, r := range raw { if unicode.IsControl(r) { return "", errors.New("name cannot contain tabs, newlines, or control characters") @@ -175,7 +186,7 @@ func validateIssueStatusName(raw string) (string, error) { if utf8.RuneCountInString(name) > maxIssueStatusNameLen { return "", fmt.Errorf("name must be %d characters or fewer", maxIssueStatusNameLen) } - if issuestatus.IsReservedStatusToken(name) { + if !allowReserved && issuestatus.IsReservedStatusToken(name) { return "", fmt.Errorf("%q is a reserved status alias and cannot be a status name", name) } return name, nil @@ -239,17 +250,21 @@ func (h *Handler) requireIssueStatusAdmin(w http.ResponseWriter, r *http.Request return workspaceID, userID, true } -// withIssueStatusLock runs fn inside a transaction holding a workspace-scoped +// withIssueStatusLock runs fn inside a transaction holding the workspace-scoped // advisory lock, serializing catalog writes for the workspace: the active-count // cap, name-uniqueness, and the clear-then-set default swap are all -// read-then-write and must not interleave. The lock is transaction-scoped. -func (h *Handler) withIssueStatusLock(r *http.Request, workspaceID string, fn func(q *db.Queries) error) error { +// read-then-write and must not interleave. The lock key is derived from the +// canonical workspace UUID (issuestatus.WorkspaceLockKey), the single protocol +// every status write shares, so a differently-formatted UUID cannot take a +// distinct lock and bypass mutual exclusion. The lock is transaction-scoped and +// releases on commit or rollback. +func (h *Handler) withIssueStatusLock(r *http.Request, wsUUID pgtype.UUID, fn func(q *db.Queries) error) error { tx, err := h.TxStarter.Begin(r.Context()) if err != nil { return err } defer tx.Rollback(r.Context()) - if _, err := tx.Exec(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", "issuestatus:"+workspaceID); err != nil { + if err := issuestatus.LockWorkspaceForStatusWrite(r.Context(), tx, wsUUID); err != nil { return err } if err := fn(h.Queries.WithTx(tx)); err != nil { @@ -263,8 +278,11 @@ func (h *Handler) withIssueStatusLock(r *http.Request, workspaceID string, fn fu // --------------------------------------------------------------------------- // ListIssueStatuses (GET /api/issue-statuses) returns the workspace catalog plus -// the alias resolution table. Readable by any workspace member and by agents — -// the alias table is exactly what an agent reads before calling `issue status`. +// the alias resolution table. The active catalog is readable by any workspace +// member and by agents — the alias table is exactly what an agent reads before +// calling `issue status`. include_archived=true is an admin-only management view +// (plan §5.1): it exposes soft-deleted statuses, so it is gated behind the same +// owner/admin check as catalog writes (and rejects agents). func (h *Handler) ListIssueStatuses(w http.ResponseWriter, r *http.Request) { workspaceID := h.resolveWorkspaceID(r) wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") @@ -272,6 +290,11 @@ func (h *Handler) ListIssueStatuses(w http.ResponseWriter, r *http.Request) { return } includeArchived := r.URL.Query().Get("include_archived") == "true" + if includeArchived { + if _, _, adminOK := h.requireIssueStatusAdmin(w, r); !adminOK { + return + } + } statuses, err := h.Queries.ListWorkspaceIssueStatuses(r.Context(), db.ListWorkspaceIssueStatusesParams{ WorkspaceID: wsUUID, IncludeArchived: includeArchived, @@ -324,7 +347,8 @@ func (h *Handler) CreateIssueStatus(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid request body") return } - name, err := validateIssueStatusName(req.Name) + // Custom statuses may never take a reserved alias token (allowReserved=false). + name, err := validateIssueStatusName(req.Name, false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -357,7 +381,7 @@ func (h *Handler) CreateIssueStatus(w http.ResponseWriter, r *http.Request) { httpStatus, httpMsg = status, msg return errClientRejected } - err = h.withIssueStatusLock(r, workspaceID, func(q *db.Queries) error { + err = h.withIssueStatusLock(r, wsUUID, func(q *db.Queries) error { active, err := q.CountActiveCustomIssueStatuses(r.Context(), wsUUID) if err != nil { return err @@ -381,6 +405,13 @@ func (h *Handler) CreateIssueStatus(w http.ResponseWriter, r *http.Request) { Category: category, IsDefault: req.IsDefault, }) + // Zero rows means the FOR KEY SHARE existence gate found no workspace: a + // concurrent DeleteWorkspace won the race. Surface it as 404 instead of a + // 500, and roll back (the ClearCategoryDefault above touched nothing since + // the workspace is gone). + if errors.Is(err, pgx.ErrNoRows) { + return fail(http.StatusNotFound, "workspace not found") + } return err }) if err != nil { @@ -414,14 +445,30 @@ func (h *Handler) UpdateIssueStatus(w http.ResponseWriter, r *http.Request) { if !ok { return } - var req UpdateIssueStatusRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Read the body once: presence of an immutable field is detected on the raw + // JSON (so an explicit `null` still counts as present), then the same bytes + // are decoded into the mutable-field struct. + body, err := io.ReadAll(r.Body) + if err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } - // Immutable fields are rejected loudly, not silently ignored (plan §5.3). - if req.Category != nil || req.SystemKey != nil || req.WorkspaceID != nil { - writeError(w, http.StatusBadRequest, "immutable_field: category, system_key, and workspace_id cannot be changed; create a new status and migrate issues instead") + var rawFields map[string]json.RawMessage + if err := json.Unmarshal(body, &rawFields); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + // Immutable fields are rejected loudly whenever the key is present — even as an + // explicit null — never silently ignored (plan §5.3). + for _, field := range immutableIssueStatusFields { + if _, present := rawFields[field]; present { + writeError(w, http.StatusBadRequest, "immutable_field: category, system_key, and workspace_id cannot be changed; create a new status and migrate issues instead") + return + } + } + var req UpdateIssueStatusRequest + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") return } @@ -432,7 +479,7 @@ func (h *Handler) UpdateIssueStatus(w http.ResponseWriter, r *http.Request) { httpStatus, httpMsg = status, msg return errClientRejected } - err := h.withIssueStatusLock(r, workspaceID, func(q *db.Queries) error { + err = h.withIssueStatusLock(r, wsUUID, func(q *db.Queries) error { existing, err := q.GetWorkspaceIssueStatus(r.Context(), db.GetWorkspaceIssueStatusParams{ID: idUUID, WorkspaceID: wsUUID}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -443,7 +490,10 @@ func (h *Handler) UpdateIssueStatus(w http.ResponseWriter, r *http.Request) { params := db.UpdateIssueStatusFieldsParams{ID: idUUID, WorkspaceID: wsUUID} if req.Name != nil { - name, err := validateIssueStatusName(*req.Name) + // Built-in statuses may bear their reserved default names ("Todo", + // "Done", ...), so renaming one and back stays allowed; custom statuses + // may not take a reserved alias token. + name, err := validateIssueStatusName(*req.Name, existing.SystemKey.Valid) if err != nil { return fail(http.StatusBadRequest, err.Error()) } @@ -555,7 +605,7 @@ func (h *Handler) DeleteIssueStatus(w http.ResponseWriter, r *http.Request) { httpStatus, httpMsg = status, msg return errClientRejected } - err := h.withIssueStatusLock(r, workspaceID, func(q *db.Queries) error { + err := h.withIssueStatusLock(r, wsUUID, func(q *db.Queries) error { existing, err := q.GetWorkspaceIssueStatus(r.Context(), db.GetWorkspaceIssueStatusParams{ID: idUUID, WorkspaceID: wsUUID}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/server/internal/handler/issue_status_review_test.go b/server/internal/handler/issue_status_review_test.go new file mode 100644 index 00000000000..820b5a0b7a3 --- /dev/null +++ b/server/internal/handler/issue_status_review_test.go @@ -0,0 +1,209 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/multica-ai/multica/server/internal/issuestatus" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// Regression coverage for the MUL-4809 status-management review (comment +// e5db7038): field-presence immutability, built-in reserved-name rename, the +// admin-only archived view, and cross-workspace tenant isolation. + +// makeNonAdminMember adds a fresh user to the test workspace with the plain +// "member" role and returns its user id. +func makeNonAdminMember(t *testing.T) string { + t.Helper() + ctx := context.Background() + var userID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO "user" (name, email) + VALUES ('Status Nonadmin', 'status-nonadmin-' || gen_random_uuid()::text || '@multica.ai') + RETURNING id::text + `).Scan(&userID); err != nil { + t.Fatalf("create non-admin user: %v", err) + } + if _, err := testPool.Exec(ctx, ` + INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member') + `, testWorkspaceID, userID); err != nil { + t.Fatalf("add non-admin member: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM member WHERE workspace_id = $1 AND user_id = $2`, testWorkspaceID, userID) + testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID) + }) + return userID +} + +// makeSecondWorkspaceWithStatuses creates a second workspace that testUserID +// owns (so the admin gate passes for it) and seeds its status catalog. +func makeSecondWorkspaceWithStatuses(t *testing.T) string { + t.Helper() + ctx := context.Background() + var wsID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO workspace (name, slug, description, issue_prefix) + VALUES ('Status Second WS', 'status-second-' || substr(md5(gen_random_uuid()::text), 1, 12), '', 'SEC') + RETURNING id::text + `).Scan(&wsID); err != nil { + t.Fatalf("create second workspace: %v", err) + } + if _, err := testPool.Exec(ctx, `INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner')`, wsID, testUserID); err != nil { + t.Fatalf("add owner to second workspace: %v", err) + } + if err := issuestatus.Ensure(ctx, db.New(testPool), parseUUID(wsID)); err != nil { + t.Fatalf("seed second workspace statuses: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE workspace_id = $1`, wsID) + testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID) + }) + return wsID +} + +// TestUpdateStatusRejectsImmutableNull covers the field-presence rule (plan +// §5.3): an immutable field is rejected whenever its key is present, including +// as an explicit JSON null, which a pointer decode cannot distinguish from an +// omitted key. +func TestUpdateStatusRejectsImmutableNull(t *testing.T) { + seedTestWorkspaceStatuses(t) + created, _, _ := createStatus(t, map[string]any{"name": "Presence", "category": "in_progress", "icon": "in_progress", "color": "warning"}) + for _, body := range []map[string]any{ + {"category": nil}, + {"system_key": nil}, + {"workspace_id": nil}, + {"name": "Renamed", "category": nil}, // present even alongside a valid mutable field + } { + _, code, msg := patchStatus(t, created.ID, body) + if code != http.StatusBadRequest { + t.Errorf("immutable null %v: expected 400, got %d: %s", body, code, msg) + } + } + // The rejected requests must not have applied the mutable field either. + if cat := getStatusCatalog(t, false); true { + for _, s := range cat.Statuses { + if s.ID == created.ID && s.Name != "Presence" { + t.Errorf("status was renamed by a rejected request: name = %q", s.Name) + } + } + } +} + +// TestRenameBuiltinToReservedNameAllowed covers the reserved-token scope (plan +// §3.1): only custom statuses may not bear a reserved alias token; a built-in +// keeps its reserved default name, so renaming it away and back must succeed. +func TestRenameBuiltinToReservedNameAllowed(t *testing.T) { + seedTestWorkspaceStatuses(t) + todoID := getStatusCatalog(t, false).CategoryDefaults["todo"] // built-in Todo + + if _, code, msg := patchStatus(t, todoID, map[string]any{"name": "待排期"}); code != http.StatusOK { + t.Fatalf("rename built-in away: expected 200, got %d: %s", code, msg) + } + if updated, code, msg := patchStatus(t, todoID, map[string]any{"name": "Todo"}); code != http.StatusOK { + t.Fatalf("rename built-in back to reserved name: expected 200, got %d: %s", code, msg) + } else if updated.Name != "Todo" { + t.Errorf("built-in name = %q, want Todo", updated.Name) + } + + // A custom status still may not take a reserved token, on create or rename. + custom, _, _ := createStatus(t, map[string]any{"name": "Custom Stage", "category": "in_progress", "icon": "in_progress", "color": "warning"}) + if _, code, _ := patchStatus(t, custom.ID, map[string]any{"name": "in_progress"}); code != http.StatusBadRequest { + t.Errorf("custom rename to reserved token: expected 400, got %d", code) + } +} + +// TestIncludeArchivedRequiresAdmin covers the archived-view gate (plan §5.1): +// the active catalog is readable by any member/agent, but include_archived=true +// is an admin-only management view. +func TestIncludeArchivedRequiresAdmin(t *testing.T) { + seedTestWorkspaceStatuses(t) + created, _, _ := createStatus(t, map[string]any{"name": "Retired", "category": "backlog", "icon": "backlog", "color": "muted-foreground"}) + if code, msg := deleteStatus(t, created.ID, ""); code != http.StatusOK { + t.Fatalf("archive: expected 200, got %d: %s", code, msg) + } + + listAs := func(req *http.Request) int { + w := httptest.NewRecorder() + testHandler.ListIssueStatuses(w, req) + return w.Code + } + + // Admin (default owner) may see archived rows. + if code := listAs(newRequest("GET", "/api/issue-statuses?include_archived=true", nil)); code != http.StatusOK { + t.Fatalf("admin include_archived: expected 200, got %d", code) + } + + // Non-admin member: active catalog OK, archived view forbidden. + memberID := makeNonAdminMember(t) + if code := listAs(newRequestAs(memberID, "GET", "/api/issue-statuses", nil)); code != http.StatusOK { + t.Errorf("non-admin active catalog: expected 200, got %d", code) + } + if code := listAs(newRequestAs(memberID, "GET", "/api/issue-statuses?include_archived=true", nil)); code != http.StatusForbidden { + t.Errorf("non-admin include_archived: expected 403, got %d", code) + } + + // Agent: archived view forbidden (agents never manage the catalog). + agentReq := newRequest("GET", "/api/issue-statuses?include_archived=true", nil) + agentReq.Header.Set("X-Actor-Source", "task_token") + agentReq.Header.Set("X-Agent-ID", testUserID) + if code := listAs(agentReq); code != http.StatusForbidden { + t.Errorf("agent include_archived: expected 403, got %d", code) + } + // The agent can still read the active catalog (the alias table it needs). + agentActive := newRequest("GET", "/api/issue-statuses", nil) + agentActive.Header.Set("X-Actor-Source", "task_token") + agentActive.Header.Set("X-Agent-ID", testUserID) + if code := listAs(agentActive); code != http.StatusOK { + t.Errorf("agent active catalog: expected 200, got %d", code) + } +} + +// TestCrossWorkspaceStatusIsolation covers tenant isolation: a status in another +// workspace cannot be mutated by pointing X-Workspace-ID at ours — the +// workspace_id WHERE guard makes it a 404, while the owner can still manage it in +// its own workspace. +func TestCrossWorkspaceStatusIsolation(t *testing.T) { + seedTestWorkspaceStatuses(t) + otherWS := makeSecondWorkspaceWithStatuses(t) + + // Create a custom status in the OTHER workspace. + createReq := newRequest("POST", "/api/issue-statuses", map[string]any{"name": "Foreign Stage", "category": "in_progress", "icon": "in_progress", "color": "warning"}) + createReq.Header.Set("X-Workspace-ID", otherWS) + wc := httptest.NewRecorder() + testHandler.CreateIssueStatus(wc, createReq) + if wc.Code != http.StatusCreated { + t.Fatalf("create in other workspace: expected 201, got %d: %s", wc.Code, wc.Body.String()) + } + var foreign IssueStatusResponse + if err := json.NewDecoder(wc.Body).Decode(&foreign); err != nil { + t.Fatalf("decode foreign status: %v", err) + } + + // PATCH/DELETE it while X-Workspace-ID points at OUR workspace -> 404. + patchReq := withURLParam(newRequest("PATCH", "/api/issue-statuses/"+foreign.ID, map[string]any{"name": "Hijacked"}), "id", foreign.ID) + wp := httptest.NewRecorder() + testHandler.UpdateIssueStatus(wp, patchReq) + if wp.Code != http.StatusNotFound { + t.Errorf("cross-workspace PATCH: expected 404, got %d: %s", wp.Code, wp.Body.String()) + } + delReq := withURLParam(newRequest("DELETE", "/api/issue-statuses/"+foreign.ID, nil), "id", foreign.ID) + wd := httptest.NewRecorder() + testHandler.DeleteIssueStatus(wd, delReq) + if wd.Code != http.StatusNotFound { + t.Errorf("cross-workspace DELETE: expected 404, got %d: %s", wd.Code, wd.Body.String()) + } + + // The owner can still manage it in its own workspace. + okReq := withURLParam(newRequest("PATCH", "/api/issue-statuses/"+foreign.ID, map[string]any{"name": "Renamed OK"}), "id", foreign.ID) + okReq.Header.Set("X-Workspace-ID", otherWS) + wok := httptest.NewRecorder() + testHandler.UpdateIssueStatus(wok, okReq) + if wok.Code != http.StatusOK { + t.Errorf("same-workspace PATCH: expected 200, got %d: %s", wok.Code, wok.Body.String()) + } +} diff --git a/server/internal/issuestatus/lock.go b/server/internal/issuestatus/lock.go new file mode 100644 index 00000000000..a9cddb6ace7 --- /dev/null +++ b/server/internal/issuestatus/lock.go @@ -0,0 +1,45 @@ +package issuestatus + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// WorkspaceLockKey returns the canonical advisory-lock key that serializes every +// write to a workspace's status catalog and every issue-status assignment that +// could race an archive migration (MUL-4809, plan §5.5). +// +// The key is derived from the workspace UUID's canonical byte form (via +// uuidToString), NOT from a raw request string. Two requests that name the same +// workspace with different textual UUID forms — upper vs lower hex, or any other +// formatting — therefore hash to the SAME lock and cannot bypass mutual +// exclusion. Keying on the raw string (as the first cut did) would let two case +// variants take two different advisory locks and run concurrently, breaking the +// count cap, the single-default-per-Category swap, and the archive census. +func WorkspaceLockKey(workspaceID pgtype.UUID) string { + return "issuestatus:" + uuidToString(workspaceID) +} + +// LockWorkspaceForStatusWrite takes the workspace-scoped, transaction-lifetime +// advisory lock that is THE single serialization point for status writes +// (MUL-4809, plan §5.5). Every catalog mutation — create, rename/recolor, +// default swap, and archive-with-issue-migration — and any future issue-status +// assignment that could point an issue at a status being archived MUST run +// inside a transaction and call this first, so those read-then-write sequences +// stay atomic against each other. The lock is released automatically on commit +// or rollback. +// +// Note this advisory lock protects the catalog invariants (cap, single default, +// archive census) between concurrent catalog writers; the no-FK workspace +// existence guard against a concurrent workspace delete is a separate FOR KEY +// SHARE gate inside the seed/create statements (see EnsureWorkspaceSystemIssueStatuses +// and CreateCustomIssueStatus), matching the workspace delete/create protocol. +func LockWorkspaceForStatusWrite(ctx context.Context, tx pgx.Tx, workspaceID pgtype.UUID) error { + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", WorkspaceLockKey(workspaceID)); err != nil { + return fmt.Errorf("lock workspace for issue-status write: %w", err) + } + return nil +} diff --git a/server/internal/issuestatus/lock_test.go b/server/internal/issuestatus/lock_test.go new file mode 100644 index 00000000000..fa9ed97ccf8 --- /dev/null +++ b/server/internal/issuestatus/lock_test.go @@ -0,0 +1,237 @@ +package issuestatus + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// TestCreateCustomStatusAfterWorkspaceDeletedInsertsNothing is the create-side +// mirror of the seed orphan guard (P0, MUL-4809 review): the FOR KEY SHARE +// existence gate in CreateCustomIssueStatus must insert zero rows for a +// workspace that has since been deleted, so a create that raced a workspace +// delete can never leave an orphan status behind. Zero rows surfaces as +// pgx.ErrNoRows to the caller. +func TestCreateCustomStatusAfterWorkspaceDeletedInsertsNothing(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("Ensure: %v", err) + } + // A live workspace accepts a custom status. + if _, err := q.CreateCustomIssueStatus(ctx, db.CreateCustomIssueStatusParams{ + WorkspaceID: wsID, Name: "Live Stage", Description: "", Icon: "in_progress", Color: "warning", Category: "in_progress", + }); err != nil { + t.Fatalf("create on live workspace: %v", err) + } + + if err := q.DeleteWorkspace(ctx, wsID); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + + // After the workspace is gone the existence gate must reject the insert. + _, err := q.CreateCustomIssueStatus(ctx, db.CreateCustomIssueStatusParams{ + WorkspaceID: wsID, Name: "Orphan Stage", Description: "", Icon: "in_progress", Color: "warning", Category: "in_progress", + }) + if !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("create on deleted workspace: want pgx.ErrNoRows, got %v", err) + } + if n := rawStatusCount(ctx, t, wsID); n != 0 { + t.Fatalf("create seeded %d orphan statuses for a deleted workspace; want 0", n) + } +} + +// TestWorkspaceLockKeyCanonicalizesUUID proves the lock key is derived from the +// canonical UUID bytes, not a raw request string: the same workspace reached via +// different textual UUID forms (upper vs lower hex) must map to the SAME key, so +// two case variants cannot take two different advisory locks and bypass mutual +// exclusion (P0, MUL-4809 review). +func TestWorkspaceLockKeyCanonicalizesUUID(t *testing.T) { + lower, err := util.ParseUUID("a1b2c3d4-5e6f-4a8b-9c0d-1e2f3a4b5c6d") + if err != nil { + t.Fatalf("parse lower: %v", err) + } + upper, err := util.ParseUUID("A1B2C3D4-5E6F-4A8B-9C0D-1E2F3A4B5C6D") + if err != nil { + t.Fatalf("parse upper: %v", err) + } + if got, want := WorkspaceLockKey(upper), WorkspaceLockKey(lower); got != want { + t.Fatalf("differently-cased UUIDs produced different lock keys: %q vs %q", got, want) + } + // A different workspace must not collide. + other, err := util.ParseUUID("00000000-0000-4000-8000-000000000001") + if err != nil { + t.Fatalf("parse other: %v", err) + } + if WorkspaceLockKey(other) == WorkspaceLockKey(lower) { + t.Fatal("distinct workspaces produced the same lock key") + } +} + +// TestLockWorkspaceForStatusWriteSerializes is the controlled-concurrency proof +// that the shared advisory lock actually serializes two transactions on the same +// workspace: while tx1 holds it, tx2's acquire blocks; once tx1 releases, tx2 +// proceeds. This is the protocol the catalog CRUD and archive migration share. +func TestLockWorkspaceForStatusWriteSerializes(t *testing.T) { + ctx := context.Background() + wsID := freshWorkspace(ctx, t) + + tx1, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx1: %v", err) + } + defer tx1.Rollback(ctx) + if err := LockWorkspaceForStatusWrite(ctx, tx1, wsID); err != nil { + t.Fatalf("tx1 acquire: %v", err) + } + + acquired := make(chan error, 1) + go func() { + tx2, err := testPool.Begin(context.Background()) + if err != nil { + acquired <- err + return + } + defer tx2.Rollback(context.Background()) + // Blocks until tx1 releases the workspace lock. + acquired <- LockWorkspaceForStatusWrite(context.Background(), tx2, wsID) + }() + + // While tx1 holds the lock, tx2 must stay blocked. + select { + case err := <-acquired: + t.Fatalf("tx2 acquired the lock while tx1 held it (err=%v); lock did not serialize", err) + case <-time.After(300 * time.Millisecond): + } + + // Releasing tx1 must let tx2 acquire promptly. + if err := tx1.Rollback(ctx); err != nil { + t.Fatalf("tx1 release: %v", err) + } + select { + case err := <-acquired: + if err != nil { + t.Fatalf("tx2 acquire after tx1 release: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("tx2 did not acquire the lock after tx1 released it") + } +} + +// TestArchiveMigrationClosesUnderSharedLock is the archive/assignment closure +// proof (P0, MUL-4809 review). It pins the dangerous interleave — a status +// re-point that lands AFTER the archive census — and shows the shared lock plus +// the assignment write's `archived_at IS NULL` guard prevent an issue from being +// stranded on an archived status. The re-point statement is the contract any +// future custom-status assignment path must follow: take +// LockWorkspaceForStatusWrite, then write only onto an active status. +func TestArchiveMigrationClosesUnderSharedLock(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("Ensure: %v", err) + } + // Two custom in_progress statuses: A gets archived, B is the migrate target. + statusA, err := q.CreateCustomIssueStatus(ctx, db.CreateCustomIssueStatusParams{ + WorkspaceID: wsID, Name: "Stage A", Icon: "in_progress", Color: "warning", Category: "in_progress", + }) + if err != nil { + t.Fatalf("create A: %v", err) + } + statusB, err := q.CreateCustomIssueStatus(ctx, db.CreateCustomIssueStatusParams{ + WorkspaceID: wsID, Name: "Stage B", Icon: "in_progress", Color: "warning", Category: "in_progress", + }) + if err != nil { + t.Fatalf("create B: %v", err) + } + + // An issue currently points at A via the authoritative status_id. + var issueID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue (workspace_id, title, status, status_id, priority, creator_type, creator_id, number) + VALUES ($1, 'closure', 'in_progress', $2, 'none', 'member', gen_random_uuid(), + COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1) + RETURNING id::text + `, wsID, statusA.ID).Scan(&issueID); err != nil { + t.Fatalf("insert issue: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) }) + + // tx1 (archive migration) takes the shared lock and holds it. + tx1, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx1: %v", err) + } + defer tx1.Rollback(ctx) + if err := LockWorkspaceForStatusWrite(ctx, tx1, wsID); err != nil { + t.Fatalf("tx1 acquire: %v", err) + } + + // tx2 (a future assignment write) tries to re-point the issue back onto A, + // but blocks on the shared lock until the archive commits. + repointDone := make(chan error, 1) + go func() { + tx2, err := testPool.Begin(context.Background()) + if err != nil { + repointDone <- err + return + } + defer tx2.Rollback(context.Background()) + if err := LockWorkspaceForStatusWrite(context.Background(), tx2, wsID); err != nil { + repointDone <- err + return + } + // The assignment write only lands on an ACTIVE (non-archived) status. + _, err = tx2.Exec(context.Background(), ` + UPDATE issue SET status_id = $2, updated_at = now() + WHERE id = $1 + AND EXISTS (SELECT 1 FROM issue_status WHERE id = $2 AND workspace_id = $3 AND archived_at IS NULL) + `, issueID, statusA.ID, wsID) + if err != nil { + repointDone <- err + return + } + repointDone <- tx2.Commit(context.Background()) + }() + + // The re-point must stay blocked while the archive holds the lock. + select { + case err := <-repointDone: + t.Fatalf("re-point completed while archive held the lock (err=%v); lock did not serialize", err) + case <-time.After(200 * time.Millisecond): + } + + // Archive migration under the lock: reassign A->B, archive A, commit. + qtx := q.WithTx(tx1) + if err := qtx.ReassignIssuesStatus(ctx, db.ReassignIssuesStatusParams{WorkspaceID: wsID, FromStatusID: statusA.ID, ToStatusID: statusB.ID}); err != nil { + t.Fatalf("reassign: %v", err) + } + if _, err := qtx.ArchiveIssueStatus(ctx, db.ArchiveIssueStatusParams{ID: statusA.ID, WorkspaceID: wsID}); err != nil { + t.Fatalf("archive: %v", err) + } + if err := tx1.Commit(ctx); err != nil { + t.Fatalf("archive commit: %v", err) + } + + // The re-point now unblocks; its guard turns it into a no-op (A is archived). + if err := <-repointDone; err != nil { + t.Fatalf("re-point tx: %v", err) + } + + // Invariant: the issue is on B (active), never stranded on the archived A. + var finalStatusID string + if err := testPool.QueryRow(ctx, `SELECT status_id::text FROM issue WHERE id = $1`, issueID).Scan(&finalStatusID); err != nil { + t.Fatalf("read final status_id: %v", err) + } + if finalStatusID != uuidToString(statusB.ID) { + t.Fatalf("issue stranded on archived status: status_id = %s, want migrated to B %s", finalStatusID, uuidToString(statusB.ID)) + } +} diff --git a/server/pkg/db/generated/issue_status.sql.go b/server/pkg/db/generated/issue_status.sql.go index aae130c88be..ab68d428447 100644 --- a/server/pkg/db/generated/issue_status.sql.go +++ b/server/pkg/db/generated/issue_status.sql.go @@ -110,45 +110,57 @@ func (q *Queries) CountWorkspaceIssueStatuses(ctx context.Context, workspaceID p } const createCustomIssueStatus = `-- name: CreateCustomIssueStatus :one +WITH ws AS ( + SELECT id FROM workspace WHERE id = $7::uuid FOR KEY SHARE +) INSERT INTO issue_status ( workspace_id, name, description, icon, color, category, system_key, is_default, position ) -SELECT $1::uuid, +SELECT ws.id, + $1::text, $2::text, $3::text, $4::text, $5::text, - $6::text, - NULL, - $7::bool, + NULL::text, + $6::bool, COALESCE((SELECT MAX(position) FROM issue_status - WHERE workspace_id = $1::uuid - AND category = $6::text), 0) + 1 + WHERE workspace_id = ws.id + AND category = $5::text), 0) + 1 +FROM ws RETURNING id, workspace_id, name, description, icon, color, category, system_key, is_default, position, archived_at, created_at, updated_at ` type CreateCustomIssueStatusParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` Name string `json:"name"` Description string `json:"description"` Icon string `json:"icon"` Color string `json:"color"` Category string `json:"category"` IsDefault bool `json:"is_default"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } // Custom statuses always have system_key = NULL and append to the end of their // Category: position = max(position within category) + 1, so they sort after // the built-ins of the same Category. +// +// The `WITH ws ... FOR KEY SHARE` clause is the no-FK workspace existence gate +// (mirrors EnsureWorkspaceSystemIssueStatuses). It takes the same lock the +// workspace delete/create protocol uses (LockWorkspaceForChatSessionCreate), so +// a concurrent DeleteWorkspace (FOR UPDATE) cannot interleave: if the workspace +// row is already gone, ws is empty and zero rows are inserted, so a create that +// lost the race to a workspace delete can never leave an orphan status behind. +// Zero rows makes this :one return pgx.ErrNoRows, which the handler maps to 404. func (q *Queries) CreateCustomIssueStatus(ctx context.Context, arg CreateCustomIssueStatusParams) (IssueStatus, error) { row := q.db.QueryRow(ctx, createCustomIssueStatus, - arg.WorkspaceID, arg.Name, arg.Description, arg.Icon, arg.Color, arg.Category, arg.IsDefault, + arg.WorkspaceID, ) var i IssueStatus err := row.Scan( diff --git a/server/pkg/db/queries/issue_status.sql b/server/pkg/db/queries/issue_status.sql index e771caafea3..4a73d57af0a 100644 --- a/server/pkg/db/queries/issue_status.sql +++ b/server/pkg/db/queries/issue_status.sql @@ -80,20 +80,32 @@ WHERE workspace_id = $1 AND system_key IS NULL AND archived_at IS NULL; -- Custom statuses always have system_key = NULL and append to the end of their -- Category: position = max(position within category) + 1, so they sort after -- the built-ins of the same Category. +-- +-- The `WITH ws ... FOR KEY SHARE` clause is the no-FK workspace existence gate +-- (mirrors EnsureWorkspaceSystemIssueStatuses). It takes the same lock the +-- workspace delete/create protocol uses (LockWorkspaceForChatSessionCreate), so +-- a concurrent DeleteWorkspace (FOR UPDATE) cannot interleave: if the workspace +-- row is already gone, ws is empty and zero rows are inserted, so a create that +-- lost the race to a workspace delete can never leave an orphan status behind. +-- Zero rows makes this :one return pgx.ErrNoRows, which the handler maps to 404. +WITH ws AS ( + SELECT id FROM workspace WHERE id = sqlc.arg('workspace_id')::uuid FOR KEY SHARE +) INSERT INTO issue_status ( workspace_id, name, description, icon, color, category, system_key, is_default, position ) -SELECT sqlc.arg('workspace_id')::uuid, +SELECT ws.id, sqlc.arg('name')::text, sqlc.arg('description')::text, sqlc.arg('icon')::text, sqlc.arg('color')::text, sqlc.arg('category')::text, - NULL, + NULL::text, sqlc.arg('is_default')::bool, COALESCE((SELECT MAX(position) FROM issue_status - WHERE workspace_id = sqlc.arg('workspace_id')::uuid + WHERE workspace_id = ws.id AND category = sqlc.arg('category')::text), 0) + 1 +FROM ws RETURNING *; -- name: UpdateIssueStatusFields :one From 111417a64fb6c2d6f35df110f375e066182f462c Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 15:20:51 +0800 Subject: [PATCH 06/41] fix(issue-status): renumber migrations 200-206 -> 202-208 (MUL-4809) main advanced past the earlier rebase and took 200/201 for the inbox archived-listing indexes, so the PR's 200-206 collide once GitHub tests the branch merged with main (TestMigrationNumericPrefixesStayUniqueAfterLegacySet fails on the merge). Shift this slice's seven migrations to the next free block, 202-208, and update the three in-file cross-references. SQL bodies are unchanged; each concurrent index stays in its own single-statement file. Co-authored-by: multica-agent --- .../{200_issue_status.down.sql => 202_issue_status.down.sql} | 0 .../{200_issue_status.up.sql => 202_issue_status.up.sql} | 2 +- ...name_index.down.sql => 203_issue_status_name_index.down.sql} | 0 ...tus_name_index.up.sql => 203_issue_status_name_index.up.sql} | 0 ...ndex.down.sql => 204_issue_status_system_key_index.down.sql} | 0 ...ey_index.up.sql => 204_issue_status_system_key_index.up.sql} | 0 ...own.sql => 205_issue_status_category_default_index.down.sql} | 0 ...ex.up.sql => 205_issue_status_category_default_index.up.sql} | 0 ...ex.down.sql => 206_issue_status_workspace_id_index.down.sql} | 0 ..._index.up.sql => 206_issue_status_workspace_id_index.up.sql} | 2 +- ...s_id_column.down.sql => 207_issue_status_id_column.down.sql} | 0 ...tatus_id_column.up.sql => 207_issue_status_id_column.up.sql} | 2 +- ...tus_id_index.down.sql => 208_issue_status_id_index.down.sql} | 0 ..._status_id_index.up.sql => 208_issue_status_id_index.up.sql} | 0 14 files changed, 3 insertions(+), 3 deletions(-) rename server/migrations/{200_issue_status.down.sql => 202_issue_status.down.sql} (100%) rename server/migrations/{200_issue_status.up.sql => 202_issue_status.up.sql} (96%) rename server/migrations/{201_issue_status_name_index.down.sql => 203_issue_status_name_index.down.sql} (100%) rename server/migrations/{201_issue_status_name_index.up.sql => 203_issue_status_name_index.up.sql} (100%) rename server/migrations/{202_issue_status_system_key_index.down.sql => 204_issue_status_system_key_index.down.sql} (100%) rename server/migrations/{202_issue_status_system_key_index.up.sql => 204_issue_status_system_key_index.up.sql} (100%) rename server/migrations/{203_issue_status_category_default_index.down.sql => 205_issue_status_category_default_index.down.sql} (100%) rename server/migrations/{203_issue_status_category_default_index.up.sql => 205_issue_status_category_default_index.up.sql} (100%) rename server/migrations/{204_issue_status_workspace_id_index.down.sql => 206_issue_status_workspace_id_index.down.sql} (100%) rename server/migrations/{204_issue_status_workspace_id_index.up.sql => 206_issue_status_workspace_id_index.up.sql} (87%) rename server/migrations/{205_issue_status_id_column.down.sql => 207_issue_status_id_column.down.sql} (100%) rename server/migrations/{205_issue_status_id_column.up.sql => 207_issue_status_id_column.up.sql} (91%) rename server/migrations/{206_issue_status_id_index.down.sql => 208_issue_status_id_index.down.sql} (100%) rename server/migrations/{206_issue_status_id_index.up.sql => 208_issue_status_id_index.up.sql} (100%) diff --git a/server/migrations/200_issue_status.down.sql b/server/migrations/202_issue_status.down.sql similarity index 100% rename from server/migrations/200_issue_status.down.sql rename to server/migrations/202_issue_status.down.sql diff --git a/server/migrations/200_issue_status.up.sql b/server/migrations/202_issue_status.up.sql similarity index 96% rename from server/migrations/200_issue_status.up.sql rename to server/migrations/202_issue_status.up.sql index d413bc828eb..0bec7738999 100644 --- a/server/migrations/200_issue_status.up.sql +++ b/server/migrations/202_issue_status.up.sql @@ -11,7 +11,7 @@ -- reference; tenant consistency and cleanup are enforced in app code (the -- workspace-delete path removes these rows in the same transaction). All -- indexes are created CONCURRENTLY in their own single-statement follow-up --- migrations (201-204) because Postgres forbids CREATE INDEX CONCURRENTLY +-- migrations (203-206) because Postgres forbids CREATE INDEX CONCURRENTLY -- inside this table-create transaction. CREATE TABLE issue_status ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/server/migrations/201_issue_status_name_index.down.sql b/server/migrations/203_issue_status_name_index.down.sql similarity index 100% rename from server/migrations/201_issue_status_name_index.down.sql rename to server/migrations/203_issue_status_name_index.down.sql diff --git a/server/migrations/201_issue_status_name_index.up.sql b/server/migrations/203_issue_status_name_index.up.sql similarity index 100% rename from server/migrations/201_issue_status_name_index.up.sql rename to server/migrations/203_issue_status_name_index.up.sql diff --git a/server/migrations/202_issue_status_system_key_index.down.sql b/server/migrations/204_issue_status_system_key_index.down.sql similarity index 100% rename from server/migrations/202_issue_status_system_key_index.down.sql rename to server/migrations/204_issue_status_system_key_index.down.sql diff --git a/server/migrations/202_issue_status_system_key_index.up.sql b/server/migrations/204_issue_status_system_key_index.up.sql similarity index 100% rename from server/migrations/202_issue_status_system_key_index.up.sql rename to server/migrations/204_issue_status_system_key_index.up.sql diff --git a/server/migrations/203_issue_status_category_default_index.down.sql b/server/migrations/205_issue_status_category_default_index.down.sql similarity index 100% rename from server/migrations/203_issue_status_category_default_index.down.sql rename to server/migrations/205_issue_status_category_default_index.down.sql diff --git a/server/migrations/203_issue_status_category_default_index.up.sql b/server/migrations/205_issue_status_category_default_index.up.sql similarity index 100% rename from server/migrations/203_issue_status_category_default_index.up.sql rename to server/migrations/205_issue_status_category_default_index.up.sql diff --git a/server/migrations/204_issue_status_workspace_id_index.down.sql b/server/migrations/206_issue_status_workspace_id_index.down.sql similarity index 100% rename from server/migrations/204_issue_status_workspace_id_index.down.sql rename to server/migrations/206_issue_status_workspace_id_index.down.sql diff --git a/server/migrations/204_issue_status_workspace_id_index.up.sql b/server/migrations/206_issue_status_workspace_id_index.up.sql similarity index 87% rename from server/migrations/204_issue_status_workspace_id_index.up.sql rename to server/migrations/206_issue_status_workspace_id_index.up.sql index 5870ea6101b..3c8df0f3788 100644 --- a/server/migrations/204_issue_status_workspace_id_index.up.sql +++ b/server/migrations/206_issue_status_workspace_id_index.up.sql @@ -1,5 +1,5 @@ -- Non-partial (workspace_id) index covering ALL rows, including archived ones. --- The three catalog uniqueness indexes (201-203) are partial (active rows / +-- The three catalog uniqueness indexes (203-205) are partial (active rows / -- non-NULL system_key), so none of them can serve the workspace-scoped -- delete/cleanup path that must remove every issue_status row for a workspace -- regardless of archived_at. Built CONCURRENTLY in its own single-statement diff --git a/server/migrations/205_issue_status_id_column.down.sql b/server/migrations/207_issue_status_id_column.down.sql similarity index 100% rename from server/migrations/205_issue_status_id_column.down.sql rename to server/migrations/207_issue_status_id_column.down.sql diff --git a/server/migrations/205_issue_status_id_column.up.sql b/server/migrations/207_issue_status_id_column.up.sql similarity index 91% rename from server/migrations/205_issue_status_id_column.up.sql rename to server/migrations/207_issue_status_id_column.up.sql index 9bc6aad7541..06785e30060 100644 --- a/server/migrations/205_issue_status_id_column.up.sql +++ b/server/migrations/207_issue_status_id_column.up.sql @@ -2,5 +2,5 @@ -- window. No FK (CLAUDE.md); resolved to issue_status in app code. Nothing -- reads or writes it yet -- the legacy issue.status TEXT column stays the -- source of truth until the Phase 2 double-write lands. Its index is created --- CONCURRENTLY in migration 206. +-- CONCURRENTLY in migration 208. ALTER TABLE issue ADD COLUMN IF NOT EXISTS status_id UUID; diff --git a/server/migrations/206_issue_status_id_index.down.sql b/server/migrations/208_issue_status_id_index.down.sql similarity index 100% rename from server/migrations/206_issue_status_id_index.down.sql rename to server/migrations/208_issue_status_id_index.down.sql diff --git a/server/migrations/206_issue_status_id_index.up.sql b/server/migrations/208_issue_status_id_index.up.sql similarity index 100% rename from server/migrations/206_issue_status_id_index.up.sql rename to server/migrations/208_issue_status_id_index.up.sql From 3b5f609d9673a3fb7cb86bda3273c0ae37778cde Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 16:47:30 +0800 Subject: [PATCH 07/41] fix(issue-status): take workspace FOR KEY SHARE first to avoid create/delete deadlock (MUL-4809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review found a lock-order inversion: a create with is_default=true took the status advisory lock and ClearCategoryDefault (locking a status row) before reaching for the workspace row FOR KEY SHARE inside the INSERT, while a concurrent DeleteWorkspace holds the workspace row FOR UPDATE then deletes the status rows — a cycle that deadlocks (40P01). Hoist the workspace-row FOR KEY SHARE gate into LockWorkspaceForStatusWrite, so every status write locks in one order: workspace row -> advisory -> status rows, matching DeleteWorkspace's workspace-row-first order. A missing workspace short-circuits to ErrWorkspaceGone (mapped to 404) before any default swap or write. The existence CTE in CreateCustomIssueStatus stays as defense-in-depth. Adds a controlled-concurrency regression test reproducing the exact interleave (delete grabs FOR UPDATE while the create holds a status row, then the create inserts): it deadlocks on the old order and passes only with the FKS-first fix (verified by reverting the fix — test fails with 40P01 — and restoring it). Co-authored-by: multica-agent --- server/internal/handler/issue_status.go | 30 ++++-- server/internal/issuestatus/lock.go | 54 +++++++--- server/internal/issuestatus/lock_test.go | 110 ++++++++++++++++++++ server/pkg/db/generated/issue_status.sql.go | 15 +-- server/pkg/db/queries/issue_status.sql | 15 +-- 5 files changed, 189 insertions(+), 35 deletions(-) diff --git a/server/internal/handler/issue_status.go b/server/internal/handler/issue_status.go index 7e219a8089a..a3b479b2ebc 100644 --- a/server/internal/handler/issue_status.go +++ b/server/internal/handler/issue_status.go @@ -250,14 +250,16 @@ func (h *Handler) requireIssueStatusAdmin(w http.ResponseWriter, r *http.Request return workspaceID, userID, true } -// withIssueStatusLock runs fn inside a transaction holding the workspace-scoped -// advisory lock, serializing catalog writes for the workspace: the active-count -// cap, name-uniqueness, and the clear-then-set default swap are all -// read-then-write and must not interleave. The lock key is derived from the -// canonical workspace UUID (issuestatus.WorkspaceLockKey), the single protocol -// every status write shares, so a differently-formatted UUID cannot take a -// distinct lock and bypass mutual exclusion. The lock is transaction-scoped and -// releases on commit or rollback. +// withIssueStatusLock runs fn inside a transaction that first takes the shared +// status-write locks (issuestatus.LockWorkspaceForStatusWrite): the workspace +// row FOR KEY SHARE gate, then the workspace-scoped advisory lock. Because that +// gate runs BEFORE fn touches any status row, every catalog write takes locks in +// the same order — workspace row -> status rows — as DeleteWorkspace, so the +// default-swap ClearCategoryDefault can no longer deadlock against a concurrent +// delete. The advisory lock serializes catalog writers so the active-count cap, +// name-uniqueness, and clear-then-set default swap can't interleave. A workspace +// already deleted surfaces as ErrWorkspaceGone before fn runs. Both locks are +// transaction-scoped and release on commit or rollback. func (h *Handler) withIssueStatusLock(r *http.Request, wsUUID pgtype.UUID, fn func(q *db.Queries) error) error { tx, err := h.TxStarter.Begin(r.Context()) if err != nil { @@ -415,6 +417,10 @@ func (h *Handler) CreateIssueStatus(w http.ResponseWriter, r *http.Request) { return err }) if err != nil { + if errors.Is(err, issuestatus.ErrWorkspaceGone) { + writeError(w, http.StatusNotFound, "workspace not found") + return + } if errors.Is(err, errClientRejected) { writeError(w, httpStatus, httpMsg) return @@ -567,6 +573,10 @@ func (h *Handler) UpdateIssueStatus(w http.ResponseWriter, r *http.Request) { return nil }) if err != nil { + if errors.Is(err, issuestatus.ErrWorkspaceGone) { + writeError(w, http.StatusNotFound, "workspace not found") + return + } if errors.Is(err, errClientRejected) { writeError(w, httpStatus, httpMsg) return @@ -664,6 +674,10 @@ func (h *Handler) DeleteIssueStatus(w http.ResponseWriter, r *http.Request) { return err }) if err != nil { + if errors.Is(err, issuestatus.ErrWorkspaceGone) { + writeError(w, http.StatusNotFound, "workspace not found") + return + } if errors.Is(err, errClientRejected) { writeError(w, httpStatus, httpMsg) return diff --git a/server/internal/issuestatus/lock.go b/server/internal/issuestatus/lock.go index a9cddb6ace7..0658cdf793a 100644 --- a/server/internal/issuestatus/lock.go +++ b/server/internal/issuestatus/lock.go @@ -2,12 +2,20 @@ package issuestatus import ( "context" + "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) +// ErrWorkspaceGone is returned by LockWorkspaceForStatusWrite when the workspace +// row no longer exists (a concurrent DeleteWorkspace already committed). Callers +// map it to 404 and must not perform any catalog write. It is distinct from +// pgx.ErrNoRows so the caller can tell "workspace deleted" from an ordinary +// missing-status lookup. +var ErrWorkspaceGone = errors.New("workspace no longer exists") + // WorkspaceLockKey returns the canonical advisory-lock key that serializes every // write to a workspace's status catalog and every issue-status assignment that // could race an archive migration (MUL-4809, plan §5.5). @@ -23,21 +31,41 @@ func WorkspaceLockKey(workspaceID pgtype.UUID) string { return "issuestatus:" + uuidToString(workspaceID) } -// LockWorkspaceForStatusWrite takes the workspace-scoped, transaction-lifetime -// advisory lock that is THE single serialization point for status writes -// (MUL-4809, plan §5.5). Every catalog mutation — create, rename/recolor, -// default swap, and archive-with-issue-migration — and any future issue-status -// assignment that could point an issue at a status being archived MUST run -// inside a transaction and call this first, so those read-then-write sequences -// stay atomic against each other. The lock is released automatically on commit -// or rollback. +// LockWorkspaceForStatusWrite establishes the ONE lock order every status write +// shares (MUL-4809, plan §5.5), in two steps that MUST be the first thing a +// status-write transaction does: +// +// 1. Take FOR KEY SHARE on the workspace row. This is the no-FK existence gate +// and — critically — it fixes the global lock order. DeleteWorkspace holds +// the workspace row FOR UPDATE and only then deletes issue_status rows; if a +// status write instead grabbed status rows first (e.g. the default-swap +// ClearCategoryDefault) and reached for the workspace row afterwards, the two +// transactions would deadlock (40P01). Acquiring the workspace row FIRST here +// means both sides always take workspace -> status rows, so one simply waits +// for the other. FOR KEY SHARE shares with other status writers (they don't +// serialize on this) but conflicts with the delete's FOR UPDATE. A missing +// row means the workspace was already deleted -> ErrWorkspaceGone, before any +// write. +// 2. Take the workspace-scoped advisory lock. This serializes catalog writers +// against each other so the count cap, single-default-per-Category swap, and +// archive census stay atomic. Its key is the canonical workspace UUID (see +// WorkspaceLockKey), so a differently-formatted UUID can't take a distinct +// lock and bypass mutual exclusion. // -// Note this advisory lock protects the catalog invariants (cap, single default, -// archive census) between concurrent catalog writers; the no-FK workspace -// existence guard against a concurrent workspace delete is a separate FOR KEY -// SHARE gate inside the seed/create statements (see EnsureWorkspaceSystemIssueStatuses -// and CreateCustomIssueStatus), matching the workspace delete/create protocol. +// Every catalog mutation (create, rename/recolor, default swap, archive-with- +// migration) and any future issue-status assignment that could point an issue at +// a status being archived calls this first. Both locks release on commit/rollback. func LockWorkspaceForStatusWrite(ctx context.Context, tx pgx.Tx, workspaceID pgtype.UUID) error { + // Step 1: workspace-row existence gate + global lock order. + var id pgtype.UUID + err := tx.QueryRow(ctx, "SELECT id FROM workspace WHERE id = $1 FOR KEY SHARE", workspaceID).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return ErrWorkspaceGone + } + if err != nil { + return fmt.Errorf("lock workspace row for issue-status write: %w", err) + } + // Step 2: catalog-writer serialization. if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", WorkspaceLockKey(workspaceID)); err != nil { return fmt.Errorf("lock workspace for issue-status write: %w", err) } diff --git a/server/internal/issuestatus/lock_test.go b/server/internal/issuestatus/lock_test.go index fa9ed97ccf8..1c422b84633 100644 --- a/server/internal/issuestatus/lock_test.go +++ b/server/internal/issuestatus/lock_test.go @@ -235,3 +235,113 @@ func TestArchiveMigrationClosesUnderSharedLock(t *testing.T) { t.Fatalf("issue stranded on archived status: status_id = %s, want migrated to B %s", finalStatusID, uuidToString(statusB.ID)) } } + +// TestDefaultCreateAndWorkspaceDeleteNoDeadlock is the lock-order regression +// guard (P0 follow-up, MUL-4809 review). It reproduces the exact interleave that +// deadlocked before the fix: a create with is_default=true holds a status row +// (ClearCategoryDefault) while a concurrent DeleteWorkspace holds the workspace +// row FOR UPDATE, and only then does the create reach for the workspace row. If +// the create took the workspace lock at that point (inside the INSERT), the two +// would deadlock (40P01). +// +// LockWorkspaceForStatusWrite now takes the workspace row FOR KEY SHARE FIRST, so +// the create already owns the workspace lock before it touches a status row: the +// concurrent delete blocks on FOR KEY SHARE, the create finishes, and the delete +// then sweeps cleanly with no orphan. The interleave is arranged so that +// reintroducing the old order (workspace lock taken only in the INSERT) makes the +// CreateCustomIssueStatus step below deadlock and fails this test. +func TestDefaultCreateAndWorkspaceDeleteNoDeadlock(t *testing.T) { + ctx := context.Background() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("Ensure: %v", err) + } + + // tx1 mirrors the create-with-default flow up to (but not including) the row + // insert: shared status-write locks first, then clear the current default, + // which locks a status row. + tx1, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx1: %v", err) + } + defer tx1.Rollback(ctx) + if err := LockWorkspaceForStatusWrite(ctx, tx1, wsID); err != nil { + t.Fatalf("tx1 status locks: %v", err) + } + q1 := q.WithTx(tx1) + if err := q1.ClearCategoryDefault(ctx, db.ClearCategoryDefaultParams{WorkspaceID: wsID, Category: "todo"}); err != nil { + t.Fatalf("tx1 clear default: %v", err) + } + + // tx2 (workspace delete) races in NOW, while tx1 holds a status row but has + // not yet inserted: it grabs the workspace row FOR UPDATE then sweeps the + // catalog. `started` fires just before it reaches for the workspace lock. + started := make(chan struct{}) + deleteDone := make(chan error, 1) + go func() { + tx2, err := testPool.Begin(context.Background()) + if err != nil { + close(started) + deleteDone <- err + return + } + defer tx2.Rollback(context.Background()) + q2 := q.WithTx(tx2) + close(started) + if _, err := q2.LockWorkspaceForDelete(context.Background(), wsID); err != nil { + deleteDone <- err + return + } + if err := q2.DeleteWorkspace(context.Background(), wsID); err != nil { + deleteDone <- err + return + } + deleteDone <- tx2.Commit(context.Background()) + }() + + // Let tx2's workspace-lock request reach Postgres (it would acquire on the old + // order, or block on FOR KEY SHARE on the fixed one) before tx1 inserts. + <-started + time.Sleep(250 * time.Millisecond) + + // The insert reaches for the workspace row (FOR KEY SHARE in its CTE). On the + // fixed order tx1 already holds it, so this proceeds; on the old order it + // would block on tx2's FOR UPDATE and deadlock (40P01) here. + if _, err := q1.CreateCustomIssueStatus(ctx, db.CreateCustomIssueStatusParams{ + WorkspaceID: wsID, Name: "Triage", Icon: "todo", Color: "warning", Category: "todo", IsDefault: true, + }); err != nil { + t.Fatalf("tx1 create default deadlocked or failed (lock-order regression?): %v", err) + } + + // The delete must still be blocked, waiting on tx1's FOR KEY SHARE. + select { + case err := <-deleteDone: + t.Fatalf("workspace delete completed while the create still held the lock (err=%v); expected it to wait", err) + case <-time.After(150 * time.Millisecond): + } + + if err := tx1.Commit(ctx); err != nil { + t.Fatalf("tx1 commit failed: %v", err) + } + select { + case err := <-deleteDone: + if err != nil { + t.Fatalf("workspace delete errored (want clean, no deadlock): %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("workspace delete never completed after the create committed") + } + + // No orphan catalog rows survive the delete, and the workspace is gone. + if n := rawStatusCount(ctx, t, wsID); n != 0 { + t.Fatalf("workspace delete left %d orphan issue_status rows; want 0", n) + } + var exists bool + if err := testPool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1)", wsID).Scan(&exists); err != nil { + t.Fatalf("check workspace existence: %v", err) + } + if exists { + t.Fatal("workspace still exists after its delete committed") + } +} diff --git a/server/pkg/db/generated/issue_status.sql.go b/server/pkg/db/generated/issue_status.sql.go index ab68d428447..d3d4d439535 100644 --- a/server/pkg/db/generated/issue_status.sql.go +++ b/server/pkg/db/generated/issue_status.sql.go @@ -145,13 +145,14 @@ type CreateCustomIssueStatusParams struct { // Category: position = max(position within category) + 1, so they sort after // the built-ins of the same Category. // -// The `WITH ws ... FOR KEY SHARE` clause is the no-FK workspace existence gate -// (mirrors EnsureWorkspaceSystemIssueStatuses). It takes the same lock the -// workspace delete/create protocol uses (LockWorkspaceForChatSessionCreate), so -// a concurrent DeleteWorkspace (FOR UPDATE) cannot interleave: if the workspace -// row is already gone, ws is empty and zero rows are inserted, so a create that -// lost the race to a workspace delete can never leave an orphan status behind. -// Zero rows makes this :one return pgx.ErrNoRows, which the handler maps to 404. +// The `WITH ws ... FOR KEY SHARE` clause is a defense-in-depth workspace +// existence gate (mirrors EnsureWorkspaceSystemIssueStatuses). The PRIMARY gate +// and lock order now live in LockWorkspaceForStatusWrite, which takes this same +// FOR KEY SHARE on the workspace row at the very start of the transaction — +// before any default-swap touches a status row — so the create/delete ordering +// cannot deadlock. This CTE stays as a backstop: if the workspace row is gone, +// ws is empty and zero rows are inserted (never an orphan), and the :one query +// returns pgx.ErrNoRows, which the handler also maps to 404. func (q *Queries) CreateCustomIssueStatus(ctx context.Context, arg CreateCustomIssueStatusParams) (IssueStatus, error) { row := q.db.QueryRow(ctx, createCustomIssueStatus, arg.Name, diff --git a/server/pkg/db/queries/issue_status.sql b/server/pkg/db/queries/issue_status.sql index 4a73d57af0a..40aeac7e610 100644 --- a/server/pkg/db/queries/issue_status.sql +++ b/server/pkg/db/queries/issue_status.sql @@ -81,13 +81,14 @@ WHERE workspace_id = $1 AND system_key IS NULL AND archived_at IS NULL; -- Category: position = max(position within category) + 1, so they sort after -- the built-ins of the same Category. -- --- The `WITH ws ... FOR KEY SHARE` clause is the no-FK workspace existence gate --- (mirrors EnsureWorkspaceSystemIssueStatuses). It takes the same lock the --- workspace delete/create protocol uses (LockWorkspaceForChatSessionCreate), so --- a concurrent DeleteWorkspace (FOR UPDATE) cannot interleave: if the workspace --- row is already gone, ws is empty and zero rows are inserted, so a create that --- lost the race to a workspace delete can never leave an orphan status behind. --- Zero rows makes this :one return pgx.ErrNoRows, which the handler maps to 404. +-- The `WITH ws ... FOR KEY SHARE` clause is a defense-in-depth workspace +-- existence gate (mirrors EnsureWorkspaceSystemIssueStatuses). The PRIMARY gate +-- and lock order now live in LockWorkspaceForStatusWrite, which takes this same +-- FOR KEY SHARE on the workspace row at the very start of the transaction — +-- before any default-swap touches a status row — so the create/delete ordering +-- cannot deadlock. This CTE stays as a backstop: if the workspace row is gone, +-- ws is empty and zero rows are inserted (never an orphan), and the :one query +-- returns pgx.ErrNoRows, which the handler also maps to 404. WITH ws AS ( SELECT id FROM workspace WHERE id = sqlc.arg('workspace_id')::uuid FOR KEY SHARE ) From bad98ff047ca529a54c38d7b3d29a47943a9b9d5 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 18:08:24 +0800 Subject: [PATCH 08/41] =?UTF-8?q?feat(issue-status):=20read=20side=20?= =?UTF-8?q?=E2=80=94=20status=5Fid/status=5Fdetail=20+=20Category=20filter?= =?UTF-8?q?s=20(MUL-4809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6.2 read side, backend. Issue responses now expose the resolved custom status catalog, and the list endpoints filter by it. - IssueResponse gains status_id + status_detail (id/name/category/icon/color), bulk-attached by the list/detail endpoints exactly like labels: a single StatusDetailsByIssues query joins issue.status_id -> issue_status (workspace- scoped, no FK; archived rows included). A load failure degrades to nil, and issues with a NULL status_id (unseeded workspace) resolve to nil — the client falls back to the legacy `status` token, which stays authoritative this phase. Wired at ListIssues (both branches), ListGroupedIssues, and GetIssue, mirroring where labels attach; create/update/WS omit them (client cache fallback), like the Labels field already documents. - ListIssues and ListGroupedIssues accept status_id and status_category filters: status_id = i.status_id equality; status_category = EXISTS against the catalog. Both validated (400 on a bad UUID / unknown Category) via a shared helper, and applied through the same dynamic WHERE so CountIssues stays consistent. status stays the source of truth; these fields are additive. No machine logic (Autopilot/notifications/Inbox) changes. DB tests cover status_detail on get/list, the two filters, their validation, and the NULL-status_id fallback. Co-authored-by: multica-agent --- server/internal/handler/issue.go | 123 +++++++++++ .../handler/issue_status_readside_test.go | 194 ++++++++++++++++++ server/pkg/db/generated/issue.sql.go | 59 ++++++ server/pkg/db/queries/issue.sql | 17 ++ 4 files changed, 393 insertions(+) create mode 100644 server/internal/handler/issue_status_readside_test.go diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 1ccccadcf14..a664a078630 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -69,6 +69,28 @@ type IssueResponse struct { // preserves whatever labels are already in cache. nil pointer = "field // absent, do not touch"; non-nil (incl. empty slice) = authoritative list. Labels *[]LabelResponse `json:"labels,omitempty"` + // StatusID / StatusDetail are the resolved custom-status catalog view of this + // issue (MUL-4809 read side), bulk-attached by the list/detail endpoints like + // Labels. StatusID is the authoritative issue_status the issue points at; + // StatusDetail carries its human-facing name/icon/color plus the Category (the + // only machine semantics). Both are nil when the issue has no status_id yet + // (workspace catalog not seeded) or on paths that don't hydrate them — the + // client then falls back to the legacy `status` token. Legacy `status` stays + // authoritative this phase; these fields are additive and optional. + StatusID *string `json:"status_id,omitempty"` + StatusDetail *IssueStatusDetail `json:"status_detail,omitempty"` +} + +// IssueStatusDetail is the resolved catalog view of an issue's status (MUL-4809). +// Category is one of the 5 immutable machine categories; name/icon/color are +// human-facing. Sourced from the issue_status row the issue's status_id points +// at (archived rows included, so an issue on one still renders its status). +type IssueStatusDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Icon string `json:"icon"` + Color string `json:"color"` } // validIssueStatuses / validIssuePriorities mirror the CHECK constraints on @@ -178,6 +200,90 @@ func (h *Handler) labelsByIssue(ctx context.Context, wsUUID pgtype.UUID, issueID return out } +// statusDetailsByIssue bulk-loads the resolved custom-status catalog detail for +// the given issue IDs, keyed by issue UUID string (MUL-4809 read side). Like +// labelsByIssue, a load failure degrades to an empty map rather than failing the +// list call — the client falls back to the legacy `status` token. Issues whose +// status_id is NULL (workspace catalog not seeded) are simply absent from the map. +func (h *Handler) statusDetailsByIssue(ctx context.Context, wsUUID pgtype.UUID, issueIDs []pgtype.UUID) map[string]IssueStatusDetail { + out := map[string]IssueStatusDetail{} + if len(issueIDs) == 0 { + return out + } + rows, err := h.Queries.StatusDetailsByIssues(ctx, db.StatusDetailsByIssuesParams{ + WorkspaceID: wsUUID, + IssueIds: issueIDs, + }) + if err != nil { + slog.Warn("StatusDetailsByIssues failed", "error", err) + return out + } + for _, r := range rows { + out[uuidToString(r.IssueID)] = IssueStatusDetail{ + ID: uuidToString(r.StatusID), + Name: r.Name, + Category: r.Category, + Icon: r.Icon, + Color: r.Color, + } + } + return out +} + +// applyStatusDetail attaches status_id + status_detail to a single response from +// a bulk-loaded map (MUL-4809). Absent = the issue has no status_id yet; both are +// left nil and the client falls back to the legacy `status` token. +func applyStatusDetail(resp *IssueResponse, details map[string]IssueStatusDetail) { + d, ok := details[resp.ID] + if !ok { + return + } + id := d.ID + detail := d + resp.StatusID = &id + resp.StatusDetail = &detail +} + +// parseStatusCatalogFilters reads the optional status_id / status_category list +// filters (MUL-4809 read side) and validates them: status_id must be a UUID, +// status_category one of the 5 Categories. On a malformed value it writes a 400 +// and returns ok=false. Empty params mean "no filter". +func parseStatusCatalogFilters(w http.ResponseWriter, r *http.Request) (statusID pgtype.UUID, category string, ok bool) { + if s := strings.TrimSpace(r.URL.Query().Get("status_id")); s != "" { + u, err := util.ParseUUID(s) + if err != nil { + writeError(w, http.StatusBadRequest, "status_id must be a uuid") + return pgtype.UUID{}, "", false + } + statusID = u + } + if c := strings.TrimSpace(r.URL.Query().Get("status_category")); c != "" { + validated, err := validateIssueStatusCategory(c) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return pgtype.UUID{}, "", false + } + category = validated + } + return statusID, category, true +} + +// appendStatusCatalogFilters adds the status_id / status_category predicates to a +// dynamic issue-list WHERE builder (MUL-4809). status_category joins the catalog +// via an EXISTS subquery scoped to the same workspace (no FK). alias is the issue +// table alias in the surrounding query (e.g. "i"). +func appendStatusCatalogFilters(where []string, alias string, statusID pgtype.UUID, category string, addArg func(any) string) []string { + if statusID.Valid { + where = append(where, fmt.Sprintf("%s.status_id = %s", alias, addArg(statusID))) + } + if category != "" { + where = append(where, fmt.Sprintf( + "EXISTS (SELECT 1 FROM issue_status ist WHERE ist.id = %s.status_id AND ist.workspace_id = %s.workspace_id AND ist.category = %s)", + alias, alias, addArg(category))) + } + return where +} + func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse { identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number)) return IssueResponse{ @@ -869,6 +975,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { ids[i] = issue.ID } labelsMap := h.labelsByIssue(ctx, wsUUID, ids) + statusMap := h.statusDetailsByIssue(ctx, wsUUID, ids) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { resp[i] = openIssueRowToResponse(issue, prefix) @@ -877,6 +984,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { labels = []LabelResponse{} } resp[i].Labels = &labels + applyStatusDetail(&resp[i], statusMap) } writeJSON(w, http.StatusOK, map[string]any{ @@ -906,6 +1014,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { if s := r.URL.Query().Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} } + statusIDFilter, statusCategoryFilter, statusFiltersOK := parseStatusCatalogFilters(w, r) + if !statusFiltersOK { + return + } // assignee_types narrows the list to issues assigned to the given actor // kinds (member / agent / squad). Mirrors the same param on @@ -992,6 +1104,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { if statusFilter.Valid { where = append(where, fmt.Sprintf("i.status = %s", addArg(statusFilter.String))) } + where = appendStatusCatalogFilters(where, "i", statusIDFilter, statusCategoryFilter, addArg) if priorityFilter.Valid { where = append(where, fmt.Sprintf("i.priority = %s", addArg(priorityFilter.String))) } @@ -1142,6 +1255,7 @@ LIMIT %s OFFSET %s`, whereSql, orderBy, limitRef, offsetRef) ids[i] = issue.ID } labelsMap := h.labelsByIssue(ctx, wsUUID, ids) + statusMap := h.statusDetailsByIssue(ctx, wsUUID, ids) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { resp[i] = issueListRowToResponse(issue, prefix) @@ -1150,6 +1264,7 @@ LIMIT %s OFFSET %s`, whereSql, orderBy, limitRef, offsetRef) labels = []LabelResponse{} } resp[i].Labels = &labels + applyStatusDetail(&resp[i], statusMap) } writeJSON(w, http.StatusOK, map[string]any{ @@ -1335,6 +1450,11 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) { if len(statuses) > 0 { where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(statuses))) } + statusIDFilter, statusCategoryFilter, statusFiltersOK := parseStatusCatalogFilters(w, r) + if !statusFiltersOK { + return + } + where = appendStatusCatalogFilters(where, "i", statusIDFilter, statusCategoryFilter, addArg) priorities := splitCommaParam(r.URL.Query().Get("priorities")) if len(priorities) == 0 { @@ -1681,6 +1801,7 @@ ORDER BY ids[i] = row.ID } labelsMap := h.labelsByIssue(ctx, wsUUID, ids) + statusMap := h.statusDetailsByIssue(ctx, wsUUID, ids) prefix := h.getIssuePrefix(ctx, wsUUID) groups := []IssueAssigneeGroupResponse{} @@ -1706,6 +1827,7 @@ ORDER BY labels = []LabelResponse{} } issue.Labels = &labels + applyStatusDetail(&issue, statusMap) groups[idx].Issues = append(groups[idx].Issues, issue) } @@ -1725,6 +1847,7 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { detailLabels = []LabelResponse{} } resp.Labels = &detailLabels + applyStatusDetail(&resp, h.statusDetailsByIssue(r.Context(), issue.WorkspaceID, []pgtype.UUID{issue.ID})) // Fetch issue reactions. reactions, err := h.Queries.ListIssueReactions(r.Context(), issue.ID) diff --git a/server/internal/handler/issue_status_readside_test.go b/server/internal/handler/issue_status_readside_test.go new file mode 100644 index 00000000000..c32734ca8a7 --- /dev/null +++ b/server/internal/handler/issue_status_readside_test.go @@ -0,0 +1,194 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// Read-side coverage for the custom-status catalog (MUL-4809, plan §6.2): +// status_id / status_detail on issue responses and the status_id / +// status_category list filters. Issues are created through the real handler so +// the status_id double-write (status token -> built-in system_key) is exercised. + +func createIssueForReadside(t *testing.T, title, status string) IssueResponse { + t.Helper() + w := httptest.NewRecorder() + testHandler.CreateIssue(w, newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": title, "status": status, "priority": "none", + })) + if w.Code != http.StatusCreated { + t.Fatalf("create issue %q: expected 201, got %d: %s", title, w.Code, w.Body.String()) + } + var resp IssueResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode created issue: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, resp.ID) }) + return resp +} + +func getIssueForReadside(t *testing.T, id string) (IssueResponse, int) { + t.Helper() + w := httptest.NewRecorder() + testHandler.GetIssue(w, withURLParam(newRequest("GET", "/api/issues/"+id, nil), "id", id)) + var resp IssueResponse + if w.Code == http.StatusOK { + json.NewDecoder(w.Body).Decode(&resp) + } + return resp, w.Code +} + +// listIssuesForReadside calls ListIssues with the given raw query string (filters +// only; the workspace comes from the header newRequest sets). +func listIssuesForReadside(t *testing.T, query string) ([]IssueResponse, int, string) { + t.Helper() + path := "/api/issues" + if query != "" { + path += "?" + query + } + w := httptest.NewRecorder() + testHandler.ListIssues(w, newRequest("GET", path, nil)) + if w.Code != http.StatusOK { + return nil, w.Code, w.Body.String() + } + var out struct { + Issues []IssueResponse `json:"issues"` + } + json.NewDecoder(w.Body).Decode(&out) + return out.Issues, w.Code, "" +} + +func containsIssue(issues []IssueResponse, id string) bool { + for _, is := range issues { + if is.ID == id { + return true + } + } + return false +} + +func findIssue(issues []IssueResponse, id string) *IssueResponse { + for i := range issues { + if issues[i].ID == id { + return &issues[i] + } + } + return nil +} + +func TestIssueResponseIncludesStatusDetail(t *testing.T) { + seedTestWorkspaceStatuses(t) + todoStatusID := getStatusCatalog(t, false).CategoryDefaults["todo"] + + issue := createIssueForReadside(t, "has status detail", "todo") + + // GetIssue resolves status_id + status_detail from the catalog. + got, code := getIssueForReadside(t, issue.ID) + if code != http.StatusOK { + t.Fatalf("GetIssue: %d", code) + } + if got.StatusID == nil || *got.StatusID != todoStatusID { + t.Fatalf("GetIssue status_id = %v, want %s", got.StatusID, todoStatusID) + } + if got.StatusDetail == nil { + t.Fatal("GetIssue status_detail is nil, want the todo catalog detail") + } + if got.StatusDetail.ID != todoStatusID || got.StatusDetail.Category != "todo" || + got.StatusDetail.Name == "" || got.StatusDetail.Icon == "" || got.StatusDetail.Color == "" { + t.Errorf("GetIssue status_detail incomplete: %+v", *got.StatusDetail) + } + + // The list endpoint attaches it too (filter by status_id to dodge pagination + // of the shared test workspace). + issues, code, msg := listIssuesForReadside(t, "status_id="+todoStatusID) + if code != http.StatusOK { + t.Fatalf("ListIssues: %d %s", code, msg) + } + listed := findIssue(issues, issue.ID) + if listed == nil { + t.Fatal("created issue missing from status_id-filtered list") + } + if listed.StatusDetail == nil || listed.StatusDetail.Category != "todo" { + t.Errorf("list status_detail = %+v", listed.StatusDetail) + } +} + +func TestListIssuesStatusCategoryFilter(t *testing.T) { + seedTestWorkspaceStatuses(t) + todoIssue := createIssueForReadside(t, "todo category filter", "todo") + doneIssue := createIssueForReadside(t, "done category filter", "done") + + issues, code, msg := listIssuesForReadside(t, "status_category=todo") + if code != http.StatusOK { + t.Fatalf("list: %d %s", code, msg) + } + if !containsIssue(issues, todoIssue.ID) { + t.Error("status_category=todo did not return the todo issue") + } + if containsIssue(issues, doneIssue.ID) { + t.Error("status_category=todo wrongly returned the done issue") + } +} + +func TestListIssuesStatusIDFilter(t *testing.T) { + seedTestWorkspaceStatuses(t) + cat := getStatusCatalog(t, false) + todoStatusID := cat.CategoryDefaults["todo"] + + todoIssue := createIssueForReadside(t, "todo id filter", "todo") + doneIssue := createIssueForReadside(t, "done id filter", "done") + + issues, code, msg := listIssuesForReadside(t, "status_id="+todoStatusID) + if code != http.StatusOK { + t.Fatalf("list: %d %s", code, msg) + } + if !containsIssue(issues, todoIssue.ID) { + t.Error("status_id filter did not return the matching issue") + } + if containsIssue(issues, doneIssue.ID) { + t.Error("status_id filter wrongly returned a non-matching issue") + } +} + +func TestListIssuesStatusFilterValidation(t *testing.T) { + seedTestWorkspaceStatuses(t) + if _, code, _ := listIssuesForReadside(t, "status_id=not-a-uuid"); code != http.StatusBadRequest { + t.Errorf("invalid status_id: expected 400, got %d", code) + } + if _, code, _ := listIssuesForReadside(t, "status_category=bogus"); code != http.StatusBadRequest { + t.Errorf("invalid status_category: expected 400, got %d", code) + } +} + +func TestIssueWithoutStatusIDHasNoStatusDetail(t *testing.T) { + // An issue inserted with a NULL status_id (workspace catalog not seeded at + // write time) resolves to no status_detail — the client falls back to the + // legacy status token. + var issueID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number) + VALUES ($1, 'no status_id', 'todo', 'none', 'member', $2, + COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1) + RETURNING id::text + `, testWorkspaceID, testUserID).Scan(&issueID); err != nil { + t.Fatalf("insert issue: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) }) + + got, code := getIssueForReadside(t, issueID) + if code != http.StatusOK { + t.Fatalf("GetIssue: %d", code) + } + if got.StatusID != nil { + t.Errorf("status_id = %v, want nil for an issue with NULL status_id", *got.StatusID) + } + if got.StatusDetail != nil { + t.Errorf("status_detail = %+v, want nil", *got.StatusDetail) + } + if got.Status != "todo" { + t.Errorf("legacy status = %q, want todo", got.Status) + } +} diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index a34fce4a29f..bb935b53237 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -1269,6 +1269,65 @@ func (q *Queries) SetIssueMetadataKey(ctx context.Context, arg SetIssueMetadataK return i, err } +const statusDetailsByIssues = `-- name: StatusDetailsByIssues :many +SELECT i.id AS issue_id, + s.id AS status_id, + s.name, + s.category, + s.icon, + s.color +FROM issue i +JOIN issue_status s ON s.id = i.status_id AND s.workspace_id = i.workspace_id +WHERE i.workspace_id = $1::uuid + AND i.id = ANY($2::uuid[]) +` + +type StatusDetailsByIssuesParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueIds []pgtype.UUID `json:"issue_ids"` +} + +type StatusDetailsByIssuesRow struct { + IssueID pgtype.UUID `json:"issue_id"` + StatusID pgtype.UUID `json:"status_id"` + Name string `json:"name"` + Category string `json:"category"` + Icon string `json:"icon"` + Color string `json:"color"` +} + +// Resolve the custom-status catalog detail for a batch of issues (MUL-4809 read +// side). Join each issue to the issue_status it points at via the authoritative +// status_id, scoped to the same workspace (no FK). Issues whose status_id is NULL +// (workspace catalog not seeded) simply don't appear. Archived statuses are +// included so an issue already on one still renders its detail. +func (q *Queries) StatusDetailsByIssues(ctx context.Context, arg StatusDetailsByIssuesParams) ([]StatusDetailsByIssuesRow, error) { + rows, err := q.db.Query(ctx, statusDetailsByIssues, arg.WorkspaceID, arg.IssueIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []StatusDetailsByIssuesRow{} + for rows.Next() { + var i StatusDetailsByIssuesRow + if err := rows.Scan( + &i.IssueID, + &i.StatusID, + &i.Name, + &i.Category, + &i.Icon, + &i.Color, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateIssue = `-- name: UpdateIssue :one UPDATE issue SET title = COALESCE($2, title), diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 8d2a39f46e4..42d88dd9bef 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -289,6 +289,23 @@ WHERE i.workspace_id = $1 )) ); +-- name: StatusDetailsByIssues :many +-- Resolve the custom-status catalog detail for a batch of issues (MUL-4809 read +-- side). Join each issue to the issue_status it points at via the authoritative +-- status_id, scoped to the same workspace (no FK). Issues whose status_id is NULL +-- (workspace catalog not seeded) simply don't appear. Archived statuses are +-- included so an issue already on one still renders its detail. +SELECT i.id AS issue_id, + s.id AS status_id, + s.name, + s.category, + s.icon, + s.color +FROM issue i +JOIN issue_status s ON s.id = i.status_id AND s.workspace_id = i.workspace_id +WHERE i.workspace_id = sqlc.arg('workspace_id')::uuid + AND i.id = ANY(sqlc.arg('issue_ids')::uuid[]); + -- name: ListChildIssues :many -- Order by number ASC so sub-issues display in stable creation order -- (oldest first), matching how a parent's plan reads top-to-bottom. The From 2f9d64638a945faaf04d671e64dbe2691e277286 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 18:08:24 +0800 Subject: [PATCH 09/41] feat(issue-status): client parses optional status_id/status_detail (MUL-4809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-side client parsing for the new issue fields. Both are optional + nullable so older backends that omit them (and endpoints that don't hydrate them) still parse — the client falls back to the legacy `status` token. - IssueSchema gains status_id (nullish) and status_detail (StatusDetailSchema, nullish). StatusDetailSchema keeps `category` a plain string and stays `.loose()`, so an unknown future Category passes through instead of failing the whole issue. List/search/grouped/child schemas inherit the fields; mobile reuses IssueSchema so it inherits them too (fallback unchanged — fields optional). - Issue type gains status_id?/status_detail?, plus StatusCategory (the 5 categories) and a StatusDetail interface. - Contract tests: an old-server issue without the fields parses (no fallback); status_id/status_detail pass through and tolerate an unknown category; an explicit null is preserved. Co-authored-by: multica-agent --- packages/core/api/schema.test.ts | 72 ++++++++++++++++++++++++++++++++ packages/core/api/schemas.ts | 17 ++++++++ packages/core/types/issue.ts | 28 +++++++++++++ 3 files changed, 117 insertions(+) diff --git a/packages/core/api/schema.test.ts b/packages/core/api/schema.test.ts index 605e12b9559..83aa82c867b 100644 --- a/packages/core/api/schema.test.ts +++ b/packages/core/api/schema.test.ts @@ -91,6 +91,78 @@ describe("ApiClient schema fallback", () => { }); }); + describe("listIssues status_detail (MUL-4809)", () => { + // Minimal valid issue row (only the non-defaulted fields). + const baseIssue = { + id: "issue-1", + workspace_id: "ws-1", + number: 1, + identifier: "MUL-1", + title: "Test", + description: null, + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + project_id: null, + position: 1, + start_date: null, + due_date: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + + it("parses an old-server issue that omits status_id / status_detail", async () => { + // Older backends predate MUL-4809 and send neither field. Must parse, not + // fall back, and leave the client on the legacy `status` token. + stubFetchJson({ issues: [baseIssue], total: 1 }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues).toHaveLength(1); + expect(res.issues[0]?.status_id).toBeUndefined(); + expect(res.issues[0]?.status_detail).toBeUndefined(); + expect(res.issues[0]?.status).toBe("todo"); + }); + + it("passes status_id / status_detail through and tolerates an unknown category", async () => { + stubFetchJson({ + issues: [ + { + ...baseIssue, + status_id: "st-1", + status_detail: { + id: "st-1", + name: "In Review", + category: "some_future_category", + icon: "in_review", + color: "success", + }, + }, + ], + total: 1, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues[0]?.status_id).toBe("st-1"); + expect(res.issues[0]?.status_detail?.name).toBe("In Review"); + expect(res.issues[0]?.status_detail?.category).toBe("some_future_category"); + }); + + it("keeps the issue when status_detail is explicitly null", async () => { + stubFetchJson({ + issues: [{ ...baseIssue, status_id: null, status_detail: null }], + total: 1, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues).toHaveLength(1); + expect(res.issues[0]?.status_detail ?? null).toBeNull(); + }); + }); + describe("searchIssues", () => { it("falls back to an empty result when the response is malformed", async () => { stubFetchJson({ issues: "not-an-array", total: 0 }); diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 4a8eb135deb..3c6edff6429 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -433,6 +433,18 @@ export const IssueTriggerPreviewSchema = z.object({ // to {} so consumers never need to nil-guard `issue.metadata`. const IssueMetadataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}); +// Resolved catalog view of an issue's status (MUL-4809). Lenient like the rest +// of this file: `category` stays a plain string so an unknown future Category +// passes through instead of failing the whole issue, and `.loose()` preserves +// any extra server fields. +export const StatusDetailSchema = z.object({ + id: z.string(), + name: z.string(), + category: z.string(), + icon: z.string(), + color: z.string(), +}).loose(); + export const IssueSchema = z.object({ id: z.string(), workspace_id: z.string(), @@ -460,6 +472,11 @@ export const IssueSchema = z.object({ properties: IssuePropertyValuesSchema, reactions: z.array(z.unknown()).optional(), labels: z.array(z.unknown()).optional(), + // Custom-status catalog fields (MUL-4809). Absent on older backends and on + // endpoints that don't hydrate them (optional); null when the issue has no + // status_id yet. Either way the client falls back to the legacy `status`. + status_id: z.string().nullish(), + status_detail: StatusDetailSchema.nullish(), created_at: z.string(), updated_at: z.string(), }).loose(); diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts index e5b64c0fb24..a1ed2e18c49 100644 --- a/packages/core/types/issue.ts +++ b/packages/core/types/issue.ts @@ -10,6 +10,28 @@ export type IssueStatus = | "blocked" | "cancelled"; +// The 5 immutable status Categories — the only machine-readable status semantics +// in the custom-status model (MUL-4809). A custom or built-in status belongs to +// exactly one Category; automation branches on the Category, never the name. +export type StatusCategory = + | "backlog" + | "todo" + | "in_progress" + | "done" + | "cancelled"; + +// StatusDetail is the resolved catalog view of an issue's status, attached to +// issue responses by list/detail endpoints (MUL-4809). Optional/nullable: absent +// or null when the issue has no status_id yet — callers fall back to the legacy +// `status` token. name/icon/color are human-facing; category is the machine key. +export interface StatusDetail { + id: string; + name: string; + category: StatusCategory; + icon: string; + color: string; +} + export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none"; export type IssueAssigneeType = "member" | "agent" | "squad"; @@ -64,6 +86,12 @@ export interface Issue { properties: IssuePropertyValues; reactions?: IssueReaction[]; labels?: Label[]; + // Authoritative custom-status catalog id + resolved detail (MUL-4809), + // bulk-attached by list/detail endpoints like `labels`. Absent/null when the + // issue has no status_id yet or on paths that don't hydrate them; the legacy + // `status` field stays the fallback. + status_id?: string | null; + status_detail?: StatusDetail | null; created_at: string; updated_at: string; } From 42d90a6e83333c2f9917d2206ecc77e53c72f61f Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 18:40:10 +0800 Subject: [PATCH 10/41] =?UTF-8?q?fix(issue-status):=20read-side=20P1s=20?= =?UTF-8?q?=E2=80=94=20open=5Fonly=20filters=20+=20search/children=20detai?= =?UTF-8?q?l=20(MUL-4809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review P1s #1 and #4 (backend half) on the read side. - P1-1: the open_only=true fast path returned before the status_id/status_category filters were parsed, so they were silently ignored and invalid values wrongly returned 200. Hoist parseStatusCatalogFilters above the branch (validation now covers both paths) and apply both predicates in ListOpenIssues (status_id equality; status_category via an EXISTS against the catalog, matching the dynamic paths). - P1-4: SearchIssues, ListChildIssues, and ListChildrenByParents returned IssueResponse without status_detail, so those entries fell back to Category and lost the precise name. All three now hydrate it (a shared hydrateStatusDetails helper for the child lists; an inline batch for search, whose scan omits status_id). Tests: open_only status_id/status_category filtering + invalid-value 400s, and status_detail on the child-list endpoint. Co-authored-by: multica-agent --- server/internal/handler/issue.go | 51 ++++++++++- .../handler/issue_status_readside_test.go | 86 +++++++++++++++++++ server/pkg/db/generated/issue.sql.go | 38 +++++--- server/pkg/db/queries/issue.sql | 8 ++ 4 files changed, 166 insertions(+), 17 deletions(-) diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 11aef7f23eb..312b00e016d 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -244,6 +244,25 @@ func applyStatusDetail(resp *IssueResponse, details map[string]IssueStatusDetail resp.StatusDetail = &detail } +// hydrateStatusDetails bulk-loads status_id + status_detail for a slice of issue +// responses in one round-trip and attaches them (MUL-4809). For read endpoints +// that don't already build an issue-id slice (search / children). No-op on empty. +func (h *Handler) hydrateStatusDetails(ctx context.Context, wsUUID pgtype.UUID, resps []IssueResponse) { + if len(resps) == 0 { + return + } + ids := make([]pgtype.UUID, 0, len(resps)) + for i := range resps { + if u, err := util.ParseUUID(resps[i].ID); err == nil { + ids = append(ids, u) + } + } + details := h.statusDetailsByIssue(ctx, wsUUID, ids) + for i := range resps { + applyStatusDetail(&resps[i], details) + } +} + // parseStatusCatalogFilters reads the optional status_id / status_category list // filters (MUL-4809 read side) and validates them: status_id must be a UUID, // status_category one of the 5 Categories. On a malformed value it writes a 400 @@ -854,6 +873,21 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { resp[i] = sir } + // Attach status_detail (MUL-4809) to each search hit in one round-trip. The + // search scan doesn't select status_id, so resolve it by issue id here. + if len(resp) > 0 { + ids := make([]pgtype.UUID, 0, len(resp)) + for i := range resp { + if u, err := util.ParseUUID(resp[i].ID); err == nil { + ids = append(ids, u) + } + } + details := h.statusDetailsByIssue(ctx, wsUUID, ids) + for i := range resp { + applyStatusDetail(&resp[i].IssueResponse, details) + } + } + w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10)) writeJSON(w, http.StatusOK, map[string]any{ "issues": resp, @@ -939,6 +973,13 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { if !ok { return } + // Custom-status catalog filters (MUL-4809). Parsed BEFORE the open_only + // branch so validation (400 on a bad UUID / unknown Category) and the two + // predicates apply to BOTH the open_only fast path and the general list. + statusIDFilter, statusCategoryFilter, statusFiltersOK := parseStatusCatalogFilters(w, r) + if !statusFiltersOK { + return + } // open_only=true returns all non-done/cancelled issues (no limit). if r.URL.Query().Get("open_only") == "true" { @@ -955,6 +996,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ WorkspaceID: wsUUID, + StatusID: statusIDFilter, + StatusCategory: pgtype.Text{String: statusCategoryFilter, Valid: statusCategoryFilter != ""}, Priority: priorityFilter, AssigneeID: assigneeFilter, AssigneeIds: assigneeIdsFilter, @@ -1014,10 +1057,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { if s := r.URL.Query().Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} } - statusIDFilter, statusCategoryFilter, statusFiltersOK := parseStatusCatalogFilters(w, r) - if !statusFiltersOK { - return - } + // statusIDFilter / statusCategoryFilter were parsed above (shared with the + // open_only branch); applied to the dynamic WHERE below. // assignee_types narrows the list to issues assigned to the given actor // kinds (member / agent / squad). Mirrors the same param on @@ -1889,6 +1930,7 @@ func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) { for i, child := range children { resp[i] = issueToResponse(child, prefix) } + h.hydrateStatusDetails(r.Context(), issue.WorkspaceID, resp) writeJSON(w, http.StatusOK, map[string]any{ "issues": resp, }) @@ -1958,6 +2000,7 @@ func (h *Handler) ListChildrenByParents(w http.ResponseWriter, r *http.Request) for i, child := range children { resp[i] = issueToResponse(child, prefix) } + h.hydrateStatusDetails(r.Context(), wsUUID, resp) writeJSON(w, http.StatusOK, map[string]any{ "issues": resp, }) diff --git a/server/internal/handler/issue_status_readside_test.go b/server/internal/handler/issue_status_readside_test.go index c32734ca8a7..2d76ba03a57 100644 --- a/server/internal/handler/issue_status_readside_test.go +++ b/server/internal/handler/issue_status_readside_test.go @@ -192,3 +192,89 @@ func TestIssueWithoutStatusIDHasNoStatusDetail(t *testing.T) { t.Errorf("legacy status = %q, want todo", got.Status) } } + +// TestListOpenIssuesStatusFilters covers P1-1 (MUL-4809 read-side review): the +// open_only fast path must apply status_id / status_category and validate them +// (they were silently ignored before, and bad values wrongly returned 200). +func TestListOpenIssuesStatusFilters(t *testing.T) { + seedTestWorkspaceStatuses(t) + todoStatusID := getStatusCatalog(t, false).CategoryDefaults["todo"] + + // Both statuses are "open" (not done/cancelled), so status filtering — not the + // open predicate — is what must separate them. + todoIssue := createIssueForReadside(t, "open todo", "todo") + inProgIssue := createIssueForReadside(t, "open in progress", "in_progress") + + issues, code, msg := listIssuesForReadside(t, "open_only=true&status_category=todo") + if code != http.StatusOK { + t.Fatalf("open_only category filter: %d %s", code, msg) + } + if !containsIssue(issues, todoIssue.ID) { + t.Error("open_only status_category=todo dropped the todo issue") + } + if containsIssue(issues, inProgIssue.ID) { + t.Error("open_only status_category=todo wrongly returned the in_progress issue") + } + + issues, code, _ = listIssuesForReadside(t, "open_only=true&status_id="+todoStatusID) + if code != http.StatusOK { + t.Fatalf("open_only status_id filter: %d", code) + } + got := findIssue(issues, todoIssue.ID) + if got == nil { + t.Fatal("open_only status_id filter dropped the matching issue") + } + if got.StatusDetail == nil || got.StatusDetail.Category != "todo" { + t.Errorf("open_only status_detail = %+v, want category todo", got.StatusDetail) + } + if containsIssue(issues, inProgIssue.ID) { + t.Error("open_only status_id filter returned a non-matching issue") + } + + // Invalid values are a 400 on the open_only branch too (no longer ignored). + if _, code, _ := listIssuesForReadside(t, "open_only=true&status_id=not-a-uuid"); code != http.StatusBadRequest { + t.Errorf("open_only invalid status_id: expected 400, got %d", code) + } + if _, code, _ := listIssuesForReadside(t, "open_only=true&status_category=bogus"); code != http.StatusBadRequest { + t.Errorf("open_only invalid status_category: expected 400, got %d", code) + } +} + +// TestListChildIssuesAttachesStatusDetail covers P1-4 (MUL-4809): the child-list +// read endpoint hydrates status_detail like the other Issue read entries. +func TestListChildIssuesAttachesStatusDetail(t *testing.T) { + seedTestWorkspaceStatuses(t) + todoStatusID := getStatusCatalog(t, false).CategoryDefaults["todo"] + parent := createIssueForReadside(t, "parent for child detail", "todo") + + var childID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO issue (workspace_id, title, status, status_id, priority, creator_type, creator_id, parent_issue_id, number) + VALUES ($1, 'child with status', 'todo', $2, 'none', 'member', $3, $4, + COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1) + RETURNING id::text + `, testWorkspaceID, todoStatusID, testUserID, parent.ID).Scan(&childID); err != nil { + t.Fatalf("insert child: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, childID) }) + + w := httptest.NewRecorder() + testHandler.ListChildIssues(w, withURLParam(newRequest("GET", "/api/issues/"+parent.ID+"/children", nil), "id", parent.ID)) + if w.Code != http.StatusOK { + t.Fatalf("ListChildIssues: %d %s", w.Code, w.Body.String()) + } + var out struct { + Issues []IssueResponse `json:"issues"` + } + json.NewDecoder(w.Body).Decode(&out) + child := findIssue(out.Issues, childID) + if child == nil { + t.Fatal("child issue missing from children list") + } + if child.StatusID == nil || *child.StatusID != todoStatusID { + t.Errorf("child status_id = %v, want %s", child.StatusID, todoStatusID) + } + if child.StatusDetail == nil || child.StatusDetail.Category != "todo" { + t.Errorf("child status_detail = %+v, want category todo", child.StatusDetail) + } +} diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index bb935b53237..ee5f958b761 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -1020,22 +1020,30 @@ SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, FROM issue i WHERE i.workspace_id = $1 AND i.status NOT IN ('done', 'cancelled') - AND ($2::text IS NULL OR i.priority = $2) - AND ($3::uuid IS NULL OR i.assignee_id = $3) - AND ($4::uuid[] IS NULL OR i.assignee_id = ANY($4::uuid[])) - AND ($5::uuid IS NULL OR i.creator_id = $5) - AND ($6::uuid IS NULL OR i.project_id = $6) - AND ($7::jsonb IS NULL OR i.metadata @> $7::jsonb) + AND ($2::uuid IS NULL OR i.status_id = $2) + -- status_category (MUL-4809): match issues whose status_id resolves to the + -- given Category, scoped to the same workspace (no FK). Mirrors the EXISTS + -- predicate the dynamic ListIssues/ListGroupedIssues paths build. + AND ($3::text IS NULL OR EXISTS ( + SELECT 1 FROM issue_status s + WHERE s.id = i.status_id AND s.workspace_id = i.workspace_id + AND s.category = $3::text)) + AND ($4::text IS NULL OR i.priority = $4) + AND ($5::uuid IS NULL OR i.assignee_id = $5) + AND ($6::uuid[] IS NULL OR i.assignee_id = ANY($6::uuid[])) + AND ($7::uuid IS NULL OR i.creator_id = $7) + AND ($8::uuid IS NULL OR i.project_id = $8) + AND ($9::jsonb IS NULL OR i.metadata @> $9::jsonb) -- properties_filter is a jsonb array of groups, each group an array of -- containment patterns (built by parsePropertiesFilterParam): the issue -- must match at least one pattern from EVERY group (AND of ORs). The -- correlated form skips the GIN index, which is fine here: open_only is -- an unpaginated workspace scan already narrowed by status. AND ( - $8::jsonb IS NULL + $10::jsonb IS NULL OR NOT EXISTS ( SELECT 1 - FROM jsonb_array_elements($8::jsonb) AS pf(alternatives) + FROM jsonb_array_elements($10::jsonb) AS pf(alternatives) WHERE NOT EXISTS ( SELECT 1 FROM jsonb_array_elements(pf.alternatives) AS alt(pattern) @@ -1044,11 +1052,11 @@ WHERE i.workspace_id = $1 ) ) AND ( - $9::uuid IS NULL + $11::uuid IS NULL OR (i.assignee_type = 'agent' AND i.assignee_id IN ( SELECT a.id FROM agent a WHERE a.workspace_id = $1 - AND a.owner_id = $9::uuid + AND a.owner_id = $11::uuid )) OR (i.assignee_type = 'squad' AND i.assignee_id IN ( SELECT sm.squad_id @@ -1056,14 +1064,14 @@ WHERE i.workspace_id = $1 JOIN squad s ON s.id = sm.squad_id WHERE s.workspace_id = $1 AND sm.member_type = 'member' - AND sm.member_id = $9::uuid + AND sm.member_id = $11::uuid UNION SELECT s.id FROM squad s JOIN agent a ON a.id = s.leader_id WHERE s.workspace_id = $1 AND a.workspace_id = $1 - AND a.owner_id = $9::uuid + AND a.owner_id = $11::uuid UNION SELECT sm.squad_id FROM squad_member sm @@ -1072,7 +1080,7 @@ WHERE i.workspace_id = $1 WHERE s.workspace_id = $1 AND sm.member_type = 'agent' AND a.workspace_id = $1 - AND a.owner_id = $9::uuid + AND a.owner_id = $11::uuid )) ) ORDER BY i.position ASC, i.created_at DESC @@ -1080,6 +1088,8 @@ ORDER BY i.position ASC, i.created_at DESC type ListOpenIssuesParams struct { WorkspaceID pgtype.UUID `json:"workspace_id"` + StatusID pgtype.UUID `json:"status_id"` + StatusCategory pgtype.Text `json:"status_category"` Priority pgtype.Text `json:"priority"` AssigneeID pgtype.UUID `json:"assignee_id"` AssigneeIds []pgtype.UUID `json:"assignee_ids"` @@ -1119,6 +1129,8 @@ type ListOpenIssuesRow struct { func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) { rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, + arg.StatusID, + arg.StatusCategory, arg.Priority, arg.AssigneeID, arg.AssigneeIds, diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 42d88dd9bef..3810176f0a8 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -187,6 +187,14 @@ SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, FROM issue i WHERE i.workspace_id = $1 AND i.status NOT IN ('done', 'cancelled') + AND (sqlc.narg('status_id')::uuid IS NULL OR i.status_id = sqlc.narg('status_id')) + -- status_category (MUL-4809): match issues whose status_id resolves to the + -- given Category, scoped to the same workspace (no FK). Mirrors the EXISTS + -- predicate the dynamic ListIssues/ListGroupedIssues paths build. + AND (sqlc.narg('status_category')::text IS NULL OR EXISTS ( + SELECT 1 FROM issue_status s + WHERE s.id = i.status_id AND s.workspace_id = i.workspace_id + AND s.category = sqlc.narg('status_category')::text)) AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority')) AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id')) AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) From 6abc8037c9bb6e4bc1008fd5218a4d27d8f81b03 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 18:40:10 +0800 Subject: [PATCH 11/41] =?UTF-8?q?fix(issue-status):=20read-side=20P1s=20?= =?UTF-8?q?=E2=80=94=20safe-degrade=20detail=20+=20honest=20category=20+?= =?UTF-8?q?=20client=20filters=20(MUL-4809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review P1s #2, #3, and #4 (client half) on the read side. - P1-2 (§7.3 safe degrade): a malformed status_detail subfield used to fail the outer IssueSchema and drop the whole list to [] via parseWithFallback. `.catch(null)` now isolates the failure to status_id/status_detail — a bad value degrades to null and the issue (and list) still parses. - P1-3 (honest type): the parser allowed any category string while the public StatusDetail.category is a closed 5-value union. category is a strict enum at runtime now (a 6th Category is never added by product design), so an unknown value degrades the single detail field to null and the type stays truthful for exhaustive switches. - P1-4 (client capability): ListIssuesParams / ListGroupedIssuesParams and the ApiClient now expose and serialize status_id / status_category so Web/Mobile can call the backend filters through the shared client. Tests updated: unknown category -> status_detail null (issue kept), malformed detail on one row doesn't blank the list, explicit-null asserted as exact null. Co-authored-by: multica-agent --- packages/core/api/client.ts | 4 +++ packages/core/api/schema.test.ts | 54 +++++++++++++++++++++++++++++--- packages/core/api/schemas.ts | 25 +++++++++------ packages/core/types/api.ts | 10 +++++- 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 09c85cad620..85f878616de 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -559,6 +559,8 @@ export class ApiClient { if (params?.offset) search.set("offset", String(params.offset)); if (params?.workspace_id) search.set("workspace_id", params.workspace_id); if (params?.status) search.set("status", params.status); + if (params?.status_id) search.set("status_id", params.status_id); + if (params?.status_category) search.set("status_category", params.status_category); if (params?.priority) search.set("priority", params.priority); if (params?.assignee_id) search.set("assignee_id", params.assignee_id); if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); @@ -592,6 +594,8 @@ export class ApiClient { if (params.offset) search.set("offset", String(params.offset)); if (params.workspace_id) search.set("workspace_id", params.workspace_id); if (params.statuses?.length) search.set("statuses", params.statuses.join(",")); + if (params.status_id) search.set("status_id", params.status_id); + if (params.status_category) search.set("status_category", params.status_category); if (params.priorities?.length) search.set("priorities", params.priorities.join(",")); if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); if (params.assignee_id) search.set("assignee_id", params.assignee_id); diff --git a/packages/core/api/schema.test.ts b/packages/core/api/schema.test.ts index def5f7614ff..756cc87bef7 100644 --- a/packages/core/api/schema.test.ts +++ b/packages/core/api/schema.test.ts @@ -127,7 +127,7 @@ describe("ApiClient schema fallback", () => { expect(res.issues[0]?.status).toBe("todo"); }); - it("passes status_id / status_detail through and tolerates an unknown category", async () => { + it("passes a well-formed status_id / status_detail through", async () => { stubFetchJson({ issues: [ { @@ -136,7 +136,7 @@ describe("ApiClient schema fallback", () => { status_detail: { id: "st-1", name: "In Review", - category: "some_future_category", + category: "in_progress", icon: "in_review", color: "success", }, @@ -148,10 +148,53 @@ describe("ApiClient schema fallback", () => { const res = await client.listIssues(); expect(res.issues[0]?.status_id).toBe("st-1"); expect(res.issues[0]?.status_detail?.name).toBe("In Review"); - expect(res.issues[0]?.status_detail?.category).toBe("some_future_category"); + expect(res.issues[0]?.status_detail?.category).toBe("in_progress"); + }); + + it("degrades status_detail to null on an unknown category, keeping the issue", async () => { + // category is a closed 5-value set. An unknown value must fail only the + // status_detail field (-> null), never blank the issue or the whole list. + stubFetchJson({ + issues: [ + { + ...baseIssue, + status_id: "st-1", + status_detail: { + id: "st-1", + name: "Weird", + category: "some_future_category", + icon: "todo", + color: "warning", + }, + }, + ], + total: 1, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues).toHaveLength(1); + expect(res.issues[0]?.status).toBe("todo"); + expect(res.issues[0]?.status_detail).toBeNull(); + }); + + it("degrades a malformed status_detail to null without blanking the list (§7.3)", async () => { + // A wrong-typed subfield used to fail the outer IssueSchema and drop the + // whole batch to []. Now it degrades to just status_detail = null. + stubFetchJson({ + issues: [ + { ...baseIssue, id: "issue-1", status_detail: { id: 123, name: null } }, + { ...baseIssue, id: "issue-2" }, + ], + total: 2, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues).toHaveLength(2); + expect(res.issues[0]?.status_detail).toBeNull(); + expect(res.issues[0]?.status).toBe("todo"); }); - it("keeps the issue when status_detail is explicitly null", async () => { + it("keeps null on explicit-null status_id / status_detail", async () => { stubFetchJson({ issues: [{ ...baseIssue, status_id: null, status_detail: null }], total: 1, @@ -159,7 +202,8 @@ describe("ApiClient schema fallback", () => { const client = new ApiClient("https://api.example.test"); const res = await client.listIssues(); expect(res.issues).toHaveLength(1); - expect(res.issues[0]?.status_detail ?? null).toBeNull(); + expect(res.issues[0]?.status_id).toBeNull(); + expect(res.issues[0]?.status_detail).toBeNull(); }); }); diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 0d6f76b1cc2..d3032539907 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -434,14 +434,16 @@ export const IssueTriggerPreviewSchema = z.object({ // to {} so consumers never need to nil-guard `issue.metadata`. const IssueMetadataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}); -// Resolved catalog view of an issue's status (MUL-4809). Lenient like the rest -// of this file: `category` stays a plain string so an unknown future Category -// passes through instead of failing the whole issue, and `.loose()` preserves -// any extra server fields. +// Resolved catalog view of an issue's status (MUL-4809). Unlike the open string +// enums elsewhere in this file, `category` is a CLOSED 5-value set by product +// design (no 6th Category is ever added), so it is a strict enum here — that +// keeps the public StatusDetail.category union honest, and a malformed/unknown +// value fails this schema and is degraded to null at the field (see IssueSchema), +// never blanking the whole issue. `.loose()` still preserves extra server fields. export const StatusDetailSchema = z.object({ id: z.string(), name: z.string(), - category: z.string(), + category: z.enum(["backlog", "todo", "in_progress", "done", "cancelled"]), icon: z.string(), color: z.string(), }).loose(); @@ -473,11 +475,14 @@ export const IssueSchema = z.object({ properties: IssuePropertyValuesSchema, reactions: z.array(z.unknown()).optional(), labels: z.array(z.unknown()).optional(), - // Custom-status catalog fields (MUL-4809). Absent on older backends and on - // endpoints that don't hydrate them (optional); null when the issue has no - // status_id yet. Either way the client falls back to the legacy `status`. - status_id: z.string().nullish(), - status_detail: StatusDetailSchema.nullish(), + // Custom-status catalog fields (MUL-4809). Optional (absent on older backends + // and on endpoints that don't hydrate them) + nullable (null when the issue has + // no status_id yet). `.catch(null)` isolates malformed data to THIS field — + // a bad status_id/status_detail degrades to null and the issue (and the whole + // list) still parses, per the §7.3 safe-degrade rule — the client then falls + // back to the legacy `status` token. + status_id: z.string().nullish().catch(null), + status_detail: StatusDetailSchema.nullish().catch(null), created_at: z.string(), updated_at: z.string(), }).loose(); diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 8daaec69b35..8c6ee983826 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -1,4 +1,4 @@ -import type { Issue, IssueMetadata, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue"; +import type { Issue, IssueMetadata, IssueStatus, IssuePriority, IssueAssigneeType, StatusCategory } from "./issue"; import type { MemberRole } from "./workspace"; import type { Project } from "./project"; @@ -81,6 +81,10 @@ export interface ListIssuesParams { offset?: number; workspace_id?: string; status?: IssueStatus; + /** Exact custom-status filter by catalog id (MUL-4809). */ + status_id?: string; + /** Filter by one of the 5 status Categories (MUL-4809). */ + status_category?: StatusCategory; priority?: IssuePriority; assignee_id?: string; assignee_ids?: string[]; @@ -132,6 +136,10 @@ export interface ListGroupedIssuesParams { offset?: number; workspace_id?: string; statuses?: IssueStatus[]; + /** Exact custom-status filter by catalog id (MUL-4809). */ + status_id?: string; + /** Filter by one of the 5 status Categories (MUL-4809). */ + status_category?: StatusCategory; priorities?: IssuePriority[]; assignee_types?: IssueAssigneeType[]; assignee_id?: string; From 7b77c9ca9781bf343434cf06610268d44aeb4aae Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Fri, 17 Jul 2026 12:40:22 +0800 Subject: [PATCH 12/41] =?UTF-8?q?feat(autopilot):=20drive=20create=5Fissue?= =?UTF-8?q?=20runs=20off=20task=20outcome,=20not=20issue=20status=20(MUL-4?= =?UTF-8?q?809=20=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple Autopilot runs from Issue status: a run now finalizes purely on the terminal state of the task it dispatched, never on the issue's status. This removes Issue status's hidden control over Autopilot (plan §1.1 #2 / §4.1) and, with it, `in_review`/`blocked` as machine states in this path. - dispatchCreateIssue now captures its dispatched task and binds it onto the run (UpdateAutopilotRunRunning: issue_created -> running, task_id set). The task keeps NO autopilot_run_id, so it stays an ordinary issue task for context / classification — the run owns the pointer instead. - New SyncRunFromCreateIssueTask finalizes the run from that task's terminal state: found by issue_id, matched to the run's dispatched-task lineage via run.task_id + the retry_of_task_id chain. That precise match is what stops a LATER comment-triggered task on the same issue from finalizing the run. A still-queued system retry (HasPendingRetryForTask) keeps the run open until the final attempt; when run.task_id was never bound (crash before bind, or a run dispatched by a pre-§4.1 pod mid-deploy) it falls back to the issue-scoped "no active task" check so the run can't hang. - Delete SyncRunFromIssue + its EventIssueUpdated listener, and SyncRunFromLinkedIssueTask (folded in). Issue status no longer ends/fails a run. - Split GetAutopilotRunByIssue: attribution now uses GetLatestAutopilotRunByIssue (latest run in ANY status) so the firing trigger's owner survives the run completing early on task outcome; the sync keeps the active-only query, which also makes finalization idempotent under a rolling deploy. run_only is unchanged (already task-driven). The failure-rate auto-pause monitor still reads completed/failed runs, now produced by the task path. Tests: create_issue completion via task, a non-lineage comment task NOT finalizing the run, plus the existing failure / retry-pending / dispatch suites updated to the bound running state; webhook crash-window repair remodeled to the pre-bind issue_created state it now represents. Co-authored-by: multica-agent --- .../autopilot_dispatch_for_plan_test.go | 4 +- server/cmd/server/autopilot_listeners.go | 43 +--- server/cmd/server/autopilot_listeners_test.go | 79 ++++++- .../handler/autopilot_private_leader_test.go | 4 +- server/internal/handler/handler_test.go | 12 +- .../internal/handler/webhook_delivery_test.go | 9 + server/internal/service/autopilot.go | 215 +++++++++++------- server/internal/service/task.go | 5 +- server/pkg/db/generated/autopilot.sql.go | 55 +++++ server/pkg/db/queries/autopilot.sql | 22 ++ 10 files changed, 305 insertions(+), 143 deletions(-) diff --git a/server/cmd/server/autopilot_dispatch_for_plan_test.go b/server/cmd/server/autopilot_dispatch_for_plan_test.go index 131088495c8..b3ae3c1809c 100644 --- a/server/cmd/server/autopilot_dispatch_for_plan_test.go +++ b/server/cmd/server/autopilot_dispatch_for_plan_test.go @@ -190,8 +190,8 @@ func TestDispatchAutopilotSuppressesRecentDuplicateIssue(t *testing.T) { if err != nil { t.Fatalf("first DispatchAutopilot: %v", err) } - if first == nil || first.Status != "issue_created" || !first.IssueID.Valid { - t.Fatalf("first dispatch = %+v, want issue_created with issue_id", first) + if first == nil || first.Status != "running" || !first.IssueID.Valid || !first.TaskID.Valid { + t.Fatalf("first dispatch = %+v, want running with issue_id + task_id (MUL-4809 §4.1)", first) } second, err := autopilotSvc.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil) diff --git a/server/cmd/server/autopilot_listeners.go b/server/cmd/server/autopilot_listeners.go index e281949a8bc..119ed546703 100644 --- a/server/cmd/server/autopilot_listeners.go +++ b/server/cmd/server/autopilot_listeners.go @@ -2,48 +2,19 @@ package main import ( "context" - "log/slog" "github.com/multica-ai/multica/server/internal/events" - "github.com/multica-ai/multica/server/internal/handler" "github.com/multica-ai/multica/server/internal/service" "github.com/multica-ai/multica/server/pkg/protocol" ) -// registerAutopilotListeners hooks into issue and task events to keep -// autopilot runs in sync with their linked issues and tasks. +// registerAutopilotListeners hooks into task terminal events to keep autopilot +// runs in sync with the task each run dispatched. Runs are finalized purely by +// task outcome (MUL-4809 §4.1) — issue status no longer ends or fails a run, so +// there is no EventIssueUpdated subscription here anymore. func registerAutopilotListeners(bus *events.Bus, svc *service.AutopilotService) { ctx := context.Background() - // When an issue with origin_type='autopilot' reaches a terminal status, - // update the corresponding autopilot run. - bus.Subscribe(protocol.EventIssueUpdated, func(e events.Event) { - payload, ok := e.Payload.(map[string]any) - if !ok { - return - } - statusChanged, _ := payload["status_changed"].(bool) - if !statusChanged { - return - } - issue, ok := payload["issue"].(handler.IssueResponse) - if !ok { - return - } - // Only handle statuses that finalize an autopilot run. - if issue.Status != "done" && issue.Status != "in_review" && issue.Status != "cancelled" && issue.Status != "blocked" { - return - } - // Load the full issue from DB to check origin_type. - dbIssue, err := svc.Queries.GetIssue(ctx, parseUUID(issue.ID)) - if err != nil { - slog.Debug("autopilot listener: failed to load issue", "issue_id", issue.ID, "error", err) - return - } - svc.SyncRunFromIssue(ctx, dbIssue) - }) - - // When a task completes or fails, check if it's an autopilot run_only task. bus.Subscribe(protocol.EventTaskCompleted, func(e events.Event) { syncRunFromTaskEvent(ctx, svc, e) }) @@ -68,11 +39,11 @@ func syncRunFromTaskEvent(ctx context.Context, svc *service.AutopilotService, e if err != nil { return } + // run_only tasks carry autopilot_run_id; create_issue tasks carry only + // issue_id and are matched to their run via run.task_id + retry lineage. if task.AutopilotRunID.Valid { svc.SyncRunFromTask(ctx, task) return } - if e.Type == protocol.EventTaskFailed { - svc.SyncRunFromLinkedIssueTask(ctx, task) - } + svc.SyncRunFromCreateIssueTask(ctx, task) } diff --git a/server/cmd/server/autopilot_listeners_test.go b/server/cmd/server/autopilot_listeners_test.go index 49d5a683cb8..83aadc37bbb 100644 --- a/server/cmd/server/autopilot_listeners_test.go +++ b/server/cmd/server/autopilot_listeners_test.go @@ -128,6 +128,7 @@ func TestAutopilotRunOnlyTaskTerminalEventsUpdateRun(t *testing.T) { // (so it must be reached via the issue_id lookup, not SyncRunFromTask). type linkedIssueAutopilotFixture struct { taskSvc *service.TaskService + svc *service.AutopilotService queries *db.Queries run *db.AutopilotRun taskID pgtype.UUID @@ -193,13 +194,19 @@ func dispatchCreateIssueAutopilot(t *testing.T, title string) linkedIssueAutopil t.Fatalf("expected one issue task, got %d", len(tasks)) } if tasks[0].AutopilotRunID.Valid { - t.Fatal("create_issue issue task unexpectedly has autopilot_run_id; test must exercise linked issue lookup") + t.Fatal("create_issue issue task unexpectedly has autopilot_run_id; the run owns the pointer via run.task_id instead") } - if run.Status != "issue_created" { - t.Fatalf("expected pre-failure run status issue_created, got %q", run.Status) + // MUL-4809 §4.1: dispatch now binds the run to its dispatched task and advances + // to running. The run finalizes on that task's terminal state (matched by + // run.task_id + retry lineage), not on the issue status. + if run.Status != "running" { + t.Fatalf("expected post-dispatch run status running, got %q", run.Status) + } + if run.TaskID.Bytes != tasks[0].ID.Bytes { + t.Fatalf("run.task_id not bound to the dispatched task: run=%x task=%x", run.TaskID.Bytes, tasks[0].ID.Bytes) } - return linkedIssueAutopilotFixture{taskSvc: taskSvc, queries: queries, run: run, taskID: tasks[0].ID} + return linkedIssueAutopilotFixture{taskSvc: taskSvc, svc: autopilotSvc, queries: queries, run: run, taskID: tasks[0].ID} } // runTaskWithBudget marks the issue task dispatched with the given attempt @@ -303,8 +310,64 @@ func TestAutopilotCreateIssueTaskRetryPendingKeepsRunOpen(t *testing.T) { if err != nil { t.Fatalf("GetAutopilotRun: %v", err) } - if updatedRun.Status != "issue_created" { - t.Fatalf("expected run to stay issue_created while a retry is pending, got %q", updatedRun.Status) + if updatedRun.Status != "running" { + t.Fatalf("expected run to stay running (open) while a retry is pending, got %q", updatedRun.Status) + } +} + +// TestAutopilotCreateIssueTaskCompletionUpdatesRun is the core §4.1 decoupling: +// a create_issue run now COMPLETES on its dispatched task's terminal success — +// no issue status change involved. +func TestAutopilotCreateIssueTaskCompletionUpdatesRun(t *testing.T) { + ctx := context.Background() + f := dispatchCreateIssueAutopilot(t, "Create-issue completion listener") + + runTaskWithBudget(t, f.queries, f.taskID, 1) + + if _, err := f.taskSvc.CompleteTask(ctx, f.taskID, []byte(`{"output":"done"}`), "", ""); err != nil { + t.Fatalf("CompleteTask: %v", err) + } + + updatedRun, err := f.queries.GetAutopilotRun(ctx, f.run.ID) + if err != nil { + t.Fatalf("GetAutopilotRun: %v", err) + } + if updatedRun.Status != "completed" { + t.Fatalf("expected run status completed after the dispatched task completed, got %q", updatedRun.Status) + } + if !updatedRun.CompletedAt.Valid { + t.Fatal("expected completed_at to be set") + } +} + +// TestAutopilotCreateIssueCommentTaskDoesNotFinalizeRun locks in the precise +// lineage match: a LATER task on the same issue that is NOT the run's dispatched +// task (nor a retry of it) — e.g. a comment-triggered task — must never finalize +// the run. This is why the run binds to a specific task_id instead of matching +// by issue alone. +func TestAutopilotCreateIssueCommentTaskDoesNotFinalizeRun(t *testing.T) { + ctx := context.Background() + f := dispatchCreateIssueAutopilot(t, "Create-issue comment-task listener") + + // A fabricated terminal task on the same issue with no retry_of lineage to the + // run's dispatched task and no autopilot_run_id — its retry-root is itself, so + // it can't match run.task_id. + commentTask := db.AgentTaskQueue{ + ID: parseUUID("11111111-1111-1111-1111-111111111111"), + IssueID: f.run.IssueID, + Status: "completed", + } + if commentTask.ID.Bytes == f.run.TaskID.Bytes { + t.Fatal("fabricated comment task id collided with the run's dispatched task id") + } + f.svc.SyncRunFromCreateIssueTask(ctx, commentTask) + + updatedRun, err := f.queries.GetAutopilotRun(ctx, f.run.ID) + if err != nil { + t.Fatalf("GetAutopilotRun: %v", err) + } + if updatedRun.Status != "running" { + t.Fatalf("a non-lineage comment task finalized the run (status %q); it must stay running", updatedRun.Status) } } @@ -465,8 +528,8 @@ func TestAutopilotCreateIssueDispatchCreatesIssueWhenRuntimeOffline(t *testing.T if run == nil { t.Fatal("expected a run, got nil") } - if run.Status != "issue_created" { - t.Fatalf("expected run status 'issue_created', got %q", run.Status) + if run.Status != "running" { + t.Fatalf("expected run status 'running' after task-bound dispatch, got %q", run.Status) } if !run.IssueID.Valid { t.Fatal("create_issue dispatch did not link an issue") diff --git a/server/internal/handler/autopilot_private_leader_test.go b/server/internal/handler/autopilot_private_leader_test.go index 89c19fee7f5..ce16d5780b0 100644 --- a/server/internal/handler/autopilot_private_leader_test.go +++ b/server/internal/handler/autopilot_private_leader_test.go @@ -208,8 +208,8 @@ func TestTriggerAutopilot_SquadPrivateLeader_OwnerCanDispatch(t *testing.T) { if err := json.NewDecoder(w.Body).Decode(&run); err != nil { t.Fatalf("decode run: %v", err) } - if run.Status != "issue_created" { - t.Fatalf("run status = %q, want issue_created", run.Status) + if run.Status != "running" { + t.Fatalf("run status = %q, want running (task-bound, MUL-4809 §4.1)", run.Status) } } diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index aea8919f870..98870c59258 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -1091,8 +1091,8 @@ func TestTriggerAutopilotAllowsActiveDuplicateIssue(t *testing.T) { if err := json.NewDecoder(w.Body).Decode(&run); err != nil { t.Fatalf("decode autopilot run: %v", err) } - if run.Status != "issue_created" { - t.Fatalf("run status = %q, want issue_created", run.Status) + if run.Status != "running" { + t.Fatalf("run status = %q, want running (task-bound, MUL-4809 §4.1)", run.Status) } if run.IssueID == nil { t.Fatal("run issue_id is nil, want newly created issue") @@ -1169,8 +1169,8 @@ func TestScheduledAutopilotAllowsActiveDuplicateIssue(t *testing.T) { if err != nil { t.Fatalf("DispatchAutopilot schedule duplicate: %v", err) } - if run == nil || run.Status != "issue_created" { - t.Fatalf("dispatch result = %+v, want status issue_created", run) + if run == nil || run.Status != "running" { + t.Fatalf("dispatch result = %+v, want status running (task-bound, MUL-4809 §4.1)", run) } newIssueID := uuidToString(run.IssueID) if newIssueID == "" { @@ -1252,8 +1252,8 @@ func TestAutopilotCreatedIssueCreatorIsAssigneeAgent(t *testing.T) { if err != nil { t.Fatalf("DispatchAutopilot: %v", err) } - if run == nil || run.Status != "issue_created" { - t.Fatalf("dispatch result = %+v, want status issue_created", run) + if run == nil || run.Status != "running" { + t.Fatalf("dispatch result = %+v, want status running (task-bound, MUL-4809 §4.1)", run) } var creatorType, creatorID string diff --git a/server/internal/handler/webhook_delivery_test.go b/server/internal/handler/webhook_delivery_test.go index 4afc6ccf50a..1c01af6ab66 100644 --- a/server/internal/handler/webhook_delivery_test.go +++ b/server/internal/handler/webhook_delivery_test.go @@ -826,6 +826,15 @@ func TestWebhookDeliveryWorker_RepairsCreateIssueTaskCrashWindow(t *testing.T) { if _, err := testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID); err != nil { t.Fatalf("remove issue task: %v", err) } + // Binding run.task_id + advancing to running is the LAST step of create_issue + // dispatch (MUL-4809 §4.1), so a crash before it leaves the run in + // issue_created with a NULL task_id. Model exactly that state so the repair + // path (issue exists, task missing) is exercised. + if _, err := testPool.Exec(context.Background(), + `UPDATE autopilot_run SET status = 'issue_created', task_id = NULL WHERE id = $1`, + first.AutopilotRunID); err != nil { + t.Fatalf("reset run to pre-bind crash state: %v", err) + } if _, err := testPool.Exec(context.Background(), ` UPDATE webhook_delivery SET status = 'queued', autopilot_run_id = NULL, available_at = now(), diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index 03831e0e168..e300216dae5 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -717,7 +717,9 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi // webhook dispatch has no actor and takes the plain entry points, where the // autopilot-origin issue resolves to rule_owner. The *WithHandoff variants are // the existing actor-carrying enqueue methods; the handoff note is empty here. - if ap.AssigneeType == "squad" { + var dispatchedTask db.AgentTaskQueue + switch { + case ap.AssigneeType == "squad": // Fail-closed invocation gate: verify the admission principal (manual // clicker, else creator — see autopilotAdmitInvoke) may still invoke the // leader. Catches configs that predate the save-time gate, and configs @@ -726,18 +728,42 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi return fmt.Errorf("not allowed to invoke private squad leader") } if actorUserID.Valid { - if _, err := s.TaskSvc.EnqueueTaskForSquadLeaderWithHandoff(ctx, issue, leader.ID, ap.AssigneeID, "", actorUserID); err != nil { - return fmt.Errorf("enqueue squad leader task: %w", err) - } - } else if _, err := s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, ap.AssigneeID, pgtype.UUID{}); err != nil { + dispatchedTask, err = s.TaskSvc.EnqueueTaskForSquadLeaderWithHandoff(ctx, issue, leader.ID, ap.AssigneeID, "", actorUserID) + } else { + dispatchedTask, err = s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, ap.AssigneeID, pgtype.UUID{}) + } + if err != nil { return fmt.Errorf("enqueue squad leader task: %w", err) } - } else if actorUserID.Valid { - if _, err := s.TaskSvc.EnqueueTaskForIssueWithHandoff(ctx, issue, "", actorUserID); err != nil { + case actorUserID.Valid: + dispatchedTask, err = s.TaskSvc.EnqueueTaskForIssueWithHandoff(ctx, issue, "", actorUserID) + if err != nil { + return fmt.Errorf("enqueue task for issue: %w", err) + } + default: + dispatchedTask, err = s.TaskSvc.EnqueueTaskForIssue(ctx, issue) + if err != nil { return fmt.Errorf("enqueue task for issue: %w", err) } - } else if _, err := s.TaskSvc.EnqueueTaskForIssue(ctx, issue); err != nil { - return fmt.Errorf("enqueue task for issue: %w", err) + } + + // Bind the run to its dispatched task and advance issue_created -> running + // (MUL-4809 §4.1). The run now finalizes on THIS task's terminal state (and + // its retry lineage), not on the issue status. The task itself keeps no + // autopilot_run_id, so it stays an ordinary issue task for context / + // classification; the run owns the pointer instead. A crash between the + // enqueue above and this bind leaves the run in issue_created (task_id NULL) — + // the same pre-existing hang class as a crash before enqueue, and the sync's + // issue-scoped fallback still finalizes such a run. + if dispatchedTask.ID.Valid { + updatedRun, bindErr := s.Queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{ + ID: run.ID, + TaskID: dispatchedTask.ID, + }) + if bindErr != nil { + return fmt.Errorf("bind autopilot run to dispatched task: %w", bindErr) + } + *run = updatedRun } slog.Info("autopilot dispatched (create_issue)", @@ -746,6 +772,7 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi "issue_id", util.UUIDToString(issue.ID), "leader_id", util.UUIDToString(leader.ID), "run_id", util.UUIDToString(run.ID), + "task_id", util.UUIDToString(dispatchedTask.ID), ) return nil } @@ -955,42 +982,117 @@ func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot, return nil } -// SyncRunFromIssue updates the autopilot run when its linked issue reaches a terminal status. -func (s *AutopilotService) SyncRunFromIssue(ctx context.Context, issue db.Issue) { - if !issue.OriginType.Valid || issue.OriginType.String != "autopilot" { - return +// isAutopilotTaskTerminal reports whether an agent task has reached a terminal +// state that finalizes an autopilot run. +func isAutopilotTaskTerminal(status string) bool { + return status == "completed" || status == "failed" || status == "cancelled" +} + +// retryRootTaskID walks the retry_of_task_id chain up from task to the first +// attempt (the root). System retries form a short linear chain bounded by +// max_attempts, so this is a handful of point lookups. +func (s *AutopilotService) retryRootTaskID(ctx context.Context, task db.AgentTaskQueue) (pgtype.UUID, error) { + root := task + for root.RetryOfTaskID.Valid { + parent, err := s.Queries.GetAgentTask(ctx, root.RetryOfTaskID) + if err != nil { + return pgtype.UUID{}, err + } + root = parent } + return root.ID, nil +} - run, err := s.Queries.GetAutopilotRunByIssue(ctx, issue.ID) +// SyncRunFromCreateIssueTask finalizes a create_issue autopilot run from the +// terminal state of the task the autopilot dispatched (MUL-4809 §4.1) — the run +// is driven purely by task outcome, never by the issue status. +// +// create_issue tasks carry issue_id (not autopilot_run_id — that stays free so +// they remain ordinary issue tasks), so the run is found by issue_id and the +// terminal task is matched to the run's dispatched-task lineage via run.task_id +// plus the retry_of_task_id chain. That match is what keeps a LATER +// comment-triggered task on the same issue from finalizing the run. +// +// When run.task_id was never bound (a crash between enqueue and bind, or a run +// dispatched by a pre-§4.1 pod mid-rolling-deploy) it falls back to the +// issue-scoped "no task still active" check so the run still finalizes rather +// than hanging. +func (s *AutopilotService) SyncRunFromCreateIssueTask(ctx context.Context, task db.AgentTaskQueue) { + // run_only tasks carry autopilot_run_id and are handled by SyncRunFromTask. + if task.AutopilotRunID.Valid || !task.IssueID.Valid || !isAutopilotTaskTerminal(task.Status) { + return + } + run, err := s.Queries.GetAutopilotRunByIssue(ctx, task.IssueID) if err != nil { - return // no active run linked to this issue + return // no in-flight run linked to this issue (covers ordinary issue/chat tasks) } + + if run.TaskID.Valid { + root, rerr := s.retryRootTaskID(ctx, task) + if rerr != nil { + slog.Warn("autopilot: failed to resolve task retry root", + "task_id", util.UUIDToString(task.ID), "error", rerr) + return + } + if root.Bytes != run.TaskID.Bytes { + return // not this run's dispatched-task lineage (e.g. a comment task) + } + // A still-queued system retry means another attempt is already in flight + // (FailTask enqueues it before broadcasting the failure) — wait for it. + if task.Status != "completed" { + pending, perr := s.Queries.HasPendingRetryForTask(ctx, task.ID) + if perr != nil { + slog.Warn("autopilot: failed to check pending retry", + "task_id", util.UUIDToString(task.ID), "error", perr) + return + } + if pending { + return + } + } + } else { + // Crash/rolling-deploy fallback: the run's task_id was never bound. Finalize + // only when no task is still active for the issue, mirroring the previous + // issue-scoped behavior so the run doesn't hang. + hasActive, herr := s.Queries.HasActiveTaskForIssue(ctx, task.IssueID) + if herr != nil { + slog.Warn("autopilot: failed to check active tasks for unbound run", + "issue_id", util.UUIDToString(task.IssueID), "error", herr) + return + } + if hasActive { + return + } + } + autopilot, err := s.Queries.GetAutopilot(ctx, run.AutopilotID) if err != nil { return } + wsID := util.UUIDToString(autopilot.WorkspaceID) - wsID := util.UUIDToString(issue.WorkspaceID) - - switch issue.Status { - case "done", "in_review": + switch task.Status { + case "completed": updatedRun, err := s.Queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{ - ID: run.ID, + ID: run.ID, + Result: task.Result, }) if err != nil { - slog.Warn("failed to complete autopilot run", "run_id", util.UUIDToString(run.ID), "error", err) + slog.Warn("failed to complete autopilot run from create_issue task", + "run_id", util.UUIDToString(run.ID), "error", err) return } s.captureAutopilotRunCompleted(autopilot, updatedRun) s.publishRunDone(wsID, updatedRun, "completed") - case "cancelled", "blocked": - reason := "issue " + issue.Status + case "failed", "cancelled": + reason := taskFailureReasonForAutopilotRun(task) updatedRun, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{ ID: run.ID, - FailureReason: pgtype.Text{String: reason, Valid: true}, + FailureReason: pgtype.Text{String: reason, Valid: reason != ""}, }) if err != nil { - slog.Warn("failed to fail autopilot run", "run_id", util.UUIDToString(run.ID), "error", err) + slog.Warn("failed to fail autopilot run from create_issue task", + "run_id", util.UUIDToString(run.ID), "error", err) return } s.captureAutopilotRunFailed(autopilot, updatedRun, updatedRun.Source, reason) @@ -1045,69 +1147,6 @@ func (s *AutopilotService) SyncRunFromTask(ctx context.Context, task db.AgentTas } } -// SyncRunFromLinkedIssueTask fails a create_issue autopilot run when its -// linked issue task fails terminally before the issue itself reaches a -// terminal status. create_issue tasks are linked through issue_id rather than -// autopilot_run_id, so SyncRunFromTask cannot see them directly. Without this -// the run would hang in `issue_created` forever — and because the failure-rate -// auto-pause monitor excludes issue_created/running runs, a consistently -// failing autopilot would never trip the auto-pause either. -// -// "Terminal" means no task is still active for the issue. FailTask enqueues an -// auto-retry for infra-shaped failures (timeout, runtime offline/recovery, -// codex no-progress) BEFORE it broadcasts the failure event, so an active task -// here means another attempt is already in flight — we wait for it instead of -// failing the run prematurely. Once retries are exhausted (or the failure was -// never retryable in the first place), the run fails carrying the task's reason. -func (s *AutopilotService) SyncRunFromLinkedIssueTask(ctx context.Context, task db.AgentTaskQueue) { - if task.AutopilotRunID.Valid || !task.IssueID.Valid || task.Status != "failed" { - return - } - // Only create_issue runs link through issue_id (and their linked issue is - // always origin_type=autopilot by construction), so a hit here both - // identifies an in-flight create_issue run and bails the common case of - // ordinary issue/chat task failures after a single query. - run, err := s.Queries.GetAutopilotRunByIssue(ctx, task.IssueID) - if err != nil { - return // no active run linked to this issue - } - // A still-active task — typically the auto-retry FailTask just enqueued — - // means the dispatch isn't terminal yet; wait for the final attempt. - hasActive, err := s.Queries.HasActiveTaskForIssue(ctx, task.IssueID) - if err != nil { - slog.Warn("failed to check active tasks for autopilot issue failure", - "issue_id", util.UUIDToString(task.IssueID), - "task_id", util.UUIDToString(task.ID), - "error", err, - ) - return - } - if hasActive { - return - } - autopilot, err := s.Queries.GetAutopilot(ctx, run.AutopilotID) - if err != nil { - return - } - - reason := taskFailureReasonForAutopilotRun(task) - updatedRun, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{ - ID: run.ID, - FailureReason: pgtype.Text{String: reason, Valid: reason != ""}, - }) - if err != nil { - slog.Warn("failed to fail autopilot run from linked issue task", - "run_id", util.UUIDToString(run.ID), - "issue_id", util.UUIDToString(task.IssueID), - "task_id", util.UUIDToString(task.ID), - "error", err, - ) - return - } - s.captureAutopilotRunFailed(autopilot, updatedRun, updatedRun.Source, reason) - s.publishRunDone(util.UUIDToString(autopilot.WorkspaceID), updatedRun, "failed") -} - func taskFailureReasonForAutopilotRun(task db.AgentTaskQueue) string { if task.Error.Valid && strings.TrimSpace(task.Error.String) != "" { return task.Error.String diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 46f110d1908..d0c435d9f85 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -463,8 +463,11 @@ func (s *TaskService) attributionForIssueTask(ctx context.Context, issue db.Issu // autopilot id, so bridge issue → active run → trigger_id to find the trigger. if s != nil && s.Queries != nil && issue.OriginType.Valid && issue.OriginType.String == "autopilot" && issue.OriginID.Valid { + // Latest run in ANY status: runs now finalize on task outcome (MUL-4809 + // §4.1), so a follow-up issue task can outlive the active run — the active + // GetAutopilotRunByIssue would miss the provenance once the run is done. var triggerID pgtype.UUID - if run, err := s.Queries.GetAutopilotRunByIssue(ctx, issue.ID); err == nil { + if run, err := s.Queries.GetLatestAutopilotRunByIssue(ctx, issue.ID); err == nil { triggerID = run.TriggerID } return triggerOwnerAttribution(ctx, s.Queries, triggerID, issue.WorkspaceID, issue.OriginID, attribution.EvidenceIssueAssignment, issue.ID) diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index 3564c37418b..ceb36e12795 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -645,6 +645,9 @@ LIMIT 1 // ===================== // Run lookup by linked entities // ===================== +// The in-flight run linked to an issue. Used by the task-outcome sync to find a +// run that is still finalizable (MUL-4809 §4.1); already-terminal runs are +// excluded, which also makes finalization idempotent under a rolling deploy. func (q *Queries) GetAutopilotRunByIssue(ctx context.Context, issueID pgtype.UUID) (AutopilotRun, error) { row := q.db.QueryRow(ctx, getAutopilotRunByIssue, issueID) var i AutopilotRun @@ -837,6 +840,41 @@ func (q *Queries) GetAutopilotTrigger(ctx context.Context, id pgtype.UUID) (Auto return i, err } +const getLatestAutopilotRunByIssue = `-- name: GetLatestAutopilotRunByIssue :one +SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at, webhook_delivery_id FROM autopilot_run +WHERE issue_id = $1 +ORDER BY created_at DESC +LIMIT 1 +` + +// The most recent run linked to an issue, in ANY status. Attribution needs the +// firing trigger's owner even after the run has already finalized (MUL-4809 +// §4.1: runs now complete on task outcome, so a later issue task can outlive the +// active run) — GetAutopilotRunByIssue would return nothing once the run is done. +func (q *Queries) GetLatestAutopilotRunByIssue(ctx context.Context, issueID pgtype.UUID) (AutopilotRun, error) { + row := q.db.QueryRow(ctx, getLatestAutopilotRunByIssue, issueID) + var i AutopilotRun + err := row.Scan( + &i.ID, + &i.AutopilotID, + &i.TriggerID, + &i.Source, + &i.Status, + &i.IssueID, + &i.TaskID, + &i.TriggeredAt, + &i.CompletedAt, + &i.FailureReason, + &i.TriggerPayload, + &i.Result, + &i.CreatedAt, + &i.SquadID, + &i.PlannedAt, + &i.WebhookDeliveryID, + ) + return i, err +} + const getWebhookTriggerByToken = `-- name: GetWebhookTriggerByToken :one SELECT t.id, t.autopilot_id, t.kind, t.enabled, t.cron_expression, t.timezone, t.next_run_at, t.webhook_token, t.label, t.last_fired_at, t.created_at, t.updated_at, t.provider, t.signing_secret, t.event_filters, t.published_by_type, t.published_by_id, a.workspace_id AS autopilot_workspace_id FROM autopilot_trigger t @@ -897,6 +935,23 @@ func (q *Queries) GetWebhookTriggerByToken(ctx context.Context, webhookToken pgt return i, err } +const hasPendingRetryForTask = `-- name: HasPendingRetryForTask :one +SELECT count(*) > 0 AS has_pending FROM agent_task_queue +WHERE retry_of_task_id = $1 + AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory') +` + +// True when a task has a non-terminal system-retry successor (retry_of_task_id). +// The create_issue run-finalization waits on this: FailTask enqueues the retry +// BEFORE broadcasting the failure event, so an active successor here means +// another attempt is in flight and the run must stay open (MUL-4809 §4.1). +func (q *Queries) HasPendingRetryForTask(ctx context.Context, retryOfTaskID pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, hasPendingRetryForTask, retryOfTaskID) + var has_pending bool + err := row.Scan(&has_pending) + return has_pending, err +} + const isAutopilotCollaborator = `-- name: IsAutopilotCollaborator :one SELECT EXISTS ( SELECT 1 FROM autopilot_collaborator diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index 141591b5f6b..2e6ca40e396 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -427,10 +427,32 @@ LIMIT 1; -- ===================== -- name: GetAutopilotRunByIssue :one +-- The in-flight run linked to an issue. Used by the task-outcome sync to find a +-- run that is still finalizable (MUL-4809 §4.1); already-terminal runs are +-- excluded, which also makes finalization idempotent under a rolling deploy. SELECT * FROM autopilot_run WHERE issue_id = $1 AND status IN ('issue_created', 'running') LIMIT 1; +-- name: GetLatestAutopilotRunByIssue :one +-- The most recent run linked to an issue, in ANY status. Attribution needs the +-- firing trigger's owner even after the run has already finalized (MUL-4809 +-- §4.1: runs now complete on task outcome, so a later issue task can outlive the +-- active run) — GetAutopilotRunByIssue would return nothing once the run is done. +SELECT * FROM autopilot_run +WHERE issue_id = $1 +ORDER BY created_at DESC +LIMIT 1; + +-- name: HasPendingRetryForTask :one +-- True when a task has a non-terminal system-retry successor (retry_of_task_id). +-- The create_issue run-finalization waits on this: FailTask enqueues the retry +-- BEFORE broadcasting the failure event, so an active successor here means +-- another attempt is in flight and the run must stay open (MUL-4809 §4.1). +SELECT count(*) > 0 AS has_pending FROM agent_task_queue +WHERE retry_of_task_id = $1 + AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory'); + -- name: FailAutopilotRunsByIssue :exec -- Fails active autopilot runs linked to a given issue. -- Must be called BEFORE issue deletion (ON DELETE SET NULL clears issue_id). From 845efb9c735a1ac763821a271456fabe4884e979 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Fri, 17 Jul 2026 17:22:46 +0800 Subject: [PATCH 13/41] =?UTF-8?q?fix(autopilot):=20compare-and-set=20run?= =?UTF-8?q?=20state=20transitions=20(MUL-4809=20=C2=A74.1=20P0-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elon review P0-1: the run bind and terminal transitions had no expected-state guard (`WHERE id = $1` only), so a run could be resurrected after completion or have two finalizers overwrite each other (last-writer-wins), and a rolling deploy's old issue-status listener could race the new task listener. - UpdateAutopilotRunRunning now only advances a run that is still pending/ issue_created/running; UpdateAutopilotRun{Completed,Failed} only finalize an in-flight (issue_created/running) run. A CAS-lost write matches zero rows (pgx.ErrNoRows) instead of clobbering a terminal state. - Callers treat ErrNoRows as first-writer-wins: the two Sync finalizers and failRun no-op silently (no capture / no duplicate run_done publish); the create_issue and webhook binds reload the already-terminal run instead of resurrecting it to running or erroring the dispatch. Adds a deterministic CAS test: a completed run rejects a late fail and a late bind (both ErrNoRows) and stays completed with no task_id. Existing autopilot service + cmd/server suites unchanged. Co-authored-by: multica-agent --- server/cmd/server/autopilot_listeners_test.go | 75 +++++++++++++++++++ server/internal/service/autopilot.go | 43 +++++++++-- server/pkg/db/generated/autopilot.sql.go | 16 +++- server/pkg/db/queries/autopilot.sql | 16 +++- 4 files changed, 138 insertions(+), 12 deletions(-) diff --git a/server/cmd/server/autopilot_listeners_test.go b/server/cmd/server/autopilot_listeners_test.go index 83aadc37bbb..380bab69ff9 100644 --- a/server/cmd/server/autopilot_listeners_test.go +++ b/server/cmd/server/autopilot_listeners_test.go @@ -2,9 +2,11 @@ package main import ( "context" + "errors" "strings" "testing" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/service" @@ -644,3 +646,76 @@ func TestManualTriggerDoesNotErrorOnPostAdmissionSkip(t *testing.T) { t.Fatalf("expected run status 'skipped', got %q", run.Status) } } + +// TestAutopilotRunTerminalTransitionsAreCAS locks in the compare-and-set +// contract (MUL-4809 §4.1 P0-1): once a run is terminal, neither a racing +// terminal write nor a late bind may overwrite or resurrect it. Concurrent +// finalizers become first-writer-wins (the loser gets pgx.ErrNoRows), which is +// what keeps a rolling deploy / task-vs-listener race from producing a wrong +// terminal state or a "running + completed_at" row. +func TestAutopilotRunTerminalTransitionsAreCAS(t *testing.T) { + ctx := context.Background() + queries := db.New(testPool) + + var agentID string + if err := testPool.QueryRow(ctx, + `SELECT id::text FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`, + testWorkspaceID, + ).Scan(&agentID); err != nil { + t.Fatalf("load fixture agent: %v", err) + } + ap, err := queries.CreateAutopilot(ctx, db.CreateAutopilotParams{ + WorkspaceID: parseUUID(testWorkspaceID), + Title: "CAS transition test", + AssigneeType: "agent", + AssigneeID: parseUUID(agentID), + Status: "active", + ExecutionMode: "create_issue", + IssueTitleTemplate: pgtype.Text{String: "x", Valid: true}, + CreatedByType: "member", + CreatedByID: parseUUID(testUserID), + }) + if err != nil { + t.Fatalf("CreateAutopilot: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID) }) + + run, err := queries.CreateAutopilotRun(ctx, db.CreateAutopilotRunParams{ + AutopilotID: ap.ID, + Source: "manual", + Status: "issue_created", + }) + if err != nil { + t.Fatalf("CreateAutopilotRun: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE id = $1`, run.ID) }) + + // A terminal transition from an active run succeeds. + completed, err := queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{ID: run.ID}) + if err != nil || completed.Status != "completed" { + t.Fatalf("first complete: status=%q err=%v", completed.Status, err) + } + + // A racing FAIL on the already-completed run must be rejected (CAS lost). + if _, err := queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{ + ID: run.ID, FailureReason: pgtype.Text{String: "late failure", Valid: true}, + }); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("fail on completed run: want pgx.ErrNoRows, got %v", err) + } + + // A late BIND must not resurrect the completed run back to running. + if _, err := queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{ + ID: run.ID, TaskID: parseUUID("22222222-2222-2222-2222-222222222222"), + }); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("bind on completed run: want pgx.ErrNoRows, got %v", err) + } + + // The run is unchanged: still completed, and never bound to the late task. + final, err := queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("reload run: %v", err) + } + if final.Status != "completed" || final.TaskID.Valid { + t.Fatalf("run mutated after CAS-lost writes: status=%q task_id_valid=%v", final.Status, final.TaskID.Valid) + } +} diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index e300216dae5..e2b81aff318 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -274,11 +274,20 @@ func (s *AutopilotService) DispatchAutopilotForWebhookDelivery( ID: run.ID, TaskID: task.ID, }) - if updateErr != nil { + switch { + case updateErr == nil: + s.TaskSvc.NotifyTaskEnqueued(ctx, task) + return &updated, nil + case errors.Is(updateErr, pgx.ErrNoRows): + // CAS lost: the task already finalized the run. Return the current + // (terminal) run rather than resurrecting it. + if reloaded, rerr := s.Queries.GetAutopilotRun(ctx, run.ID); rerr == nil { + return &reloaded, nil + } + return run, nil + default: return run, fmt.Errorf("dispatch for webhook delivery: repair task linkage: %w", updateErr) } - s.TaskSvc.NotifyTaskEnqueued(ctx, task) - return &updated, nil case !errors.Is(taskErr, pgx.ErrNoRows): return run, fmt.Errorf("dispatch for webhook delivery: lookup linked task: %w", taskErr) } @@ -760,10 +769,19 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi ID: run.ID, TaskID: dispatchedTask.ID, }) - if bindErr != nil { + switch { + case bindErr == nil: + *run = updatedRun + case errors.Is(bindErr, pgx.ErrNoRows): + // CAS lost: the dispatched task already ran to terminal and finalized + // the run before this bind landed. Do NOT resurrect it to running — + // reload the terminal run and treat dispatch as succeeded. + if reloaded, rerr := s.Queries.GetAutopilotRun(ctx, run.ID); rerr == nil { + *run = reloaded + } + default: return fmt.Errorf("bind autopilot run to dispatched task: %w", bindErr) } - *run = updatedRun } slog.Info("autopilot dispatched (create_issue)", @@ -1078,6 +1096,9 @@ func (s *AutopilotService) SyncRunFromCreateIssueTask(ctx context.Context, task Result: task.Result, }) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return // CAS lost: the run was already finalized by another path + } slog.Warn("failed to complete autopilot run from create_issue task", "run_id", util.UUIDToString(run.ID), "error", err) return @@ -1091,6 +1112,9 @@ func (s *AutopilotService) SyncRunFromCreateIssueTask(ctx context.Context, task FailureReason: pgtype.Text{String: reason, Valid: reason != ""}, }) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return // CAS lost: the run was already finalized by another path + } slog.Warn("failed to fail autopilot run from create_issue task", "run_id", util.UUIDToString(run.ID), "error", err) return @@ -1124,6 +1148,9 @@ func (s *AutopilotService) SyncRunFromTask(ctx context.Context, task db.AgentTas Result: task.Result, }) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return // CAS lost: the run was already finalized by another path + } slog.Warn("failed to complete autopilot run from task", "run_id", util.UUIDToString(run.ID), "error", err) return } @@ -1139,6 +1166,9 @@ func (s *AutopilotService) SyncRunFromTask(ctx context.Context, task db.AgentTas FailureReason: pgtype.Text{String: reason, Valid: true}, }) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return // CAS lost: the run was already finalized by another path + } slog.Warn("failed to fail autopilot run from task", "run_id", util.UUIDToString(run.ID), "error", err) return } @@ -1202,7 +1232,8 @@ func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reaso if _, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{ ID: runID, FailureReason: pgtype.Text{String: reason, Valid: true}, - }); err != nil { + }); err != nil && !errors.Is(err, pgx.ErrNoRows) { + // ErrNoRows = CAS lost: the run already reached a terminal state; nothing to do. slog.Warn("failed to mark autopilot run as failed", "run_id", util.UUIDToString(runID), "error", err) } } diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index ceb36e12795..01594ea1503 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -1730,7 +1730,7 @@ func (q *Queries) UpdateAutopilotLastRunAt(ctx context.Context, id pgtype.UUID) const updateAutopilotRunCompleted = `-- name: UpdateAutopilotRunCompleted :one UPDATE autopilot_run SET status = 'completed', completed_at = now(), result = $2 -WHERE id = $1 +WHERE id = $1 AND status IN ('issue_created', 'running') RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at, webhook_delivery_id ` @@ -1739,6 +1739,10 @@ type UpdateAutopilotRunCompletedParams struct { Result []byte `json:"result"` } +// Compare-and-set terminal transition (MUL-4809 §4.1): only an in-flight run may +// be completed. Zero rows means another path (a racing task/issue listener, or a +// rolling-deploy old pod) already wrote a terminal state — first writer wins; +// the caller no-ops instead of overwriting the terminal state or re-publishing. func (q *Queries) UpdateAutopilotRunCompleted(ctx context.Context, arg UpdateAutopilotRunCompletedParams) (AutopilotRun, error) { row := q.db.QueryRow(ctx, updateAutopilotRunCompleted, arg.ID, arg.Result) var i AutopilotRun @@ -1766,7 +1770,7 @@ func (q *Queries) UpdateAutopilotRunCompleted(ctx context.Context, arg UpdateAut const updateAutopilotRunFailed = `-- name: UpdateAutopilotRunFailed :one UPDATE autopilot_run SET status = 'failed', completed_at = now(), failure_reason = $2 -WHERE id = $1 +WHERE id = $1 AND status IN ('issue_created', 'running') RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at, webhook_delivery_id ` @@ -1775,6 +1779,7 @@ type UpdateAutopilotRunFailedParams struct { FailureReason pgtype.Text `json:"failure_reason"` } +// Compare-and-set terminal transition (MUL-4809 §4.1); see UpdateAutopilotRunCompleted. func (q *Queries) UpdateAutopilotRunFailed(ctx context.Context, arg UpdateAutopilotRunFailedParams) (AutopilotRun, error) { row := q.db.QueryRow(ctx, updateAutopilotRunFailed, arg.ID, arg.FailureReason) var i AutopilotRun @@ -1838,7 +1843,7 @@ func (q *Queries) UpdateAutopilotRunIssueCreated(ctx context.Context, arg Update const updateAutopilotRunRunning = `-- name: UpdateAutopilotRunRunning :one UPDATE autopilot_run SET status = 'running', task_id = $2 -WHERE id = $1 +WHERE id = $1 AND status IN ('pending', 'issue_created', 'running') RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at, webhook_delivery_id ` @@ -1847,6 +1852,11 @@ type UpdateAutopilotRunRunningParams struct { TaskID pgtype.UUID `json:"task_id"` } +// Compare-and-set bind (MUL-4809 §4.1): only advance a run that is NOT already +// terminal. If the dispatched task finished and finalized the run before the +// bind lands, this matches zero rows (pgx.ErrNoRows) instead of resurrecting a +// completed/failed run back to running. Callers treat zero rows as "already +// finalized" and reload rather than error. func (q *Queries) UpdateAutopilotRunRunning(ctx context.Context, arg UpdateAutopilotRunRunningParams) (AutopilotRun, error) { row := q.db.QueryRow(ctx, updateAutopilotRunRunning, arg.ID, arg.TaskID) var i AutopilotRun diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index 2e6ca40e396..7ee14646f1c 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -309,21 +309,31 @@ WHERE id = $1 RETURNING *; -- name: UpdateAutopilotRunRunning :one +-- Compare-and-set bind (MUL-4809 §4.1): only advance a run that is NOT already +-- terminal. If the dispatched task finished and finalized the run before the +-- bind lands, this matches zero rows (pgx.ErrNoRows) instead of resurrecting a +-- completed/failed run back to running. Callers treat zero rows as "already +-- finalized" and reload rather than error. UPDATE autopilot_run SET status = 'running', task_id = $2 -WHERE id = $1 +WHERE id = $1 AND status IN ('pending', 'issue_created', 'running') RETURNING *; -- name: UpdateAutopilotRunCompleted :one +-- Compare-and-set terminal transition (MUL-4809 §4.1): only an in-flight run may +-- be completed. Zero rows means another path (a racing task/issue listener, or a +-- rolling-deploy old pod) already wrote a terminal state — first writer wins; +-- the caller no-ops instead of overwriting the terminal state or re-publishing. UPDATE autopilot_run SET status = 'completed', completed_at = now(), result = sqlc.narg('result') -WHERE id = $1 +WHERE id = $1 AND status IN ('issue_created', 'running') RETURNING *; -- name: UpdateAutopilotRunFailed :one +-- Compare-and-set terminal transition (MUL-4809 §4.1); see UpdateAutopilotRunCompleted. UPDATE autopilot_run SET status = 'failed', completed_at = now(), failure_reason = $2 -WHERE id = $1 +WHERE id = $1 AND status IN ('issue_created', 'running') RETURNING *; -- name: UpdateAutopilotRunSkipped :one From 44d7850e875d15e9169076ea8f670419660eac69 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Fri, 17 Jul 2026 18:07:28 +0800 Subject: [PATCH 14/41] =?UTF-8?q?fix(autopilot):=20harden=20CAS=20bind=20?= =?UTF-8?q?=E2=80=94=20exclusive/idempotent=20+=20authoritative=20reload?= =?UTF-8?q?=20(MUL-4809=20=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the CAS-slice review of 845efb9c7 (4 items). P0-1 — bind is now a true CAS. UpdateAutopilotRunRunning gains `AND (task_id IS NULL OR task_id = $2)`: NULL first-binds, the same task replays idempotently, a DIFFERENT task is rejected (zero rows). The first dispatched task can no longer be silently rebound to another. P0-2 — single bindAutopilotRunTask helper used by all three dispatch entry points (create_issue, run_only, webhook repair). On a CAS miss it reloads to establish the authoritative state and only reports success when the run is already terminal (a racing task finalized it) or already bound to this task; an active-but-different bind, a missing row, or a failed reload is an error — so schedule/webhook callers never record a phantom dispatch. run_only and webhook binds now return that error instead of logging-and-continuing. P1-3 — comments/tests no longer claim this CAS solves the rolling-deploy race: an old-version pod still runs the unguarded write, so the guarantee is only first-writer-wins among same-version finalizers; the deploy-enable gate is later work. P1-4 — failRun returns whether it won the transition; the three dispatch-failure call sites capture a failure only when the write actually landed, ending the double-count when another finalizer already terminated the run. Tests: bindAutopilotRunTask idempotent same-task / rejects different-task / returns the authoritative terminal run / errors when the run row is gone. Existing service, cmd/server autopilot, and handler webhook suites unchanged. Co-authored-by: multica-agent --- server/cmd/server/autopilot_listeners_test.go | 7 +- server/internal/service/autopilot.go | 133 ++++++++------ .../service/autopilot_bind_cas_test.go | 164 ++++++++++++++++++ server/pkg/db/generated/autopilot.sql.go | 27 ++- server/pkg/db/queries/autopilot.sql | 26 ++- 5 files changed, 285 insertions(+), 72 deletions(-) create mode 100644 server/internal/service/autopilot_bind_cas_test.go diff --git a/server/cmd/server/autopilot_listeners_test.go b/server/cmd/server/autopilot_listeners_test.go index 380bab69ff9..4b70923abd9 100644 --- a/server/cmd/server/autopilot_listeners_test.go +++ b/server/cmd/server/autopilot_listeners_test.go @@ -650,9 +650,10 @@ func TestManualTriggerDoesNotErrorOnPostAdmissionSkip(t *testing.T) { // TestAutopilotRunTerminalTransitionsAreCAS locks in the compare-and-set // contract (MUL-4809 §4.1 P0-1): once a run is terminal, neither a racing // terminal write nor a late bind may overwrite or resurrect it. Concurrent -// finalizers become first-writer-wins (the loser gets pgx.ErrNoRows), which is -// what keeps a rolling deploy / task-vs-listener race from producing a wrong -// terminal state or a "running + completed_at" row. +// finalizers OF THIS BINARY become first-writer-wins (the loser gets +// pgx.ErrNoRows). (Note: this cannot constrain an old-version pod still running +// the unguarded write during a rolling deploy — that needs the deploy-enable +// gate tracked as later work.) func TestAutopilotRunTerminalTransitionsAreCAS(t *testing.T) { ctx := context.Background() queries := db.New(testPool) diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index e2b81aff318..918e7ce13be 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -270,24 +270,17 @@ func (s *AutopilotService) DispatchAutopilotForWebhookDelivery( task, taskErr := s.Queries.GetAutopilotTaskByRun(ctx, run.ID) switch { case taskErr == nil: - updated, updateErr := s.Queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{ - ID: run.ID, - TaskID: task.ID, - }) - switch { - case updateErr == nil: + updated, bindErr := s.bindAutopilotRunTask(ctx, run.ID, task.ID) + if bindErr != nil { + return run, fmt.Errorf("dispatch for webhook delivery: repair task linkage: %w", bindErr) + } + // Only wake the daemon when this call actually (re)bound the task; if a + // racing finalizer already completed the run, updated is that terminal + // run and there is nothing to enqueue. + if !isAutopilotRunTerminalStatus(updated.Status) { s.TaskSvc.NotifyTaskEnqueued(ctx, task) - return &updated, nil - case errors.Is(updateErr, pgx.ErrNoRows): - // CAS lost: the task already finalized the run. Return the current - // (terminal) run rather than resurrecting it. - if reloaded, rerr := s.Queries.GetAutopilotRun(ctx, run.ID); rerr == nil { - return &reloaded, nil - } - return run, nil - default: - return run, fmt.Errorf("dispatch for webhook delivery: repair task linkage: %w", updateErr) } + return &updated, nil case !errors.Is(taskErr, pgx.ErrNoRows): return run, fmt.Errorf("dispatch for webhook delivery: lookup linked task: %w", taskErr) } @@ -519,8 +512,9 @@ func (s *AutopilotService) dispatchAutopilotRun( if skipped, code := s.handleDispatchSkip(ctx, autopilot, run, err); skipped != nil { return skipped, code, nil } - s.failRun(ctx, run.ID, err.Error()) - s.captureAutopilotRunFailed(autopilot, *run, source, err.Error()) + if failed, won := s.failRun(ctx, run.ID, err.Error()); won { + s.captureAutopilotRunFailed(autopilot, failed, source, err.Error()) + } return run, dispatchFailReasonCode(err), fmt.Errorf("dispatch create_issue: %w", err) } case "run_only": @@ -528,13 +522,15 @@ func (s *AutopilotService) dispatchAutopilotRun( if skipped, code := s.handleDispatchSkip(ctx, autopilot, run, err); skipped != nil { return skipped, code, nil } - s.failRun(ctx, run.ID, err.Error()) - s.captureAutopilotRunFailed(autopilot, *run, source, err.Error()) + if failed, won := s.failRun(ctx, run.ID, err.Error()); won { + s.captureAutopilotRunFailed(autopilot, failed, source, err.Error()) + } return run, dispatchFailReasonCode(err), fmt.Errorf("dispatch run_only: %w", err) } default: - s.failRun(ctx, run.ID, "unknown execution_mode: "+autopilot.ExecutionMode) - s.captureAutopilotRunFailed(autopilot, *run, source, "unknown execution_mode: "+autopilot.ExecutionMode) + if failed, won := s.failRun(ctx, run.ID, "unknown execution_mode: "+autopilot.ExecutionMode); won { + s.captureAutopilotRunFailed(autopilot, failed, source, "unknown execution_mode: "+autopilot.ExecutionMode) + } return run, dispatch.ReasonInternalError, fmt.Errorf("unknown execution_mode: %s", autopilot.ExecutionMode) } @@ -765,23 +761,11 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi // the same pre-existing hang class as a crash before enqueue, and the sync's // issue-scoped fallback still finalizes such a run. if dispatchedTask.ID.Valid { - updatedRun, bindErr := s.Queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{ - ID: run.ID, - TaskID: dispatchedTask.ID, - }) - switch { - case bindErr == nil: - *run = updatedRun - case errors.Is(bindErr, pgx.ErrNoRows): - // CAS lost: the dispatched task already ran to terminal and finalized - // the run before this bind landed. Do NOT resurrect it to running — - // reload the terminal run and treat dispatch as succeeded. - if reloaded, rerr := s.Queries.GetAutopilotRun(ctx, run.ID); rerr == nil { - *run = reloaded - } - default: - return fmt.Errorf("bind autopilot run to dispatched task: %w", bindErr) + updatedRun, bindErr := s.bindAutopilotRunTask(ctx, run.ID, dispatchedTask.ID) + if bindErr != nil { + return bindErr } + *run = updatedRun } slog.Info("autopilot dispatched (create_issue)", @@ -974,16 +958,14 @@ func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot, return fmt.Errorf("create autopilot task: %w", err) } - // Update run with task reference. - updatedRun, err := s.Queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{ - ID: run.ID, - TaskID: task.ID, - }) + // Bind the run to its task (compare-and-set). A failure here is a real error + // — we must not report the dispatch as succeeded if we can't confirm the run + // now owns this task. + updatedRun, err := s.bindAutopilotRunTask(ctx, run.ID, task.ID) if err != nil { - slog.Warn("failed to update run with task_id", "run_id", util.UUIDToString(run.ID), "error", err) - } else { - *run = updatedRun + return fmt.Errorf("bind run_only task: %w", err) } + *run = updatedRun // Drop the empty-claim cache and wake the daemon. dispatchRunOnly // inserts the task row directly via Queries.CreateAutopilotTask @@ -1006,6 +988,46 @@ func isAutopilotTaskTerminal(status string) bool { return status == "completed" || status == "failed" || status == "cancelled" } +// isAutopilotRunTerminalStatus reports whether an autopilot_run has reached a +// terminal state (no further transition is possible). +func isAutopilotRunTerminalStatus(status string) bool { + return status == "completed" || status == "failed" || status == "skipped" +} + +// bindAutopilotRunTask is the single, idempotent compare-and-set bind used by +// every dispatch entry point (MUL-4809 §4.1). It advances the run to running and +// records taskID, then returns the AUTHORITATIVE run so callers never report a +// dispatch as landing work it did not: +// +// - normal / same-task replay: the CAS succeeds and the bound run is returned. +// - CAS lost (pgx.ErrNoRows): reload and decide. A run that a racing task +// already finalized (terminal) is returned as-is — the work did land, just +// via the task path. Anything else — the run is active but bound to a +// DIFFERENT task, the row is gone, or the reload itself failed — is an error, +// so schedule/webhook callers do NOT record a phantom success. +func (s *AutopilotService) bindAutopilotRunTask(ctx context.Context, runID, taskID pgtype.UUID) (db.AutopilotRun, error) { + updated, err := s.Queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{ + ID: runID, + TaskID: taskID, + }) + if err == nil { + return updated, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return db.AutopilotRun{}, fmt.Errorf("bind autopilot run to task: %w", err) + } + current, rerr := s.Queries.GetAutopilotRun(ctx, runID) + if rerr != nil { + return db.AutopilotRun{}, fmt.Errorf("bind autopilot run %s: reload after CAS miss: %w", util.UUIDToString(runID), rerr) + } + if isAutopilotRunTerminalStatus(current.Status) { + return current, nil // a racing task already finalized this run + } + // Active but the CAS excluded it: the run is bound to a different task. + return db.AutopilotRun{}, fmt.Errorf("bind autopilot run %s: run is %s bound to a different task, refusing to rebind to %s", + util.UUIDToString(runID), current.Status, util.UUIDToString(taskID)) +} + // retryRootTaskID walks the retry_of_task_id chain up from task to the first // attempt (the root). System retries form a short linear chain bounded by // max_attempts, so this is a handful of point lookups. @@ -1228,14 +1250,23 @@ func (s *AutopilotService) handleDispatchSkip(ctx context.Context, ap db.Autopil return run, skipErr.code } -func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reason string) { - if _, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{ +// failRun marks a run failed as a compare-and-set. It returns the updated +// (failed) run and true only when THIS call actually won the terminal +// transition. On a CAS miss (the run was already finalized by another path) it +// returns false, and the caller MUST NOT record a failure — otherwise analytics +// would double-count against a run the DB no longer let it overwrite. +func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reason string) (db.AutopilotRun, bool) { + updated, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{ ID: runID, FailureReason: pgtype.Text{String: reason, Valid: true}, - }); err != nil && !errors.Is(err, pgx.ErrNoRows) { - // ErrNoRows = CAS lost: the run already reached a terminal state; nothing to do. - slog.Warn("failed to mark autopilot run as failed", "run_id", util.UUIDToString(runID), "error", err) + }) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + slog.Warn("failed to mark autopilot run as failed", "run_id", util.UUIDToString(runID), "error", err) + } + return db.AutopilotRun{}, false } + return updated, true } // shouldSkipDispatch is the pre-flight admission check from MUL-1899. diff --git a/server/internal/service/autopilot_bind_cas_test.go b/server/internal/service/autopilot_bind_cas_test.go new file mode 100644 index 00000000000..27a9d6723dd --- /dev/null +++ b/server/internal/service/autopilot_bind_cas_test.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +func mustUUID(t *testing.T, s string) pgtype.UUID { + t.Helper() + u, err := util.ParseUUID(s) + if err != nil { + t.Fatalf("parse uuid %q: %v", s, err) + } + return u +} + +// newBindCASFixture creates a workspace + agent + autopilot + an issue_created +// run and returns the AutopilotService, its queries, the pool, the run, and a +// helper that inserts a real agent_task_queue row (task_id carries a legacy FK, +// so binds must reference actual tasks) and returns its id. +func newBindCASFixture(t *testing.T) (*AutopilotService, *db.Queries, *pgxpool.Pool, db.AutopilotRun, func() pgtype.UUID) { + t.Helper() + ctx := context.Background() + pool := newTaskClaimRacePool(t) + queries := db.New(pool) + + suffix := time.Now().UnixNano() + var userID, workspaceID, runtimeID, agentID string + if err := pool.QueryRow(ctx, `INSERT INTO "user" (name, email) VALUES ('bind cas', $1) RETURNING id`, + fmt.Sprintf("bind-cas-%d@multica.ai", suffix)).Scan(&userID); err != nil { + t.Fatalf("create user: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO workspace (name, slug, description, issue_prefix) VALUES ('bind cas', $1, '', 'BCS') RETURNING id`, + fmt.Sprintf("bind-cas-%d", suffix)).Scan(&workspaceID); err != nil { + t.Fatalf("create workspace: %v", err) + } + t.Cleanup(func() { + pool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, workspaceID) + pool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID) + }) + if err := pool.QueryRow(ctx, ` + INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, visibility, owner_id) + VALUES ($1, NULL, 'bind cas rt', 'cloud', 'bind_cas', 'online', 'rt', '{}'::jsonb, now(), 'private', $2) RETURNING id + `, workspaceID, userID).Scan(&runtimeID); err != nil { + t.Fatalf("create runtime: %v", err) + } + if err := pool.QueryRow(ctx, ` + INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id) + VALUES ($1, 'bind cas agent', '', 'cloud', '{}'::jsonb, $2, 'private', 1, $3) RETURNING id + `, workspaceID, runtimeID, userID).Scan(&agentID); err != nil { + t.Fatalf("create agent: %v", err) + } + + ap, err := queries.CreateAutopilot(ctx, db.CreateAutopilotParams{ + WorkspaceID: mustUUID(t, workspaceID), + Title: "bind cas", + AssigneeType: "agent", + AssigneeID: mustUUID(t, agentID), + Status: "active", + ExecutionMode: "create_issue", + CreatedByType: "member", + CreatedByID: mustUUID(t, userID), + }) + if err != nil { + t.Fatalf("CreateAutopilot: %v", err) + } + run, err := queries.CreateAutopilotRun(ctx, db.CreateAutopilotRunParams{ + AutopilotID: ap.ID, + Source: "manual", + Status: "issue_created", + }) + if err != nil { + t.Fatalf("CreateAutopilotRun: %v", err) + } + + newTask := func() pgtype.UUID { + var id string + if err := pool.QueryRow(context.Background(), + `INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority) VALUES ($1, $2, 'queued', 0) RETURNING id`, + agentID, runtimeID).Scan(&id); err != nil { + t.Fatalf("insert task: %v", err) + } + return mustUUID(t, id) + } + + bus := events.New() + taskSvc := NewTaskService(queries, pool, nil, bus) + svc := NewAutopilotService(queries, pool, bus, taskSvc) + return svc, queries, pool, run, newTask +} + +// TestBindAutopilotRunTaskIsIdempotentAndExclusive covers MUL-4809 §4.1 P0-1/P0-2: +// the first dispatched task owns the run; re-binding the same task is idempotent, +// a different task is refused, and once the run is terminal the bind reports the +// authoritative terminal run rather than a phantom running state. +func TestBindAutopilotRunTaskIsIdempotentAndExclusive(t *testing.T) { + ctx := context.Background() + svc, queries, _, run, newTask := newBindCASFixture(t) + taskA := newTask() + taskB := newTask() + + // First bind wins. + bound, err := svc.bindAutopilotRunTask(ctx, run.ID, taskA) + if err != nil { + t.Fatalf("first bind: %v", err) + } + if bound.Status != "running" || bound.TaskID.Bytes != taskA.Bytes { + t.Fatalf("after first bind: status=%q task_id=%x", bound.Status, bound.TaskID.Bytes) + } + + // Re-binding the SAME task is idempotent. + again, err := svc.bindAutopilotRunTask(ctx, run.ID, taskA) + if err != nil || again.TaskID.Bytes != taskA.Bytes { + t.Fatalf("idempotent re-bind: err=%v task_id=%x", err, again.TaskID.Bytes) + } + + // A DIFFERENT task must be refused, and the run must still point at A. + if _, err := svc.bindAutopilotRunTask(ctx, run.ID, taskB); err == nil { + t.Fatal("re-bind to a different task should have failed") + } + cur, _ := queries.GetAutopilotRun(ctx, run.ID) + if cur.TaskID.Bytes != taskA.Bytes { + t.Fatalf("run rebound to a different task: task_id=%x", cur.TaskID.Bytes) + } + + // Once the run is terminal, a late bind returns the authoritative terminal + // run (not an error, not a resurrection). + if _, err := queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{ID: run.ID}); err != nil { + t.Fatalf("complete: %v", err) + } + term, err := svc.bindAutopilotRunTask(ctx, run.ID, taskA) + if err != nil { + t.Fatalf("bind after terminal should return the terminal run, got err: %v", err) + } + if term.Status != "completed" { + t.Fatalf("bind after terminal: status=%q, want completed", term.Status) + } +} + +// TestBindAutopilotRunTaskFailsWhenRunGone covers the "cannot confirm authoritative +// state" case (MUL-4809 §4.1 P0-2): if the run row disappears, the bind must +// error rather than report a phantom dispatch success (which would let a +// schedule/webhook caller record the delivery as dispatched). +func TestBindAutopilotRunTaskFailsWhenRunGone(t *testing.T) { + ctx := context.Background() + svc, _, pool, run, newTask := newBindCASFixture(t) + task := newTask() + + if _, err := pool.Exec(ctx, `DELETE FROM autopilot_run WHERE id = $1`, run.ID); err != nil { + t.Fatalf("delete run: %v", err) + } + + if _, err := svc.bindAutopilotRunTask(ctx, run.ID, task); err == nil { + t.Fatal("bind on a missing run must return an error, not a phantom success") + } +} diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index 01594ea1503..ba7cc0a4cb3 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -1740,9 +1740,11 @@ type UpdateAutopilotRunCompletedParams struct { } // Compare-and-set terminal transition (MUL-4809 §4.1): only an in-flight run may -// be completed. Zero rows means another path (a racing task/issue listener, or a -// rolling-deploy old pod) already wrote a terminal state — first writer wins; -// the caller no-ops instead of overwriting the terminal state or re-publishing. +// be completed. Zero rows means the run was already finalized by a concurrent +// finalizer of THIS binary — first writer wins; the caller no-ops instead of +// overwriting the terminal state or re-publishing. (This CAS cannot constrain an +// OLD-version pod still running the unguarded write during a rolling deploy — +// that requires the explicit deploy-enable gate tracked as later work.) func (q *Queries) UpdateAutopilotRunCompleted(ctx context.Context, arg UpdateAutopilotRunCompletedParams) (AutopilotRun, error) { row := q.db.QueryRow(ctx, updateAutopilotRunCompleted, arg.ID, arg.Result) var i AutopilotRun @@ -1843,7 +1845,9 @@ func (q *Queries) UpdateAutopilotRunIssueCreated(ctx context.Context, arg Update const updateAutopilotRunRunning = `-- name: UpdateAutopilotRunRunning :one UPDATE autopilot_run SET status = 'running', task_id = $2 -WHERE id = $1 AND status IN ('pending', 'issue_created', 'running') +WHERE id = $1 + AND status IN ('pending', 'issue_created', 'running') + AND (task_id IS NULL OR task_id = $2) RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id, planned_at, webhook_delivery_id ` @@ -1852,11 +1856,16 @@ type UpdateAutopilotRunRunningParams struct { TaskID pgtype.UUID `json:"task_id"` } -// Compare-and-set bind (MUL-4809 §4.1): only advance a run that is NOT already -// terminal. If the dispatched task finished and finalized the run before the -// bind lands, this matches zero rows (pgx.ErrNoRows) instead of resurrecting a -// completed/failed run back to running. Callers treat zero rows as "already -// finalized" and reload rather than error. +// Compare-and-set bind (MUL-4809 §4.1). Only a non-terminal run whose task_id is +// unset OR already this same task may be bound: +// - task_id IS NULL -> first bind wins. +// - task_id = $2 -> idempotent replay of the same bind (returns the row). +// - task_id = other task -> zero rows: the run is already bound to a DIFFERENT +// dispatched task and must not be re-bound (the first dispatched task owns it). +// - terminal status -> zero rows: never resurrect a finalized run. +// +// Zero rows surfaces as pgx.ErrNoRows; callers reload to establish the +// authoritative state (see bindAutopilotRunTask). func (q *Queries) UpdateAutopilotRunRunning(ctx context.Context, arg UpdateAutopilotRunRunningParams) (AutopilotRun, error) { row := q.db.QueryRow(ctx, updateAutopilotRunRunning, arg.ID, arg.TaskID) var i AutopilotRun diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index 7ee14646f1c..92161c07eab 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -309,21 +309,29 @@ WHERE id = $1 RETURNING *; -- name: UpdateAutopilotRunRunning :one --- Compare-and-set bind (MUL-4809 §4.1): only advance a run that is NOT already --- terminal. If the dispatched task finished and finalized the run before the --- bind lands, this matches zero rows (pgx.ErrNoRows) instead of resurrecting a --- completed/failed run back to running. Callers treat zero rows as "already --- finalized" and reload rather than error. +-- Compare-and-set bind (MUL-4809 §4.1). Only a non-terminal run whose task_id is +-- unset OR already this same task may be bound: +-- - task_id IS NULL -> first bind wins. +-- - task_id = $2 -> idempotent replay of the same bind (returns the row). +-- - task_id = other task -> zero rows: the run is already bound to a DIFFERENT +-- dispatched task and must not be re-bound (the first dispatched task owns it). +-- - terminal status -> zero rows: never resurrect a finalized run. +-- Zero rows surfaces as pgx.ErrNoRows; callers reload to establish the +-- authoritative state (see bindAutopilotRunTask). UPDATE autopilot_run SET status = 'running', task_id = $2 -WHERE id = $1 AND status IN ('pending', 'issue_created', 'running') +WHERE id = $1 + AND status IN ('pending', 'issue_created', 'running') + AND (task_id IS NULL OR task_id = $2) RETURNING *; -- name: UpdateAutopilotRunCompleted :one -- Compare-and-set terminal transition (MUL-4809 §4.1): only an in-flight run may --- be completed. Zero rows means another path (a racing task/issue listener, or a --- rolling-deploy old pod) already wrote a terminal state — first writer wins; --- the caller no-ops instead of overwriting the terminal state or re-publishing. +-- be completed. Zero rows means the run was already finalized by a concurrent +-- finalizer of THIS binary — first writer wins; the caller no-ops instead of +-- overwriting the terminal state or re-publishing. (This CAS cannot constrain an +-- OLD-version pod still running the unguarded write during a rolling deploy — +-- that requires the explicit deploy-enable gate tracked as later work.) UPDATE autopilot_run SET status = 'completed', completed_at = now(), result = sqlc.narg('result') WHERE id = $1 AND status IN ('issue_created', 'running') From ea2511a2295b26dd74882124fe33462f11973dff Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Fri, 17 Jul 2026 18:25:09 +0800 Subject: [PATCH 15/41] =?UTF-8?q?fix(autopilot):=20bind=20must=20check=20t?= =?UTF-8?q?ask=20ownership=20on=20terminal=20CAS=20miss=20(MUL-4809=20?= =?UTF-8?q?=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review P0: bindAutopilotRunTask's terminal-reload branch returned success for ANY terminal run, so `A bind -> A finalizes run -> B bind` reported B's dispatch as landed even though the run belongs to A — a competing dispatch the CAS is meant to reject (run_only would then wake B; create_issue's B is already enqueued). A terminal run is now an idempotent success ONLY when it is unbound (the pre-bind crash-window compatibility, handled explicitly) or already owned by THIS task; a terminal run owned by a DIFFERENT task returns an error, so the caller treats the bind as a dispatch failure rather than a landed one. "terminal" alone is no longer treated as proof that this task landed. Regression extended: `A bind -> terminal -> A bind` succeeds; `A bind -> terminal -> B bind` is refused and the run stays owned by A. Full service + cmd/server autopilot suites unchanged. Co-authored-by: multica-agent --- server/internal/service/autopilot.go | 14 +++++++++++++- .../internal/service/autopilot_bind_cas_test.go | 17 ++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index 918e7ce13be..8e4601f66ea 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -1021,7 +1021,19 @@ func (s *AutopilotService) bindAutopilotRunTask(ctx context.Context, runID, task return db.AutopilotRun{}, fmt.Errorf("bind autopilot run %s: reload after CAS miss: %w", util.UUIDToString(runID), rerr) } if isAutopilotRunTerminalStatus(current.Status) { - return current, nil // a racing task already finalized this run + // A racing finalizer already ended the run. This is a legitimate idempotent + // success ONLY if this same task owns the run, or the run has no owning task + // yet (the pre-bind crash-window compatibility: it finalized via the + // issue-scoped fallback before any bind, so this dispatched task IS its + // work). A terminal run already owned by a DIFFERENT task means this is a + // competing dispatch — it must NOT be reported as landed, or the caller + // would wake / treat a second task as the run's work. "terminal" alone is + // not proof that THIS task landed. + if !current.TaskID.Valid || current.TaskID.Bytes == taskID.Bytes { + return current, nil + } + return db.AutopilotRun{}, fmt.Errorf("bind autopilot run %s: run is terminal (%s) owned by a different task, refusing to report bind of %s as dispatched", + util.UUIDToString(runID), current.Status, util.UUIDToString(taskID)) } // Active but the CAS excluded it: the run is bound to a different task. return db.AutopilotRun{}, fmt.Errorf("bind autopilot run %s: run is %s bound to a different task, refusing to rebind to %s", diff --git a/server/internal/service/autopilot_bind_cas_test.go b/server/internal/service/autopilot_bind_cas_test.go index 27a9d6723dd..ca8fc834c23 100644 --- a/server/internal/service/autopilot_bind_cas_test.go +++ b/server/internal/service/autopilot_bind_cas_test.go @@ -131,18 +131,29 @@ func TestBindAutopilotRunTaskIsIdempotentAndExclusive(t *testing.T) { t.Fatalf("run rebound to a different task: task_id=%x", cur.TaskID.Bytes) } - // Once the run is terminal, a late bind returns the authoritative terminal - // run (not an error, not a resurrection). + // Once the run is terminal AND owned by A, a late bind by the SAME task A + // returns the authoritative terminal run (idempotent, not a resurrection). if _, err := queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{ID: run.ID}); err != nil { t.Fatalf("complete: %v", err) } term, err := svc.bindAutopilotRunTask(ctx, run.ID, taskA) if err != nil { - t.Fatalf("bind after terminal should return the terminal run, got err: %v", err) + t.Fatalf("bind-after-terminal by the owning task should succeed, got err: %v", err) } if term.Status != "completed" { t.Fatalf("bind after terminal: status=%q, want completed", term.Status) } + + // But a DIFFERENT task B binding after A already finalized the run must be + // refused — otherwise B's competing dispatch would be reported as landed. The + // run must still be owned by A. + if _, err := svc.bindAutopilotRunTask(ctx, run.ID, taskB); err == nil { + t.Fatal("bind-after-terminal by a different task must fail, not be reported as dispatched") + } + owner, _ := queries.GetAutopilotRun(ctx, run.ID) + if owner.TaskID.Bytes != taskA.Bytes || owner.Status != "completed" { + t.Fatalf("terminal run ownership changed: status=%q task_id=%x", owner.Status, owner.TaskID.Bytes) + } } // TestBindAutopilotRunTaskFailsWhenRunGone covers the "cannot confirm authoritative From af489ea039ac9bd2ee3df81ba0493cdf70eba32a Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 14:00:12 +0800 Subject: [PATCH 16/41] =?UTF-8?q?fix(autopilot):=20precise=20crash-window?= =?UTF-8?q?=20bind=20repair=20for=20unbound=20create=5Fissue=20runs=20(MUL?= =?UTF-8?q?-4809=20=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server/internal/service/autopilot.go | 114 ++++++++--- .../autopilot_create_issue_sync_test.go | 183 ++++++++++++++++++ server/pkg/db/generated/autopilot.sql.go | 80 ++++++++ server/pkg/db/queries/autopilot.sql | 18 ++ 4 files changed, 365 insertions(+), 30 deletions(-) create mode 100644 server/internal/service/autopilot_create_issue_sync_test.go diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index 8e4601f66ea..a1371f5de82 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -1040,6 +1040,58 @@ func (s *AutopilotService) bindAutopilotRunTask(ctx context.Context, runID, task util.UUIDToString(runID), current.Status, util.UUIDToString(taskID)) } +// autopilotBindRepairWindow bounds how long after issue creation a task may have +// been queued and still count as the run's dispatched task during crash-window +// repair. The autopilot enqueues its task in the same call that creates the issue, +// so the real dispatched task lands within milliseconds; the window only has to +// absorb dispatch latency while staying far shorter than the gap before any +// human/agent could comment and spawn an unrelated task on the same issue. +const autopilotBindRepairWindow = 15 * time.Minute + +// repairUnboundCreateIssueRun binds an unbound create_issue run to the task it +// actually dispatched and returns the bound run. The run's task_id was never set +// (a crash between enqueue and bind, or a run dispatched by a pre-§4.1 pod), so the +// dispatched task is recovered precisely — the first root task queued for the issue +// by the run's dispatch target within the bind window (MUL-4809 §4.1 item 4). ok is +// false when the run cannot be safely repaired; the caller must not finalize it then. +func (s *AutopilotService) repairUnboundCreateIssueRun(ctx context.Context, run db.AutopilotRun) (db.AutopilotRun, bool) { + if !run.IssueID.Valid { + return db.AutopilotRun{}, false + } + issue, err := s.Queries.GetIssue(ctx, run.IssueID) + if err != nil { + slog.Warn("autopilot: failed to load issue for bind repair", + "issue_id", util.UUIDToString(run.IssueID), "error", err) + return db.AutopilotRun{}, false + } + // Autopilot-created issues are authored by the dispatched agent (the resolved + // leader), so the dispatched task's agent_id equals the issue creator. A + // different shape isn't a create_issue dispatch this repair can attribute. + if issue.CreatorType != "agent" || !issue.CreatorID.Valid || !issue.CreatedAt.Valid { + return db.AutopilotRun{}, false + } + dispatched, err := s.Queries.FindAutopilotDispatchedTaskForIssue(ctx, db.FindAutopilotDispatchedTaskForIssueParams{ + IssueID: run.IssueID, + AgentID: issue.CreatorID, + CreatedAt: pgtype.Timestamptz{Time: issue.CreatedAt.Time.Add(autopilotBindRepairWindow), Valid: true}, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return db.AutopilotRun{}, false // no dispatched task in the window — don't finalize off a stray task + } + slog.Warn("autopilot: failed to find dispatched task for bind repair", + "issue_id", util.UUIDToString(run.IssueID), "error", err) + return db.AutopilotRun{}, false + } + bound, err := s.bindAutopilotRunTask(ctx, run.ID, dispatched.ID) + if err != nil { + slog.Warn("autopilot: bind repair failed", + "run_id", util.UUIDToString(run.ID), "task_id", util.UUIDToString(dispatched.ID), "error", err) + return db.AutopilotRun{}, false + } + return bound, true +} + // retryRootTaskID walks the retry_of_task_id chain up from task to the first // attempt (the root). System retries form a short linear chain bounded by // max_attempts, so this is a handful of point lookups. @@ -1079,40 +1131,42 @@ func (s *AutopilotService) SyncRunFromCreateIssueTask(ctx context.Context, task return // no in-flight run linked to this issue (covers ordinary issue/chat tasks) } - if run.TaskID.Valid { - root, rerr := s.retryRootTaskID(ctx, task) - if rerr != nil { - slog.Warn("autopilot: failed to resolve task retry root", - "task_id", util.UUIDToString(task.ID), "error", rerr) + // When the run's task_id was never bound — a crash between enqueue and bind, or + // a run dispatched by a pre-§4.1 pod mid rolling-deploy — repair it before + // matching. The repair binds the run to the task it actually dispatched (the + // first root task the autopilot queued for the issue), so a later comment/chat + // task on the same issue can never be misattributed as the run's dispatched work + // and finalize the run off the wrong outcome (MUL-4809 §4.1 item 4). + if !run.TaskID.Valid { + repaired, ok := s.repairUnboundCreateIssueRun(ctx, run) + if !ok { return } - if root.Bytes != run.TaskID.Bytes { - return // not this run's dispatched-task lineage (e.g. a comment task) - } - // A still-queued system retry means another attempt is already in flight - // (FailTask enqueues it before broadcasting the failure) — wait for it. - if task.Status != "completed" { - pending, perr := s.Queries.HasPendingRetryForTask(ctx, task.ID) - if perr != nil { - slog.Warn("autopilot: failed to check pending retry", - "task_id", util.UUIDToString(task.ID), "error", perr) - return - } - if pending { - return - } - } - } else { - // Crash/rolling-deploy fallback: the run's task_id was never bound. Finalize - // only when no task is still active for the issue, mirroring the previous - // issue-scoped behavior so the run doesn't hang. - hasActive, herr := s.Queries.HasActiveTaskForIssue(ctx, task.IssueID) - if herr != nil { - slog.Warn("autopilot: failed to check active tasks for unbound run", - "issue_id", util.UUIDToString(task.IssueID), "error", herr) + run = repaired + } + + // Match the terminal task to the run's dispatched-task lineage: run.task_id plus + // the retry_of_task_id chain. A task outside that lineage (e.g. a comment task) + // leaves the run untouched. + root, rerr := s.retryRootTaskID(ctx, task) + if rerr != nil { + slog.Warn("autopilot: failed to resolve task retry root", + "task_id", util.UUIDToString(task.ID), "error", rerr) + return + } + if root.Bytes != run.TaskID.Bytes { + return // not this run's dispatched-task lineage (e.g. a comment task) + } + // A still-queued system retry means another attempt is already in flight + // (FailTask enqueues it before broadcasting the failure) — wait for it. + if task.Status != "completed" { + pending, perr := s.Queries.HasPendingRetryForTask(ctx, task.ID) + if perr != nil { + slog.Warn("autopilot: failed to check pending retry", + "task_id", util.UUIDToString(task.ID), "error", perr) return } - if hasActive { + if pending { return } } diff --git a/server/internal/service/autopilot_create_issue_sync_test.go b/server/internal/service/autopilot_create_issue_sync_test.go new file mode 100644 index 00000000000..72a00d923da --- /dev/null +++ b/server/internal/service/autopilot_create_issue_sync_test.go @@ -0,0 +1,183 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// newCreateIssueRunFixture builds a create_issue autopilot whose run is linked to +// an agent-authored issue but left UNBOUND (task_id NULL) — the crash-window state +// SyncRunFromCreateIssueTask must repair (MUL-4809 §4.1 item 4). It returns the +// service, the dispatch agent id, the run, and a helper that inserts a task on the +// issue with a chosen agent and created_at offset from issue creation. +func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.AutopilotRun, func(agent string, offset time.Duration, status string) db.AgentTaskQueue) { + t.Helper() + ctx := context.Background() + pool := newTaskClaimRacePool(t) + queries := db.New(pool) + + suffix := time.Now().UnixNano() + var userID, workspaceID, runtimeID, agentID string + if err := pool.QueryRow(ctx, `INSERT INTO "user" (name, email) VALUES ('ci run', $1) RETURNING id`, + fmt.Sprintf("ci-run-%d@multica.ai", suffix)).Scan(&userID); err != nil { + t.Fatalf("create user: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO workspace (name, slug, description, issue_prefix) VALUES ('ci run', $1, '', 'CIR') RETURNING id`, + fmt.Sprintf("ci-run-%d", suffix)).Scan(&workspaceID); err != nil { + t.Fatalf("create workspace: %v", err) + } + t.Cleanup(func() { + pool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, workspaceID) + pool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID) + }) + if err := pool.QueryRow(ctx, ` + INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, visibility, owner_id) + VALUES ($1, NULL, 'ci run rt', 'cloud', 'ci_run', 'online', 'rt', '{}'::jsonb, now(), 'private', $2) RETURNING id + `, workspaceID, userID).Scan(&runtimeID); err != nil { + t.Fatalf("create runtime: %v", err) + } + if err := pool.QueryRow(ctx, ` + INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id) + VALUES ($1, 'ci run agent', '', 'cloud', '{}'::jsonb, $2, 'private', 1, $3) RETURNING id + `, workspaceID, runtimeID, userID).Scan(&agentID); err != nil { + t.Fatalf("create agent: %v", err) + } + + ap, err := queries.CreateAutopilot(ctx, db.CreateAutopilotParams{ + WorkspaceID: mustUUID(t, workspaceID), + Title: "ci run", + AssigneeType: "agent", + AssigneeID: mustUUID(t, agentID), + Status: "active", + ExecutionMode: "create_issue", + CreatedByType: "member", + CreatedByID: mustUUID(t, userID), + }) + if err != nil { + t.Fatalf("CreateAutopilot: %v", err) + } + + // Issue authored by the dispatch agent — dispatchCreateIssue sets the issue + // creator to the resolved leader, so the repair attributes the dispatched task + // by that creator. + var issueIDStr string + if err := pool.QueryRow(ctx, ` + INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number, origin_type, origin_id) + VALUES ($1, 'ci run issue', 'todo', 'none', 'agent', $2, + COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1, 'autopilot', $3) + RETURNING id::text + `, workspaceID, agentID, util.UUIDToString(ap.ID)).Scan(&issueIDStr); err != nil { + t.Fatalf("create issue: %v", err) + } + issueUUID := mustUUID(t, issueIDStr) + + run, err := queries.CreateAutopilotRun(ctx, db.CreateAutopilotRunParams{ + AutopilotID: ap.ID, + Source: "manual", + Status: "issue_created", + }) + if err != nil { + t.Fatalf("CreateAutopilotRun: %v", err) + } + run, err = queries.UpdateAutopilotRunIssueCreated(ctx, db.UpdateAutopilotRunIssueCreatedParams{ + ID: run.ID, + IssueID: issueUUID, + }) + if err != nil { + t.Fatalf("link run to issue: %v", err) + } + + var issueCreatedAt time.Time + if err := pool.QueryRow(ctx, `SELECT created_at FROM issue WHERE id = $1`, issueIDStr).Scan(&issueCreatedAt); err != nil { + t.Fatalf("read issue created_at: %v", err) + } + + insertTask := func(agent string, offset time.Duration, status string) db.AgentTaskQueue { + var id string + if err := pool.QueryRow(context.Background(), + `INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, created_at) VALUES ($1, $2, $3, $4, 0, $5) RETURNING id`, + agent, runtimeID, issueIDStr, status, issueCreatedAt.Add(offset)).Scan(&id); err != nil { + t.Fatalf("insert task: %v", err) + } + task, err := queries.GetAgentTask(context.Background(), mustUUID(t, id)) + if err != nil { + t.Fatalf("get task: %v", err) + } + return task + } + + bus := events.New() + taskSvc := NewTaskService(queries, pool, nil, bus) + svc := NewAutopilotService(queries, pool, bus, taskSvc) + return svc, agentID, run, insertTask +} + +func isTerminalRunStatus(status string) bool { + return status == "completed" || status == "failed" || status == "skipped" +} + +// TestSyncRunFromCreateIssueTaskRepairsUnboundRunPrecisely covers MUL-4809 §4.1 +// item 4: when a run's task_id was never bound (a crash between enqueue and bind), +// finalization must attribute the run to the FIRST dispatched task only. A later +// comment task on the same issue must not finalize the run — instead the repair +// binds the run to the real dispatched task, and only that task finalizes it. +func TestSyncRunFromCreateIssueTaskRepairsUnboundRunPrecisely(t *testing.T) { + ctx := context.Background() + svc, agentID, run, insertTask := newCreateIssueRunFixture(t) + + dispatched := insertTask(agentID, 0, "completed") // the autopilot's own task + comment := insertTask(agentID, 30*time.Second, "completed") // a later comment task on the same issue + + // The comment task finishing first must NOT finalize the run, but repair must + // still bind the run to the real dispatched task. + svc.SyncRunFromCreateIssueTask(ctx, comment) + after, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if isTerminalRunStatus(after.Status) { + t.Fatalf("run finalized off a comment task: status=%q", after.Status) + } + if !after.TaskID.Valid || after.TaskID.Bytes != dispatched.ID.Bytes { + t.Fatalf("repair did not bind the dispatched task: task_id valid=%v", after.TaskID.Valid) + } + + // The dispatched task completing then finalizes the run. + svc.SyncRunFromCreateIssueTask(ctx, dispatched) + final, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if final.Status != "completed" { + t.Fatalf("dispatched task did not finalize the run: status=%q", final.Status) + } +} + +// TestSyncRunFromCreateIssueTaskIgnoresTaskOutsideBindWindow covers the crash-window +// guard: when the run's task_id was never bound and the only task on the issue was +// queued long after issue creation (so the real dispatched task never existed — an +// enqueue crash), a stray terminal task must NOT finalize the run. +func TestSyncRunFromCreateIssueTaskIgnoresTaskOutsideBindWindow(t *testing.T) { + ctx := context.Background() + svc, agentID, run, insertTask := newCreateIssueRunFixture(t) + + stray := insertTask(agentID, autopilotBindRepairWindow+time.Minute, "completed") + + svc.SyncRunFromCreateIssueTask(ctx, stray) + after, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if isTerminalRunStatus(after.Status) { + t.Fatalf("run finalized off a task outside the bind window: status=%q", after.Status) + } + if after.TaskID.Valid { + t.Fatalf("run bound to a task outside the bind window: task_id=%x", after.TaskID.Bytes) + } +} diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index ba7cc0a4cb3..dcffc192928 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -515,6 +515,86 @@ func (q *Queries) FailAutopilotRunsByIssue(ctx context.Context, issueID pgtype.U return err } +const findAutopilotDispatchedTaskForIssue = `-- name: FindAutopilotDispatchedTaskForIssue :one +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +WHERE issue_id = $1 + AND agent_id = $2 + AND retry_of_task_id IS NULL + AND created_at <= $3 +ORDER BY created_at ASC, id ASC +LIMIT 1 +` + +type FindAutopilotDispatchedTaskForIssueParams struct { + IssueID pgtype.UUID `json:"issue_id"` + AgentID pgtype.UUID `json:"agent_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +// Recovers the task a create_issue run dispatched when the run's task_id was never +// bound — a crash between enqueue and bind, or a run dispatched by a pre-§4.1 pod +// mid rolling-deploy (MUL-4809 §4.1 item 4). The dispatched task is the FIRST root +// attempt (retry_of_task_id IS NULL) queued for the autopilot's issue, by the run's +// dispatch target (agent_id), within the bind crash window after issue creation. +// Ordinary comment/chat tasks on the same issue are queued later, by a different +// agent, or outside the window, so none of them matches — that is what stops a +// stray task from being misattributed as the run's dispatched work and finalizing +// the run off the wrong outcome. +func (q *Queries) FindAutopilotDispatchedTaskForIssue(ctx context.Context, arg FindAutopilotDispatchedTaskForIssueParams) (AgentTaskQueue, error) { + row := q.db.QueryRow(ctx, findAutopilotDispatchedTaskForIssue, arg.IssueID, arg.AgentID, arg.CreatedAt) + var i AgentTaskQueue + err := row.Scan( + &i.ID, + &i.AgentID, + &i.IssueID, + &i.Status, + &i.Priority, + &i.DispatchedAt, + &i.StartedAt, + &i.CompletedAt, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.Context, + &i.RuntimeID, + &i.SessionID, + &i.WorkDir, + &i.TriggerCommentID, + &i.ChatSessionID, + &i.AutopilotRunID, + &i.Attempt, + &i.MaxAttempts, + &i.ParentTaskID, + &i.FailureReason, + &i.TriggerSummary, + &i.ForceFreshSession, + &i.IsLeaderTask, + &i.WaitReason, + &i.InitiatorUserID, + &i.HandoffNote, + &i.PrepareLeaseExpiresAt, + &i.SquadID, + &i.RuntimeMcpOverlay, + &i.EscalationForTaskID, + &i.FireAt, + &i.OriginatorUserID, + &i.RuntimeConnectedApps, + &i.CoalescedCommentIds, + &i.DeliveredCommentIds, + &i.ChatInputTaskID, + &i.ChatFinalizeDeferredAt, + &i.OriginatorSource, + &i.DelegatedFromTaskID, + &i.RetryOfTaskID, + &i.RerunOfTaskID, + &i.RuleVersionID, + &i.TriggerEvidenceKind, + &i.TriggerEvidenceRefID, + &i.AccountableUserID, + ) + return i, err +} + const getActiveAutopilotRuleVersion = `-- name: GetActiveAutopilotRuleVersion :one SELECT id, autopilot_id, workspace_id, published_by_type, published_by_id, config_summary, created_at FROM autopilot_rule_version WHERE workspace_id = $1 AND autopilot_id = $2 diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index 92161c07eab..7a4d620c743 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -471,6 +471,24 @@ SELECT count(*) > 0 AS has_pending FROM agent_task_queue WHERE retry_of_task_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory'); +-- name: FindAutopilotDispatchedTaskForIssue :one +-- Recovers the task a create_issue run dispatched when the run's task_id was never +-- bound — a crash between enqueue and bind, or a run dispatched by a pre-§4.1 pod +-- mid rolling-deploy (MUL-4809 §4.1 item 4). The dispatched task is the FIRST root +-- attempt (retry_of_task_id IS NULL) queued for the autopilot's issue, by the run's +-- dispatch target (agent_id), within the bind crash window after issue creation. +-- Ordinary comment/chat tasks on the same issue are queued later, by a different +-- agent, or outside the window, so none of them matches — that is what stops a +-- stray task from being misattributed as the run's dispatched work and finalizing +-- the run off the wrong outcome. +SELECT * FROM agent_task_queue +WHERE issue_id = $1 + AND agent_id = $2 + AND retry_of_task_id IS NULL + AND created_at <= $3 +ORDER BY created_at ASC, id ASC +LIMIT 1; + -- name: FailAutopilotRunsByIssue :exec -- Fails active autopilot runs linked to a given issue. -- Must be called BEFORE issue deletion (ON DELETE SET NULL clears issue_id). From 4fbee574f4905d871a859b4e2a20c8789c36a815 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 14:03:47 +0800 Subject: [PATCH 17/41] =?UTF-8?q?feat(autopilot):=20traceable=20cancelled?= =?UTF-8?q?=20reason=20+=20=C2=A79.2=20acceptance=20tests=20(MUL-4809=20?= =?UTF-8?q?=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server/internal/service/autopilot.go | 6 + .../autopilot_create_issue_sync_test.go | 157 +++++++++++++++++- 2 files changed, 157 insertions(+), 6 deletions(-) diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index a1371f5de82..bbdd554d885 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -1272,6 +1272,12 @@ func taskFailureReasonForAutopilotRun(task db.AgentTaskQueue) string { if task.FailureReason.Valid && strings.TrimSpace(task.FailureReason.String) != "" { return task.FailureReason.String } + // A cancelled task carries no error text of its own; keep the run's failure + // reason traceable to the cancellation rather than a generic "failed" + // (MUL-4809 §9.2 — task cancelled run reason must be attributable). + if task.Status == "cancelled" { + return "task cancelled" + } return "task failed" } diff --git a/server/internal/service/autopilot_create_issue_sync_test.go b/server/internal/service/autopilot_create_issue_sync_test.go index 72a00d923da..19b7734efc9 100644 --- a/server/internal/service/autopilot_create_issue_sync_test.go +++ b/server/internal/service/autopilot_create_issue_sync_test.go @@ -3,9 +3,11 @@ package service import ( "context" "fmt" + "strings" "testing" "time" + "github.com/jackc/pgx/v5/pgxpool" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" @@ -14,9 +16,9 @@ import ( // newCreateIssueRunFixture builds a create_issue autopilot whose run is linked to // an agent-authored issue but left UNBOUND (task_id NULL) — the crash-window state // SyncRunFromCreateIssueTask must repair (MUL-4809 §4.1 item 4). It returns the -// service, the dispatch agent id, the run, and a helper that inserts a task on the -// issue with a chosen agent and created_at offset from issue creation. -func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.AutopilotRun, func(agent string, offset time.Duration, status string) db.AgentTaskQueue) { +// service, the dispatch agent id, the run, the pool, and a helper that inserts a +// task on the issue with a chosen agent and created_at offset from issue creation. +func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.AutopilotRun, *pgxpool.Pool, func(agent string, offset time.Duration, status string) db.AgentTaskQueue) { t.Helper() ctx := context.Background() pool := newTaskClaimRacePool(t) @@ -115,7 +117,7 @@ func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.Autop bus := events.New() taskSvc := NewTaskService(queries, pool, nil, bus) svc := NewAutopilotService(queries, pool, bus, taskSvc) - return svc, agentID, run, insertTask + return svc, agentID, run, pool, insertTask } func isTerminalRunStatus(status string) bool { @@ -129,7 +131,7 @@ func isTerminalRunStatus(status string) bool { // binds the run to the real dispatched task, and only that task finalizes it. func TestSyncRunFromCreateIssueTaskRepairsUnboundRunPrecisely(t *testing.T) { ctx := context.Background() - svc, agentID, run, insertTask := newCreateIssueRunFixture(t) + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) dispatched := insertTask(agentID, 0, "completed") // the autopilot's own task comment := insertTask(agentID, 30*time.Second, "completed") // a later comment task on the same issue @@ -165,7 +167,7 @@ func TestSyncRunFromCreateIssueTaskRepairsUnboundRunPrecisely(t *testing.T) { // enqueue crash), a stray terminal task must NOT finalize the run. func TestSyncRunFromCreateIssueTaskIgnoresTaskOutsideBindWindow(t *testing.T) { ctx := context.Background() - svc, agentID, run, insertTask := newCreateIssueRunFixture(t) + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) stray := insertTask(agentID, autopilotBindRepairWindow+time.Minute, "completed") @@ -181,3 +183,146 @@ func TestSyncRunFromCreateIssueTaskIgnoresTaskOutsideBindWindow(t *testing.T) { t.Fatalf("run bound to a task outside the bind window: task_id=%x", after.TaskID.Bytes) } } + +// TestSyncRunFromCreateIssueTaskCompletesRegardlessOfIssueStatus covers MUL-4809 +// §4.1 / §9.2: the run is finalized purely by task outcome. Even when the agent +// leaves the issue in an in_progress-category status (In Review), the completed +// task must complete the run, and finalizing the run must not touch issue status. +func TestSyncRunFromCreateIssueTaskCompletesRegardlessOfIssueStatus(t *testing.T) { + ctx := context.Background() + svc, agentID, run, pool, insertTask := newCreateIssueRunFixture(t) + + if _, err := pool.Exec(ctx, `UPDATE issue SET status = 'in_review' WHERE id = $1`, run.IssueID); err != nil { + t.Fatalf("set issue in_review: %v", err) + } + + dispatched := insertTask(agentID, 0, "completed") + svc.SyncRunFromCreateIssueTask(ctx, dispatched) + + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "completed" { + t.Fatalf("run did not complete while issue was in_review: status=%q", got.Status) + } + var issueStatus string + if err := pool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, run.IssueID).Scan(&issueStatus); err != nil { + t.Fatalf("read issue status: %v", err) + } + if issueStatus != "in_review" { + t.Fatalf("run finalization changed issue status to %q", issueStatus) + } +} + +// TestSyncRunFromCreateIssueTaskCancelledReasonTraceable covers MUL-4809 §9.2: a +// cancelled dispatched task fails the run with a reason that names the cancellation +// rather than a generic "task failed". +func TestSyncRunFromCreateIssueTaskCancelledReasonTraceable(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) + + dispatched := insertTask(agentID, 0, "cancelled") + svc.SyncRunFromCreateIssueTask(ctx, dispatched) + + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "failed" { + t.Fatalf("cancelled task did not fail the run: status=%q", got.Status) + } + if !got.FailureReason.Valid || !strings.Contains(strings.ToLower(got.FailureReason.String), "cancel") { + t.Fatalf("cancelled run reason not traceable: %q", got.FailureReason.String) + } +} + +// TestSyncRunFromCreateIssueTaskDoesNotRewriteTerminalRun covers MUL-4809 §9.2: +// once a run has finalized, a later comment task on the same issue must not +// resurrect or rewrite it — GetAutopilotRunByIssue excludes terminal runs, so the +// sync is a no-op. +func TestSyncRunFromCreateIssueTaskDoesNotRewriteTerminalRun(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) + + dispatched := insertTask(agentID, 0, "completed") + svc.SyncRunFromCreateIssueTask(ctx, dispatched) + done, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if done.Status != "completed" { + t.Fatalf("run did not complete: status=%q", done.Status) + } + + comment := insertTask(agentID, time.Minute, "failed") + svc.SyncRunFromCreateIssueTask(ctx, comment) + after, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if after.Status != "completed" { + t.Fatalf("comment task rewrote a terminal run: status=%q", after.Status) + } + if after.TaskID.Bytes != done.TaskID.Bytes { + t.Fatal("comment task rebound a terminal run's task_id") + } +} + +// TestSyncRunFromCreateIssueTaskRetryKeepsRunRunningUntilFinal covers MUL-4809 §4.1 +// item 3 / §9.2: a retryable failure with a system retry already queued must NOT +// fail the run; only the final terminal attempt (no further retry) does — so +// failure-rate auto-pause stays accurate. +func TestSyncRunFromCreateIssueTaskRetryKeepsRunRunningUntilFinal(t *testing.T) { + ctx := context.Background() + svc, agentID, run, pool, insertTask := newCreateIssueRunFixture(t) + + // Bind the run to its dispatched task, as dispatchCreateIssue does. + dispatched := insertTask(agentID, 0, "running") + bound, err := svc.bindAutopilotRunTask(ctx, run.ID, dispatched.ID) + if err != nil { + t.Fatalf("bind: %v", err) + } + if bound.Status != "running" { + t.Fatalf("bind did not move run to running: %q", bound.Status) + } + + // The dispatched attempt fails, but a system retry is already queued (FailTask + // enqueues the retry before broadcasting the failure). The run must stay open. + var retryID string + if err := pool.QueryRow(ctx, + `INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, retry_of_task_id, created_at) + VALUES ($1, $2, $3, 'queued', 0, $4, now()) RETURNING id`, + dispatched.AgentID, dispatched.RuntimeID, dispatched.IssueID, dispatched.ID).Scan(&retryID); err != nil { + t.Fatalf("insert retry: %v", err) + } + if _, err := pool.Exec(ctx, `UPDATE agent_task_queue SET status = 'failed' WHERE id = $1`, dispatched.ID); err != nil { + t.Fatalf("fail dispatched: %v", err) + } + dispatched.Status = "failed" + svc.SyncRunFromCreateIssueTask(ctx, dispatched) + stillRunning, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if stillRunning.Status != "running" { + t.Fatalf("run finalized while a retry was pending: status=%q", stillRunning.Status) + } + + // The retry fails with no further attempt queued — now the run fails. + if _, err := pool.Exec(ctx, `UPDATE agent_task_queue SET status = 'failed' WHERE id = $1`, retryID); err != nil { + t.Fatalf("fail retry: %v", err) + } + retryTask, err := svc.Queries.GetAgentTask(ctx, mustUUID(t, retryID)) + if err != nil { + t.Fatalf("get retry task: %v", err) + } + svc.SyncRunFromCreateIssueTask(ctx, retryTask) + final, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if final.Status != "failed" { + t.Fatalf("run did not fail after the final retry failed: status=%q", final.Status) + } +} From 4b75d1caeac53530e4da090e5061a97053df3699 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 14:09:58 +0800 Subject: [PATCH 18/41] =?UTF-8?q?refactor(issue-status):=20task=5Ffailed?= =?UTF-8?q?=20dismiss=20keys=20off=20terminal=20Category,=20not=20in=5Frev?= =?UTF-8?q?iew=20(MUL-4809=20=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server/cmd/server/notification_listeners.go | 58 +++++++++---------- .../cmd/server/notification_listeners_test.go | 55 +++++++++++++++--- server/internal/issuestatus/resolver.go | 25 ++++++++ server/internal/issuestatus/resolver_test.go | 43 ++++++++++++++ 4 files changed, 145 insertions(+), 36 deletions(-) diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 5761ee7a1a8..bfcdef3fb41 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -8,6 +8,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" + "github.com/multica-ai/multica/server/internal/issuestatus" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" @@ -19,7 +20,6 @@ type mention struct { ID string // user_id, agent_id, issue_id, or "all" } - // statusLabels maps DB status values to human-readable labels for notifications. var statusLabels = map[string]string{ "backlog": "Backlog", @@ -78,19 +78,19 @@ var parentBubbleNotifTypes = map[string]bool{ // notifTypeToGroup maps each InboxItemType to a user-configurable preference // group. Types not in this map are always delivered (not configurable). var notifTypeToGroup = map[string]string{ - "issue_assigned": "assignments", - "unassigned": "assignments", - "assignee_changed": "assignments", - "status_changed": "status_changes", - "new_comment": "comments", - "mentioned": "comments", - "priority_changed": "updates", + "issue_assigned": "assignments", + "unassigned": "assignments", + "assignee_changed": "assignments", + "status_changed": "status_changes", + "new_comment": "comments", + "mentioned": "comments", + "priority_changed": "updates", "start_date_changed": "updates", - "due_date_changed": "updates", - "task_completed": "agent_activity", - "task_failed": "agent_activity", - "agent_blocked": "agent_activity", - "agent_completed": "agent_activity", + "due_date_changed": "updates", + "task_completed": "agent_activity", + "task_failed": "agent_activity", + "agent_blocked": "agent_activity", + "agent_completed": "agent_activity", } // isNotifMuted returns true if the given notification type is muted for a user @@ -140,17 +140,17 @@ func loadUserPrefs( return result } -// terminalStatusForTaskFailedDismiss is the set of issue statuses that mark -// the issue as "the user no longer needs to triage past failures." When a -// status change lands on one of these, any pre-existing task_failed inbox -// rows for the issue are archived so the inbox stays a fresh-signal surface. -// `in_review` is included because in Multica's agent flow that's the most -// reliable "work delivered" handoff — and a status flip back to in_progress -// will simply produce new task_failed rows that surface normally. -var terminalStatusForTaskFailedDismiss = map[string]bool{ - "in_review": true, - "done": true, - "cancelled": true, +// issueStatusDismissesTaskFailed reports whether landing on this issue status +// should archive the issue's stale task_failed inbox rows so the inbox stays a +// fresh-signal surface. The trigger is the terminal Category — done or cancelled — +// where the work is finished or abandoned and past failures are no longer +// actionable. Machine logic keys off Category, not the display token (MUL-4809 +// §4.2): in_review and blocked are ordinary in_progress states and no longer +// dismiss failures, while a custom done/cancelled status dismisses them the same +// as the built-ins. A later flip back to an active status simply produces new +// task_failed rows that surface normally. +func issueStatusDismissesTaskFailed(status string) bool { + return issuestatus.IsTerminalCategory(issuestatus.CategoryForStatusToken(status)) } // archiveStaleTaskFailedInbox archives all task_failed inbox rows for the @@ -663,11 +663,11 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { issue.Title, "", statusDetails) - // When the issue progresses past the failure (in_review / done / - // cancelled), retire any stale task_failed inbox rows so the - // inbox reflects the current state of the work, not its history. - // The activity log keeps the full failure history for audit. - if terminalStatusForTaskFailedDismiss[issue.Status] { + // When the issue reaches a terminal Category (done / cancelled), + // retire any stale task_failed inbox rows so the inbox reflects the + // current state of the work, not its history. The activity log keeps + // the full failure history for audit. + if issueStatusDismissesTaskFailed(issue.Status) { archiveStaleTaskFailedInbox(ctx, queries, bus, e.WorkspaceID, issue.ID) } } diff --git a/server/cmd/server/notification_listeners_test.go b/server/cmd/server/notification_listeners_test.go index 7267bbddc6b..9a13478f758 100644 --- a/server/cmd/server/notification_listeners_test.go +++ b/server/cmd/server/notification_listeners_test.go @@ -1132,10 +1132,10 @@ func publishStatusChange(bus *events.Bus, issueID, newStatus, prevStatus string) } // TestNotification_StatusChange_ArchivesStaleTaskFailed verifies that when an -// issue transitions into a terminal status (in_review/done/cancelled), any -// existing task_failed inbox rows for that issue are archived for every -// affected member recipient, an inbox:batch-archived event fires per -// recipient, and sibling notifications on the same issue are untouched. +// issue reaches a terminal Category (done/cancelled), any existing task_failed +// inbox rows for that issue are archived for every affected member recipient, an +// inbox:batch-archived event fires per recipient, and sibling notifications on the +// same issue are untouched. func TestNotification_StatusChange_ArchivesStaleTaskFailed(t *testing.T) { queries := db.New(testPool) bus := newNotificationBus(t, queries) @@ -1193,7 +1193,7 @@ func TestNotification_StatusChange_ArchivesStaleTaskFailed(t *testing.T) { batchArchived = append(batchArchived, e) }) - publishStatusChange(bus, issueID, "in_review", "in_progress") + publishStatusChange(bus, issueID, "done", "in_progress") // task_failed rows are archived for both recipients. for _, recipient := range []string{testUserID, subID} { @@ -1280,6 +1280,47 @@ func TestNotification_StatusChange_NonTerminalKeepsTaskFailed(t *testing.T) { } } +// TestNotification_StatusChange_InReviewKeepsTaskFailed verifies MUL-4809 §4.2: +// in_review is an in_progress-Category status, not a terminal one, so moving an +// issue to in_review must NOT archive its task_failed inbox rows. This is the +// removal of the old in_review special-case — dismissal now keys off the terminal +// Category (done/cancelled) alone. +func TestNotification_StatusChange_InReviewKeepsTaskFailed(t *testing.T) { + queries := db.New(testPool) + bus := newNotificationBus(t, queries) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupInboxForIssue(t, issueID) + cleanupTestIssue(t, issueID) + }) + + addTestSubscriber(t, issueID, "member", testUserID, "creator") + + bus.Publish(events.Event{ + Type: protocol.EventTaskFailed, + WorkspaceID: testWorkspaceID, + ActorType: "system", + Payload: map[string]any{ + "task_id": "00000000-0000-0000-0000-bbbbbbbbbbbb", + "agent_id": "00000000-0000-0000-0000-aaaaaaaaaaaa", + "issue_id": issueID, + }, + }) + + if active, _ := countInboxByTypeForRecipient(t, testUserID, "task_failed"); active != 1 { + t.Fatalf("precondition: expected 1 active task_failed row, got %d", active) + } + + publishStatusChange(bus, issueID, "in_review", "in_progress") + + // in_review is in_progress Category, so the failure row must stay active. + active, archived := countInboxByTypeForRecipient(t, testUserID, "task_failed") + if active != 1 || archived != 0 { + t.Fatalf("expected task_failed row to remain active after in_review transition, got active=%d archived=%d", active, archived) + } +} + // TestNotification_StatusChange_ReopenSurfacesNewTaskFailed verifies that // after a terminal-status auto-archive, a status flip back to in_progress // followed by a new task failure produces a fresh, visible task_failed row. @@ -1310,13 +1351,13 @@ func TestNotification_StatusChange_ReopenSurfacesNewTaskFailed(t *testing.T) { }) // First terminal transition archives the original failure. - publishStatusChange(bus, issueID, "in_review", "in_progress") + publishStatusChange(bus, issueID, "done", "in_progress") if active, archived := countInboxByTypeForRecipient(t, testUserID, "task_failed"); active != 0 || archived != 1 { t.Fatalf("after terminal transition: expected active=0 archived=1, got active=%d archived=%d", active, archived) } // Reviewer kicks the issue back; a rerun fails again. - publishStatusChange(bus, issueID, "in_progress", "in_review") + publishStatusChange(bus, issueID, "in_progress", "done") bus.Publish(events.Event{ Type: protocol.EventTaskFailed, WorkspaceID: testWorkspaceID, diff --git a/server/internal/issuestatus/resolver.go b/server/internal/issuestatus/resolver.go index 1d7ea076b14..ea6a342ff38 100644 --- a/server/internal/issuestatus/resolver.go +++ b/server/internal/issuestatus/resolver.go @@ -14,6 +14,31 @@ import ( // the ONLY status semantics any automation may branch on. var Categories = []string{"backlog", "todo", "in_progress", "done", "cancelled"} +// CategoryForStatusToken maps the compatibility issue.status projection to its +// machine Category (MUL-4809 §4.2). Automation must branch on Category, never on +// the raw status token: the two built-in legacy statuses in_review and blocked +// both belong to the in_progress Category, while every other token already equals +// its Category — the five Category keys, and custom statuses which project to +// their own Category. This is the DB-free resolver for call sites that already +// hold issue.status; a status_id → issue_status.category lookup would be redundant +// because the compat projection already carries the Category for every status. +func CategoryForStatusToken(status string) string { + switch status { + case "in_review", "blocked": + return "in_progress" + default: + return status + } +} + +// IsTerminalCategory reports whether a machine Category is terminal — the work is +// finished (done) or abandoned (cancelled). in_review and blocked are NOT terminal +// (they are in_progress), which is why machine logic keys off Category and not the +// display token (MUL-4809 §4.2). +func IsTerminalCategory(category string) bool { + return category == "done" || category == "cancelled" +} + // categoryAliasSet is the set of Category alias tokens. Each resolves to the // workspace's current default status for that Category. var categoryAliasSet = map[string]struct{}{ diff --git a/server/internal/issuestatus/resolver_test.go b/server/internal/issuestatus/resolver_test.go index 1fd148de5d0..f9b34da4735 100644 --- a/server/internal/issuestatus/resolver_test.go +++ b/server/internal/issuestatus/resolver_test.go @@ -188,3 +188,46 @@ func TestIsReservedStatusToken(t *testing.T) { } } } + +// TestCategoryForStatusToken covers the compat-projection → Category mapping used +// by machine logic (MUL-4809 §4.2). The two legacy tokens collapse to in_progress; +// everything else (Category keys, custom-status projections) maps to itself. +func TestCategoryForStatusToken(t *testing.T) { + cases := map[string]string{ + "backlog": "backlog", + "todo": "todo", + "in_progress": "in_progress", + "in_review": "in_progress", + "blocked": "in_progress", + "done": "done", + "cancelled": "cancelled", + } + for token, want := range cases { + if got := CategoryForStatusToken(token); got != want { + t.Errorf("CategoryForStatusToken(%q) = %q, want %q", token, got, want) + } + } +} + +// TestIsTerminalCategory covers the terminal-Category predicate: only done and +// cancelled are terminal; in_review/blocked map to in_progress and are not. +func TestIsTerminalCategory(t *testing.T) { + terminal := []string{"done", "cancelled"} + nonTerminal := []string{"backlog", "todo", "in_progress"} + for _, c := range terminal { + if !IsTerminalCategory(c) { + t.Errorf("IsTerminalCategory(%q) = false, want true", c) + } + } + for _, c := range nonTerminal { + if IsTerminalCategory(c) { + t.Errorf("IsTerminalCategory(%q) = true, want false", c) + } + } + // The in_progress display tokens resolve through their Category, never terminal. + for _, token := range []string{"in_review", "blocked"} { + if IsTerminalCategory(CategoryForStatusToken(token)) { + t.Errorf("%q resolved as terminal, want non-terminal", token) + } + } +} From 6e1be4da7b81a6f2e04c4bdbeecde00953d37c70 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 14:56:33 +0800 Subject: [PATCH 19/41] =?UTF-8?q?fix(issue-status):=20reset-stuck-issue=20?= =?UTF-8?q?keys=20off=20in=5Fprogress=20Category,=20not=20token=20(MUL-480?= =?UTF-8?q?9=20=C2=A74.2=20P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server/cmd/server/runtime_sweeper.go | 5 +- server/internal/service/task.go | 5 +- .../service/task_reset_stuck_issue_test.go | 86 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 server/internal/service/task_reset_stuck_issue_test.go diff --git a/server/cmd/server/runtime_sweeper.go b/server/cmd/server/runtime_sweeper.go index 941e01c13e3..f9b411a47ca 100644 --- a/server/cmd/server/runtime_sweeper.go +++ b/server/cmd/server/runtime_sweeper.go @@ -9,6 +9,7 @@ import ( "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" + "github.com/multica-ai/multica/server/internal/issuestatus" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" "github.com/multica-ai/multica/server/internal/service" "github.com/multica-ai/multica/server/internal/util" @@ -359,7 +360,9 @@ func broadcastFailedTasks(ctx context.Context, queries *db.Queries, taskSvc *ser if issue, err := queries.GetIssue(ctx, t.IssueID); err == nil { workspaceID = util.UUIDToString(issue.WorkspaceID) issueKey := util.UUIDToString(t.IssueID) - if issue.Status == "in_progress" && !processedIssues[issueKey] { + // Category is the machine semantics (MUL-4809 §4.2): in_review / + // blocked are in_progress and must reset like any in_progress issue. + if issuestatus.CategoryForStatusToken(issue.Status) == "in_progress" && !processedIssues[issueKey] { processedIssues[issueKey] = true if hasActive, herr := queries.HasActiveTaskForIssue(ctx, t.IssueID); herr == nil && !hasActive { queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{ID: t.IssueID, Status: "todo", WorkspaceID: issue.WorkspaceID}) diff --git a/server/internal/service/task.go b/server/internal/service/task.go index ac50cbefb0a..ad1f93cb485 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -18,6 +18,7 @@ import ( "github.com/multica-ai/multica/server/internal/attribution" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/featureflags" + "github.com/multica-ai/multica/server/internal/issuestatus" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/runtimeapps" @@ -3600,8 +3601,10 @@ func (s *TaskService) HandleFailedTasks(ctx context.Context, tasks []db.AgentTas workspaceID = util.UUIDToString(issue.WorkspaceID) // Reset stuck in_progress issues only when no other active // task exists for the issue and no retry was just enqueued. + // Category, not the raw token, is the machine semantics (MUL-4809 + // §4.2): in_review and blocked are in_progress and must reset too. issueKey := util.UUIDToString(t.IssueID) - if issue.Status == "in_progress" && !processedIssues[issueKey] && !retriedIssues[issueKey] { + if issuestatus.CategoryForStatusToken(issue.Status) == "in_progress" && !processedIssues[issueKey] && !retriedIssues[issueKey] { processedIssues[issueKey] = true hasActive, checkErr := s.Queries.HasActiveTaskForIssue(ctx, t.IssueID) if checkErr != nil { diff --git a/server/internal/service/task_reset_stuck_issue_test.go b/server/internal/service/task_reset_stuck_issue_test.go new file mode 100644 index 00000000000..36fa2ddcab9 --- /dev/null +++ b/server/internal/service/task_reset_stuck_issue_test.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/multica-ai/multica/server/internal/events" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// TestHandleFailedTasksResetsInReviewIssue is the §4.2 reverse-leak counter-example +// (MUL-4809 P1): in_review belongs to the in_progress Category, so a stuck in_review +// issue whose last task fails (no active task, no retry) must be reset to todo just +// like a literal in_progress issue. Before keying the reset off Category, the +// hardcoded `status == "in_progress"` check skipped in_review/blocked and left the +// issue stuck — this test fails on that old behavior. +func TestHandleFailedTasksResetsInReviewIssue(t *testing.T) { + ctx := context.Background() + pool := newTaskClaimRacePool(t) + queries := db.New(pool) + + suffix := time.Now().UnixNano() + var userID, workspaceID, runtimeID, agentID string + if err := pool.QueryRow(ctx, `INSERT INTO "user" (name, email) VALUES ('reset stuck', $1) RETURNING id`, + fmt.Sprintf("reset-stuck-%d@multica.ai", suffix)).Scan(&userID); err != nil { + t.Fatalf("create user: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO workspace (name, slug, description, issue_prefix) VALUES ('reset stuck', $1, '', 'RST') RETURNING id`, + fmt.Sprintf("reset-stuck-%d", suffix)).Scan(&workspaceID); err != nil { + t.Fatalf("create workspace: %v", err) + } + t.Cleanup(func() { + pool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, workspaceID) + pool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID) + }) + if err := pool.QueryRow(ctx, ` + INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, visibility, owner_id) + VALUES ($1, NULL, 'reset rt', 'cloud', 'reset', 'online', 'rt', '{}'::jsonb, now(), 'private', $2) RETURNING id + `, workspaceID, userID).Scan(&runtimeID); err != nil { + t.Fatalf("create runtime: %v", err) + } + if err := pool.QueryRow(ctx, ` + INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id) + VALUES ($1, 'reset agent', '', 'cloud', '{}'::jsonb, $2, 'private', 1, $3) RETURNING id + `, workspaceID, runtimeID, userID).Scan(&agentID); err != nil { + t.Fatalf("create agent: %v", err) + } + + // Issue sitting in in_review (an in_progress-Category status), assigned to the agent. + var issueID string + if err := pool.QueryRow(ctx, ` + INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id, number) + VALUES ($1, 'stuck in review', 'in_review', 'none', 'member', $2, 'agent', $3, + COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1) + RETURNING id::text + `, workspaceID, userID, agentID).Scan(&issueID); err != nil { + t.Fatalf("create issue: %v", err) + } + + // One failed task for the issue, no active tasks. Empty failure_reason is not a + // retryable reason, so MaybeRetryFailedTask is a no-op and the reset path runs. + var taskID string + if err := pool.QueryRow(ctx, + `INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority) VALUES ($1, $2, $3, 'failed', 0) RETURNING id`, + agentID, runtimeID, issueID).Scan(&taskID); err != nil { + t.Fatalf("insert failed task: %v", err) + } + + svc := NewTaskService(queries, pool, nil, events.New()) + task, err := queries.GetAgentTask(ctx, mustUUID(t, taskID)) + if err != nil { + t.Fatalf("get task: %v", err) + } + + svc.HandleFailedTasks(ctx, []db.AgentTaskQueue{task}) + + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&status); err != nil { + t.Fatalf("reload issue: %v", err) + } + if status != "todo" { + t.Fatalf("in_review issue was not reset to todo after its last task failed: status=%q", status) + } +} From 9012d2022d70ad6a7cbe46cfb1e957b016659baa Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 15:04:09 +0800 Subject: [PATCH 20/41] =?UTF-8?q?fix(autopilot):=20precise=20dispatch=20pr?= =?UTF-8?q?ovenance=20for=20crash-window=20bind=20repair=20(MUL-4809=20?= =?UTF-8?q?=C2=A74.1=20P0-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server/cmd/server/autopilot_listeners_test.go | 6 + server/internal/service/autopilot.go | 60 ++-- .../autopilot_create_issue_sync_test.go | 73 ++--- server/internal/service/task.go | 28 ++ ...ent_task_dispatched_autopilot_run.down.sql | 1 + ...agent_task_dispatched_autopilot_run.up.sql | 8 + ...sk_dispatched_autopilot_run_index.down.sql | 1 + ...task_dispatched_autopilot_run_index.up.sql | 6 + server/pkg/db/generated/agent.sql.go | 267 ++++++++++-------- server/pkg/db/generated/autopilot.sql.go | 158 +++++------ server/pkg/db/generated/chat.sql.go | 6 +- server/pkg/db/generated/models.go | 3 +- server/pkg/db/generated/runtime.sql.go | 6 +- server/pkg/db/queries/agent.sql | 6 +- server/pkg/db/queries/autopilot.sql | 21 +- 15 files changed, 362 insertions(+), 288 deletions(-) create mode 100644 server/migrations/209_agent_task_dispatched_autopilot_run.down.sql create mode 100644 server/migrations/209_agent_task_dispatched_autopilot_run.up.sql create mode 100644 server/migrations/210_agent_task_dispatched_autopilot_run_index.down.sql create mode 100644 server/migrations/210_agent_task_dispatched_autopilot_run_index.up.sql diff --git a/server/cmd/server/autopilot_listeners_test.go b/server/cmd/server/autopilot_listeners_test.go index 4b70923abd9..69a2496eba9 100644 --- a/server/cmd/server/autopilot_listeners_test.go +++ b/server/cmd/server/autopilot_listeners_test.go @@ -207,6 +207,12 @@ func dispatchCreateIssueAutopilot(t *testing.T, title string) linkedIssueAutopil if run.TaskID.Bytes != tasks[0].ID.Bytes { t.Fatalf("run.task_id not bound to the dispatched task: run=%x task=%x", run.TaskID.Bytes, tasks[0].ID.Bytes) } + // MUL-4809 §4.1 P0-1: the dispatch stamps the task's provenance at INSERT, so a + // crash before the bind can be repaired precisely. Verify the real dispatch path + // writes it (not just the bind), which is what makes repair unambiguous. + if !tasks[0].DispatchedAutopilotRunID.Valid || tasks[0].DispatchedAutopilotRunID.Bytes != run.ID.Bytes { + t.Fatalf("dispatched task not stamped with run provenance: stamp valid=%v", tasks[0].DispatchedAutopilotRunID.Valid) + } return linkedIssueAutopilotFixture{taskSvc: taskSvc, svc: autopilotSvc, queries: queries, run: run, taskID: tasks[0].ID} } diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index bbdd554d885..08f18ad9b22 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -722,6 +722,12 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi // webhook dispatch has no actor and takes the plain entry points, where the // autopilot-origin issue resolves to rule_owner. The *WithHandoff variants are // the existing actor-carrying enqueue methods; the handoff note is empty here. + // Stamp the dispatched task with this run's provenance at INSERT (MUL-4809 + // §4.1). If the process crashes between this enqueue and the run.task_id bind + // below, the run can be repaired by a precise lookup of the task carrying this + // run's id — no time-window guessing, and an ordinary comment task (never + // stamped) can never be misattributed as the run's dispatched work. + dispatchCtx := withDispatchedAutopilotRun(ctx, run.ID) var dispatchedTask db.AgentTaskQueue switch { case ap.AssigneeType == "squad": @@ -733,20 +739,20 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi return fmt.Errorf("not allowed to invoke private squad leader") } if actorUserID.Valid { - dispatchedTask, err = s.TaskSvc.EnqueueTaskForSquadLeaderWithHandoff(ctx, issue, leader.ID, ap.AssigneeID, "", actorUserID) + dispatchedTask, err = s.TaskSvc.EnqueueTaskForSquadLeaderWithHandoff(dispatchCtx, issue, leader.ID, ap.AssigneeID, "", actorUserID) } else { - dispatchedTask, err = s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, ap.AssigneeID, pgtype.UUID{}) + dispatchedTask, err = s.TaskSvc.EnqueueTaskForSquadLeader(dispatchCtx, issue, leader.ID, ap.AssigneeID, pgtype.UUID{}) } if err != nil { return fmt.Errorf("enqueue squad leader task: %w", err) } case actorUserID.Valid: - dispatchedTask, err = s.TaskSvc.EnqueueTaskForIssueWithHandoff(ctx, issue, "", actorUserID) + dispatchedTask, err = s.TaskSvc.EnqueueTaskForIssueWithHandoff(dispatchCtx, issue, "", actorUserID) if err != nil { return fmt.Errorf("enqueue task for issue: %w", err) } default: - dispatchedTask, err = s.TaskSvc.EnqueueTaskForIssue(ctx, issue) + dispatchedTask, err = s.TaskSvc.EnqueueTaskForIssue(dispatchCtx, issue) if err != nil { return fmt.Errorf("enqueue task for issue: %w", err) } @@ -1040,47 +1046,23 @@ func (s *AutopilotService) bindAutopilotRunTask(ctx context.Context, runID, task util.UUIDToString(runID), current.Status, util.UUIDToString(taskID)) } -// autopilotBindRepairWindow bounds how long after issue creation a task may have -// been queued and still count as the run's dispatched task during crash-window -// repair. The autopilot enqueues its task in the same call that creates the issue, -// so the real dispatched task lands within milliseconds; the window only has to -// absorb dispatch latency while staying far shorter than the gap before any -// human/agent could comment and spawn an unrelated task on the same issue. -const autopilotBindRepairWindow = 15 * time.Minute - // repairUnboundCreateIssueRun binds an unbound create_issue run to the task it -// actually dispatched and returns the bound run. The run's task_id was never set -// (a crash between enqueue and bind, or a run dispatched by a pre-§4.1 pod), so the -// dispatched task is recovered precisely — the first root task queued for the issue -// by the run's dispatch target within the bind window (MUL-4809 §4.1 item 4). ok is -// false when the run cannot be safely repaired; the caller must not finalize it then. +// actually dispatched and returns the bound run. The run's task_id was never set — +// a crash between task enqueue and the run.task_id bind, or an unbound run left by +// a gate-off (legacy-mode) dispatch that is only now being finalized. The +// dispatched task is recovered by PRECISE provenance: the task stamped with this +// run's id at INSERT (dispatched_autopilot_run_id), not a time/agent heuristic. ok +// is false when no such task exists — the caller must not finalize the run then, so +// an ordinary comment/chat task on the same issue (never stamped) can never bind +// here or finalize the run off the wrong outcome (MUL-4809 §4.1). func (s *AutopilotService) repairUnboundCreateIssueRun(ctx context.Context, run db.AutopilotRun) (db.AutopilotRun, bool) { - if !run.IssueID.Valid { - return db.AutopilotRun{}, false - } - issue, err := s.Queries.GetIssue(ctx, run.IssueID) - if err != nil { - slog.Warn("autopilot: failed to load issue for bind repair", - "issue_id", util.UUIDToString(run.IssueID), "error", err) - return db.AutopilotRun{}, false - } - // Autopilot-created issues are authored by the dispatched agent (the resolved - // leader), so the dispatched task's agent_id equals the issue creator. A - // different shape isn't a create_issue dispatch this repair can attribute. - if issue.CreatorType != "agent" || !issue.CreatorID.Valid || !issue.CreatedAt.Valid { - return db.AutopilotRun{}, false - } - dispatched, err := s.Queries.FindAutopilotDispatchedTaskForIssue(ctx, db.FindAutopilotDispatchedTaskForIssueParams{ - IssueID: run.IssueID, - AgentID: issue.CreatorID, - CreatedAt: pgtype.Timestamptz{Time: issue.CreatedAt.Time.Add(autopilotBindRepairWindow), Valid: true}, - }) + dispatched, err := s.Queries.GetTaskByDispatchedAutopilotRun(ctx, run.ID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return db.AutopilotRun{}, false // no dispatched task in the window — don't finalize off a stray task + return db.AutopilotRun{}, false // no provably-dispatched task — don't finalize off a stray task } slog.Warn("autopilot: failed to find dispatched task for bind repair", - "issue_id", util.UUIDToString(run.IssueID), "error", err) + "run_id", util.UUIDToString(run.ID), "error", err) return db.AutopilotRun{}, false } bound, err := s.bindAutopilotRunTask(ctx, run.ID, dispatched.ID) diff --git a/server/internal/service/autopilot_create_issue_sync_test.go b/server/internal/service/autopilot_create_issue_sync_test.go index 19b7734efc9..1e0d30e9f57 100644 --- a/server/internal/service/autopilot_create_issue_sync_test.go +++ b/server/internal/service/autopilot_create_issue_sync_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/util" @@ -15,10 +16,12 @@ import ( // newCreateIssueRunFixture builds a create_issue autopilot whose run is linked to // an agent-authored issue but left UNBOUND (task_id NULL) — the crash-window state -// SyncRunFromCreateIssueTask must repair (MUL-4809 §4.1 item 4). It returns the -// service, the dispatch agent id, the run, the pool, and a helper that inserts a -// task on the issue with a chosen agent and created_at offset from issue creation. -func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.AutopilotRun, *pgxpool.Pool, func(agent string, offset time.Duration, status string) db.AgentTaskQueue) { +// SyncRunFromCreateIssueTask must repair (MUL-4809 §4.1). It returns the service, +// the dispatch agent id, the run, the pool, and a helper that inserts a task on the +// issue. Pass a valid dispatchedRunID to stamp the task's dispatched_autopilot_run_id +// (the autopilot's own dispatched task); pass pgtype.UUID{} for an ordinary +// comment/chat task that carries no provenance. +func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.AutopilotRun, *pgxpool.Pool, func(agent string, offset time.Duration, status string, dispatchedRunID pgtype.UUID) db.AgentTaskQueue) { t.Helper() ctx := context.Background() pool := newTaskClaimRacePool(t) @@ -65,9 +68,6 @@ func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.Autop t.Fatalf("CreateAutopilot: %v", err) } - // Issue authored by the dispatch agent — dispatchCreateIssue sets the issue - // creator to the resolved leader, so the repair attributes the dispatched task - // by that creator. var issueIDStr string if err := pool.QueryRow(ctx, ` INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number, origin_type, origin_id) @@ -95,16 +95,12 @@ func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.Autop t.Fatalf("link run to issue: %v", err) } - var issueCreatedAt time.Time - if err := pool.QueryRow(ctx, `SELECT created_at FROM issue WHERE id = $1`, issueIDStr).Scan(&issueCreatedAt); err != nil { - t.Fatalf("read issue created_at: %v", err) - } - - insertTask := func(agent string, offset time.Duration, status string) db.AgentTaskQueue { + insertTask := func(agent string, offset time.Duration, status string, dispatchedRunID pgtype.UUID) db.AgentTaskQueue { var id string if err := pool.QueryRow(context.Background(), - `INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, created_at) VALUES ($1, $2, $3, $4, 0, $5) RETURNING id`, - agent, runtimeID, issueIDStr, status, issueCreatedAt.Add(offset)).Scan(&id); err != nil { + `INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, created_at, dispatched_autopilot_run_id) + VALUES ($1, $2, $3, $4, 0, now() + $5::interval, $6) RETURNING id`, + agent, runtimeID, issueIDStr, status, fmt.Sprintf("%d seconds", int(offset.Seconds())), dispatchedRunID).Scan(&id); err != nil { t.Fatalf("insert task: %v", err) } task, err := queries.GetAgentTask(context.Background(), mustUUID(t, id)) @@ -124,17 +120,18 @@ func isTerminalRunStatus(status string) bool { return status == "completed" || status == "failed" || status == "skipped" } -// TestSyncRunFromCreateIssueTaskRepairsUnboundRunPrecisely covers MUL-4809 §4.1 -// item 4: when a run's task_id was never bound (a crash between enqueue and bind), -// finalization must attribute the run to the FIRST dispatched task only. A later -// comment task on the same issue must not finalize the run — instead the repair -// binds the run to the real dispatched task, and only that task finalizes it. +// TestSyncRunFromCreateIssueTaskRepairsUnboundRunPrecisely covers MUL-4809 §4.1: +// when a run's task_id was never bound (a crash between enqueue and bind), +// finalization must attribute the run to the task carrying its provenance stamp — +// never to an unstamped comment task on the same issue. The comment task finishing +// first must not finalize the run; the repair binds the run to the stamped task, +// and only that task finalizes it. func TestSyncRunFromCreateIssueTaskRepairsUnboundRunPrecisely(t *testing.T) { ctx := context.Background() svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) - dispatched := insertTask(agentID, 0, "completed") // the autopilot's own task - comment := insertTask(agentID, 30*time.Second, "completed") // a later comment task on the same issue + dispatched := insertTask(agentID, 0, "completed", run.ID) // the autopilot's own (stamped) task + comment := insertTask(agentID, 30*time.Second, "completed", pgtype.UUID{}) // a later, unstamped comment task // The comment task finishing first must NOT finalize the run, but repair must // still bind the run to the real dispatched task. @@ -161,15 +158,19 @@ func TestSyncRunFromCreateIssueTaskRepairsUnboundRunPrecisely(t *testing.T) { } } -// TestSyncRunFromCreateIssueTaskIgnoresTaskOutsideBindWindow covers the crash-window -// guard: when the run's task_id was never bound and the only task on the issue was -// queued long after issue creation (so the real dispatched task never existed — an -// enqueue crash), a stray terminal task must NOT finalize the run. -func TestSyncRunFromCreateIssueTaskIgnoresTaskOutsideBindWindow(t *testing.T) { +// TestSyncRunFromCreateIssueTaskIgnoresUnstampedTask is the precise-provenance +// counter-example Elon asked for (MUL-4809 §4.1 P0-1): when the run's task_id was +// never bound and the ONLY task on the issue is an unstamped comment/assignment +// task queued shortly after issue creation (the real dispatched task never existed +// — e.g. a crash before enqueue), that stray task must NOT finalize the run. A +// time-window heuristic would have bound it; precise provenance refuses to. +func TestSyncRunFromCreateIssueTaskIgnoresUnstampedTask(t *testing.T) { ctx := context.Background() svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) - stray := insertTask(agentID, autopilotBindRepairWindow+time.Minute, "completed") + // Same agent, same issue, seconds after creation — but NOT stamped with the + // run's provenance, because it isn't the autopilot's dispatched task. + stray := insertTask(agentID, 10*time.Second, "completed", pgtype.UUID{}) svc.SyncRunFromCreateIssueTask(ctx, stray) after, err := svc.Queries.GetAutopilotRun(ctx, run.ID) @@ -177,16 +178,16 @@ func TestSyncRunFromCreateIssueTaskIgnoresTaskOutsideBindWindow(t *testing.T) { t.Fatalf("get run: %v", err) } if isTerminalRunStatus(after.Status) { - t.Fatalf("run finalized off a task outside the bind window: status=%q", after.Status) + t.Fatalf("run finalized off an unstamped stray task: status=%q", after.Status) } if after.TaskID.Valid { - t.Fatalf("run bound to a task outside the bind window: task_id=%x", after.TaskID.Bytes) + t.Fatalf("run bound to an unstamped stray task: task_id=%x", after.TaskID.Bytes) } } // TestSyncRunFromCreateIssueTaskCompletesRegardlessOfIssueStatus covers MUL-4809 // §4.1 / §9.2: the run is finalized purely by task outcome. Even when the agent -// leaves the issue in an in_progress-category status (In Review), the completed +// leaves the issue in an in_progress-Category status (In Review), the completed // task must complete the run, and finalizing the run must not touch issue status. func TestSyncRunFromCreateIssueTaskCompletesRegardlessOfIssueStatus(t *testing.T) { ctx := context.Background() @@ -196,7 +197,7 @@ func TestSyncRunFromCreateIssueTaskCompletesRegardlessOfIssueStatus(t *testing.T t.Fatalf("set issue in_review: %v", err) } - dispatched := insertTask(agentID, 0, "completed") + dispatched := insertTask(agentID, 0, "completed", run.ID) svc.SyncRunFromCreateIssueTask(ctx, dispatched) got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) @@ -222,7 +223,7 @@ func TestSyncRunFromCreateIssueTaskCancelledReasonTraceable(t *testing.T) { ctx := context.Background() svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) - dispatched := insertTask(agentID, 0, "cancelled") + dispatched := insertTask(agentID, 0, "cancelled", run.ID) svc.SyncRunFromCreateIssueTask(ctx, dispatched) got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) @@ -245,7 +246,7 @@ func TestSyncRunFromCreateIssueTaskDoesNotRewriteTerminalRun(t *testing.T) { ctx := context.Background() svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) - dispatched := insertTask(agentID, 0, "completed") + dispatched := insertTask(agentID, 0, "completed", run.ID) svc.SyncRunFromCreateIssueTask(ctx, dispatched) done, err := svc.Queries.GetAutopilotRun(ctx, run.ID) if err != nil { @@ -255,7 +256,7 @@ func TestSyncRunFromCreateIssueTaskDoesNotRewriteTerminalRun(t *testing.T) { t.Fatalf("run did not complete: status=%q", done.Status) } - comment := insertTask(agentID, time.Minute, "failed") + comment := insertTask(agentID, time.Minute, "failed", pgtype.UUID{}) svc.SyncRunFromCreateIssueTask(ctx, comment) after, err := svc.Queries.GetAutopilotRun(ctx, run.ID) if err != nil { @@ -278,7 +279,7 @@ func TestSyncRunFromCreateIssueTaskRetryKeepsRunRunningUntilFinal(t *testing.T) svc, agentID, run, pool, insertTask := newCreateIssueRunFixture(t) // Bind the run to its dispatched task, as dispatchCreateIssue does. - dispatched := insertTask(agentID, 0, "running") + dispatched := insertTask(agentID, 0, "running", run.ID) bound, err := svc.bindAutopilotRunTask(ctx, run.ID, dispatched.ID) if err != nil { t.Fatalf("bind: %v", err) diff --git a/server/internal/service/task.go b/server/internal/service/task.go index ad1f93cb485..a20ae69f945 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -906,6 +906,28 @@ func taskErrorType(reason string) string { } } +// dispatchedAutopilotRunCtxKey carries the autopilot run a create_issue dispatch is +// enqueuing a task for, so the task INSERT stamps dispatched_autopilot_run_id +// atomically (MUL-4809 §4.1 provenance). Only the autopilot create_issue dispatch +// sets it via withDispatchedAutopilotRun; every other enqueue path leaves it unset +// and stamps NULL, so an ordinary comment/chat task can never be mistaken for a +// run's dispatched task during crash-window repair. +type dispatchedAutopilotRunCtxKey struct{} + +// withDispatchedAutopilotRun returns a ctx that stamps the next enqueued task with +// the dispatching autopilot run id, so a crash before run.task_id is bound can be +// repaired by precise provenance lookup rather than a time/agent heuristic. +func withDispatchedAutopilotRun(ctx context.Context, runID pgtype.UUID) context.Context { + return context.WithValue(ctx, dispatchedAutopilotRunCtxKey{}, runID) +} + +func dispatchedAutopilotRunFromContext(ctx context.Context) pgtype.UUID { + if v, ok := ctx.Value(dispatchedAutopilotRunCtxKey{}).(pgtype.UUID); ok { + return v + } + return pgtype.UUID{} +} + // EnqueueTaskForIssue creates a queued task for an agent-assigned issue. // No context snapshot is stored — the agent fetches all data it needs at // runtime via the multica CLI. @@ -1035,6 +1057,9 @@ func (s *TaskService) enqueueIssueTaskWithCommentPlan(ctx context.Context, issue // Stamp the reviewed head so dedup can distinguish this run's target // from a later request against a new HEAD (TEN-356). HeadSha: headShaText(s.ResolveIssueReviewSHA(ctx, issue.ID)), + // Autopilot create_issue provenance: set only when this task is the + // autopilot's dispatched task (MUL-4809 §4.1); NULL otherwise. + DispatchedAutopilotRunID: dispatchedAutopilotRunFromContext(ctx), }) if err != nil { slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err) @@ -1154,6 +1179,9 @@ func (s *TaskService) enqueueMentionTaskWithCommentPlan(ctx context.Context, iss // Stamp the reviewed head so dedup can distinguish this run's target // from a later request against a new HEAD (TEN-356). HeadSha: headShaText(s.ResolveIssueReviewSHA(ctx, issue.ID)), + // Autopilot create_issue provenance for a squad-leader dispatch: set only + // when this task is the autopilot's dispatched task (MUL-4809 §4.1). + DispatchedAutopilotRunID: dispatchedAutopilotRunFromContext(ctx), }) if err != nil { slog.Error("mention task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err) diff --git a/server/migrations/209_agent_task_dispatched_autopilot_run.down.sql b/server/migrations/209_agent_task_dispatched_autopilot_run.down.sql new file mode 100644 index 00000000000..9c1acd893e1 --- /dev/null +++ b/server/migrations/209_agent_task_dispatched_autopilot_run.down.sql @@ -0,0 +1 @@ +ALTER TABLE agent_task_queue DROP COLUMN IF EXISTS dispatched_autopilot_run_id; diff --git a/server/migrations/209_agent_task_dispatched_autopilot_run.up.sql b/server/migrations/209_agent_task_dispatched_autopilot_run.up.sql new file mode 100644 index 00000000000..5854fcfa3c6 --- /dev/null +++ b/server/migrations/209_agent_task_dispatched_autopilot_run.up.sql @@ -0,0 +1,8 @@ +-- Provenance marker for autopilot create_issue dispatch (MUL-4809 §4.1). Stamped +-- atomically at task INSERT with the autopilot_run this task was dispatched for, so +-- a crash between task enqueue and run.task_id bind can be repaired PRECISELY by +-- looking up the task carrying this run's id -- no time-window guessing, and an +-- ordinary comment/chat task (never stamped) can never be misattributed as the +-- run's dispatched work. No FK per CLAUDE.md; the relationship is resolved in app +-- code. Its lookup index is created CONCURRENTLY in migration 210. +ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS dispatched_autopilot_run_id UUID; diff --git a/server/migrations/210_agent_task_dispatched_autopilot_run_index.down.sql b/server/migrations/210_agent_task_dispatched_autopilot_run_index.down.sql new file mode 100644 index 00000000000..f43ed349b93 --- /dev/null +++ b/server/migrations/210_agent_task_dispatched_autopilot_run_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_agent_task_dispatched_autopilot_run; diff --git a/server/migrations/210_agent_task_dispatched_autopilot_run_index.up.sql b/server/migrations/210_agent_task_dispatched_autopilot_run_index.up.sql new file mode 100644 index 00000000000..8ed2c356bc8 --- /dev/null +++ b/server/migrations/210_agent_task_dispatched_autopilot_run_index.up.sql @@ -0,0 +1,6 @@ +-- Repair lookup: find the task an autopilot run dispatched (MUL-4809 §4.1). Partial +-- so it only indexes autopilot-dispatched tasks. Built CONCURRENTLY in its own +-- single-statement migration because agent_task_queue is a hot table. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agent_task_dispatched_autopilot_run + ON agent_task_queue (dispatched_autopilot_run_id) + WHERE dispatched_autopilot_run_id IS NOT NULL; diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index 00da62d99ef..c8bcdcb0e28 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -190,7 +190,7 @@ const cancelAgentTask = `-- name: CancelAgentTask :one UPDATE agent_task_queue SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL WHERE id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` func (q *Queries) CancelAgentTask(ctx context.Context, id pgtype.UUID) (AgentTaskQueue, error) { @@ -244,6 +244,7 @@ func (q *Queries) CancelAgentTask(ctx context.Context, id pgtype.UUID) (AgentTas &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -252,7 +253,7 @@ const cancelAgentTasksByAgent = `-- name: CancelAgentTasksByAgent :many UPDATE agent_task_queue SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Bulk-cancel every active (queued/dispatched/running) task for an agent. @@ -317,6 +318,7 @@ func (q *Queries) CancelAgentTasksByAgent(ctx context.Context, agentID pgtype.UU &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -332,7 +334,7 @@ const cancelAgentTasksByChatSession = `-- name: CancelAgentTasksByChatSession :m UPDATE agent_task_queue SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Cancels active tasks belonging to a chat session. Called from @@ -397,6 +399,7 @@ func (q *Queries) CancelAgentTasksByChatSession(ctx context.Context, chatSession &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -412,7 +415,7 @@ const cancelAgentTasksByIssue = `-- name: CancelAgentTasksByIssue :many UPDATE agent_task_queue SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Cancels every active task on the issue and returns the affected rows so the @@ -477,6 +480,7 @@ func (q *Queries) CancelAgentTasksByIssue(ctx context.Context, issueID pgtype.UU &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -492,7 +496,7 @@ const cancelAgentTasksByIssueAndAgent = `-- name: CancelAgentTasksByIssueAndAgen UPDATE agent_task_queue SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CancelAgentTasksByIssueAndAgentParams struct { @@ -561,6 +565,7 @@ func (q *Queries) CancelAgentTasksByIssueAndAgent(ctx context.Context, arg Cance &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -577,7 +582,7 @@ UPDATE agent_task_queue SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL WHERE (trigger_comment_id = $1 OR $1 = ANY(coalesced_comment_ids)) AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Cancels active tasks whose planned batch contains the edited/deleted comment. @@ -641,6 +646,7 @@ func (q *Queries) CancelAgentTasksByTriggerComment(ctx context.Context, triggerC &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -661,9 +667,9 @@ WITH cancelled AS ( AND fallback.status IN ('deferred', 'queued', 'dispatched', 'waiting_local_directory') AND primary_task.issue_id = $1 AND primary_task.agent_id = $2 - RETURNING fallback.id, fallback.agent_id, fallback.issue_id, fallback.status, fallback.priority, fallback.dispatched_at, fallback.started_at, fallback.completed_at, fallback.result, fallback.error, fallback.created_at, fallback.context, fallback.runtime_id, fallback.session_id, fallback.work_dir, fallback.trigger_comment_id, fallback.chat_session_id, fallback.autopilot_run_id, fallback.attempt, fallback.max_attempts, fallback.parent_task_id, fallback.failure_reason, fallback.trigger_summary, fallback.force_fresh_session, fallback.is_leader_task, fallback.wait_reason, fallback.initiator_user_id, fallback.handoff_note, fallback.prepare_lease_expires_at, fallback.squad_id, fallback.runtime_mcp_overlay, fallback.escalation_for_task_id, fallback.fire_at, fallback.originator_user_id, fallback.runtime_connected_apps, fallback.coalesced_comment_ids, fallback.delivered_comment_ids, fallback.chat_input_task_id, fallback.chat_finalize_deferred_at, fallback.originator_source, fallback.delegated_from_task_id, fallback.retry_of_task_id, fallback.rerun_of_task_id, fallback.rule_version_id, fallback.trigger_evidence_kind, fallback.trigger_evidence_ref_id, fallback.accountable_user_id + RETURNING fallback.id, fallback.agent_id, fallback.issue_id, fallback.status, fallback.priority, fallback.dispatched_at, fallback.started_at, fallback.completed_at, fallback.result, fallback.error, fallback.created_at, fallback.context, fallback.runtime_id, fallback.session_id, fallback.work_dir, fallback.trigger_comment_id, fallback.chat_session_id, fallback.autopilot_run_id, fallback.attempt, fallback.max_attempts, fallback.parent_task_id, fallback.failure_reason, fallback.trigger_summary, fallback.force_fresh_session, fallback.is_leader_task, fallback.wait_reason, fallback.initiator_user_id, fallback.handoff_note, fallback.prepare_lease_expires_at, fallback.squad_id, fallback.runtime_mcp_overlay, fallback.escalation_for_task_id, fallback.fire_at, fallback.originator_user_id, fallback.runtime_connected_apps, fallback.coalesced_comment_ids, fallback.delivered_comment_ids, fallback.chat_input_task_id, fallback.chat_finalize_deferred_at, fallback.originator_source, fallback.delegated_from_task_id, fallback.retry_of_task_id, fallback.rerun_of_task_id, fallback.rule_version_id, fallback.trigger_evidence_kind, fallback.trigger_evidence_ref_id, fallback.accountable_user_id, fallback.dispatched_autopilot_run_id ) -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM cancelled +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM cancelled ` type CancelDeferredEscalationsForIssueAgentParams struct { @@ -672,53 +678,54 @@ type CancelDeferredEscalationsForIssueAgentParams struct { } type CancelDeferredEscalationsForIssueAgentRow struct { - ID pgtype.UUID `json:"id"` - AgentID pgtype.UUID `json:"agent_id"` - IssueID pgtype.UUID `json:"issue_id"` - Status string `json:"status"` - Priority int32 `json:"priority"` - DispatchedAt pgtype.Timestamptz `json:"dispatched_at"` - StartedAt pgtype.Timestamptz `json:"started_at"` - CompletedAt pgtype.Timestamptz `json:"completed_at"` - Result []byte `json:"result"` - Error pgtype.Text `json:"error"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - Context []byte `json:"context"` - RuntimeID pgtype.UUID `json:"runtime_id"` - SessionID pgtype.Text `json:"session_id"` - WorkDir pgtype.Text `json:"work_dir"` - TriggerCommentID pgtype.UUID `json:"trigger_comment_id"` - ChatSessionID pgtype.UUID `json:"chat_session_id"` - AutopilotRunID pgtype.UUID `json:"autopilot_run_id"` - Attempt int32 `json:"attempt"` - MaxAttempts int32 `json:"max_attempts"` - ParentTaskID pgtype.UUID `json:"parent_task_id"` - FailureReason pgtype.Text `json:"failure_reason"` - TriggerSummary pgtype.Text `json:"trigger_summary"` - ForceFreshSession bool `json:"force_fresh_session"` - IsLeaderTask bool `json:"is_leader_task"` - WaitReason pgtype.Text `json:"wait_reason"` - InitiatorUserID pgtype.UUID `json:"initiator_user_id"` - HandoffNote pgtype.Text `json:"handoff_note"` - PrepareLeaseExpiresAt pgtype.Timestamptz `json:"prepare_lease_expires_at"` - SquadID pgtype.UUID `json:"squad_id"` - RuntimeMcpOverlay []byte `json:"runtime_mcp_overlay"` - EscalationForTaskID pgtype.UUID `json:"escalation_for_task_id"` - FireAt pgtype.Timestamptz `json:"fire_at"` - OriginatorUserID pgtype.UUID `json:"originator_user_id"` - RuntimeConnectedApps []byte `json:"runtime_connected_apps"` - CoalescedCommentIds []pgtype.UUID `json:"coalesced_comment_ids"` - DeliveredCommentIds []pgtype.UUID `json:"delivered_comment_ids"` - ChatInputTaskID pgtype.UUID `json:"chat_input_task_id"` - ChatFinalizeDeferredAt pgtype.Timestamptz `json:"chat_finalize_deferred_at"` - OriginatorSource pgtype.Text `json:"originator_source"` - DelegatedFromTaskID pgtype.UUID `json:"delegated_from_task_id"` - RetryOfTaskID pgtype.UUID `json:"retry_of_task_id"` - RerunOfTaskID pgtype.UUID `json:"rerun_of_task_id"` - RuleVersionID pgtype.UUID `json:"rule_version_id"` - TriggerEvidenceKind pgtype.Text `json:"trigger_evidence_kind"` - TriggerEvidenceRefID pgtype.UUID `json:"trigger_evidence_ref_id"` - AccountableUserID pgtype.UUID `json:"accountable_user_id"` + ID pgtype.UUID `json:"id"` + AgentID pgtype.UUID `json:"agent_id"` + IssueID pgtype.UUID `json:"issue_id"` + Status string `json:"status"` + Priority int32 `json:"priority"` + DispatchedAt pgtype.Timestamptz `json:"dispatched_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + Result []byte `json:"result"` + Error pgtype.Text `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Context []byte `json:"context"` + RuntimeID pgtype.UUID `json:"runtime_id"` + SessionID pgtype.Text `json:"session_id"` + WorkDir pgtype.Text `json:"work_dir"` + TriggerCommentID pgtype.UUID `json:"trigger_comment_id"` + ChatSessionID pgtype.UUID `json:"chat_session_id"` + AutopilotRunID pgtype.UUID `json:"autopilot_run_id"` + Attempt int32 `json:"attempt"` + MaxAttempts int32 `json:"max_attempts"` + ParentTaskID pgtype.UUID `json:"parent_task_id"` + FailureReason pgtype.Text `json:"failure_reason"` + TriggerSummary pgtype.Text `json:"trigger_summary"` + ForceFreshSession bool `json:"force_fresh_session"` + IsLeaderTask bool `json:"is_leader_task"` + WaitReason pgtype.Text `json:"wait_reason"` + InitiatorUserID pgtype.UUID `json:"initiator_user_id"` + HandoffNote pgtype.Text `json:"handoff_note"` + PrepareLeaseExpiresAt pgtype.Timestamptz `json:"prepare_lease_expires_at"` + SquadID pgtype.UUID `json:"squad_id"` + RuntimeMcpOverlay []byte `json:"runtime_mcp_overlay"` + EscalationForTaskID pgtype.UUID `json:"escalation_for_task_id"` + FireAt pgtype.Timestamptz `json:"fire_at"` + OriginatorUserID pgtype.UUID `json:"originator_user_id"` + RuntimeConnectedApps []byte `json:"runtime_connected_apps"` + CoalescedCommentIds []pgtype.UUID `json:"coalesced_comment_ids"` + DeliveredCommentIds []pgtype.UUID `json:"delivered_comment_ids"` + ChatInputTaskID pgtype.UUID `json:"chat_input_task_id"` + ChatFinalizeDeferredAt pgtype.Timestamptz `json:"chat_finalize_deferred_at"` + OriginatorSource pgtype.Text `json:"originator_source"` + DelegatedFromTaskID pgtype.UUID `json:"delegated_from_task_id"` + RetryOfTaskID pgtype.UUID `json:"retry_of_task_id"` + RerunOfTaskID pgtype.UUID `json:"rerun_of_task_id"` + RuleVersionID pgtype.UUID `json:"rule_version_id"` + TriggerEvidenceKind pgtype.Text `json:"trigger_evidence_kind"` + TriggerEvidenceRefID pgtype.UUID `json:"trigger_evidence_ref_id"` + AccountableUserID pgtype.UUID `json:"accountable_user_id"` + DispatchedAutopilotRunID pgtype.UUID `json:"dispatched_autopilot_run_id"` } func (q *Queries) CancelDeferredEscalationsForIssueAgent(ctx context.Context, arg CancelDeferredEscalationsForIssueAgentParams) ([]CancelDeferredEscalationsForIssueAgentRow, error) { @@ -778,6 +785,7 @@ func (q *Queries) CancelDeferredEscalationsForIssueAgent(ctx context.Context, ar &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -794,7 +802,7 @@ UPDATE agent_task_queue SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL WHERE escalation_for_task_id = $1 AND status IN ('deferred', 'queued', 'dispatched', 'waiting_local_directory') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` func (q *Queries) CancelDeferredEscalationsForTask(ctx context.Context, escalationForTaskID pgtype.UUID) ([]AgentTaskQueue, error) { @@ -854,6 +862,7 @@ func (q *Queries) CancelDeferredEscalationsForTask(ctx context.Context, escalati &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -894,7 +903,7 @@ WHERE id = ( LIMIT 1 FOR UPDATE SKIP LOCKED ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type ClaimAgentTaskParams struct { @@ -962,6 +971,7 @@ func (q *Queries) ClaimAgentTask(ctx context.Context, arg ClaimAgentTaskParams) &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -970,7 +980,7 @@ const claimChatFinalizeDeferred = `-- name: ClaimChatFinalizeDeferred :one UPDATE agent_task_queue SET chat_finalize_deferred_at = NULL WHERE id = $1 AND chat_finalize_deferred_at IS NOT NULL -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Atomically claims the deferred marker so the daemon ack and the sweeper @@ -1026,6 +1036,7 @@ func (q *Queries) ClaimChatFinalizeDeferred(ctx context.Context, id pgtype.UUID) &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -1163,7 +1174,7 @@ const completeAgentTask = `-- name: CompleteAgentTask :one UPDATE agent_task_queue SET status = 'completed', completed_at = now(), result = $2, session_id = $3, work_dir = $4, prepare_lease_expires_at = NULL WHERE id = $1 AND status = 'running' -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CompleteAgentTaskParams struct { @@ -1229,6 +1240,7 @@ func (q *Queries) CompleteAgentTask(ctx context.Context, arg CompleteAgentTaskPa &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -1411,7 +1423,8 @@ INSERT INTO agent_task_queue ( agent_id, runtime_id, issue_id, status, priority, trigger_comment_id, coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task, handoff_note, squad_id, context, originator_user_id, accountable_user_id, runtime_mcp_overlay, runtime_connected_apps, - originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id, trigger_evidence_kind, trigger_evidence_ref_id + originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id, trigger_evidence_kind, trigger_evidence_ref_id, + dispatched_autopilot_run_id ) VALUES ( $1, $2, $3, 'queued', $4, $5, @@ -1435,34 +1448,36 @@ VALUES ( $19, $20, $21, - $22 + $22, + $23 ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CreateAgentTaskParams struct { - AgentID pgtype.UUID `json:"agent_id"` - RuntimeID pgtype.UUID `json:"runtime_id"` - IssueID pgtype.UUID `json:"issue_id"` - Priority int32 `json:"priority"` - TriggerCommentID pgtype.UUID `json:"trigger_comment_id"` - CoalescedCommentIds []pgtype.UUID `json:"coalesced_comment_ids"` - TriggerSummary pgtype.Text `json:"trigger_summary"` - ForceFreshSession pgtype.Bool `json:"force_fresh_session"` - IsLeaderTask pgtype.Bool `json:"is_leader_task"` - HandoffNote pgtype.Text `json:"handoff_note"` - SquadID pgtype.UUID `json:"squad_id"` - HeadSha pgtype.Text `json:"head_sha"` - OriginatorUserID pgtype.UUID `json:"originator_user_id"` - AccountableUserID pgtype.UUID `json:"accountable_user_id"` - RuntimeMcpOverlay []byte `json:"runtime_mcp_overlay"` - RuntimeConnectedApps []byte `json:"runtime_connected_apps"` - OriginatorSource pgtype.Text `json:"originator_source"` - DelegatedFromTaskID pgtype.UUID `json:"delegated_from_task_id"` - RuleVersionID pgtype.UUID `json:"rule_version_id"` - RerunOfTaskID pgtype.UUID `json:"rerun_of_task_id"` - TriggerEvidenceKind pgtype.Text `json:"trigger_evidence_kind"` - TriggerEvidenceRefID pgtype.UUID `json:"trigger_evidence_ref_id"` + AgentID pgtype.UUID `json:"agent_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + IssueID pgtype.UUID `json:"issue_id"` + Priority int32 `json:"priority"` + TriggerCommentID pgtype.UUID `json:"trigger_comment_id"` + CoalescedCommentIds []pgtype.UUID `json:"coalesced_comment_ids"` + TriggerSummary pgtype.Text `json:"trigger_summary"` + ForceFreshSession pgtype.Bool `json:"force_fresh_session"` + IsLeaderTask pgtype.Bool `json:"is_leader_task"` + HandoffNote pgtype.Text `json:"handoff_note"` + SquadID pgtype.UUID `json:"squad_id"` + HeadSha pgtype.Text `json:"head_sha"` + OriginatorUserID pgtype.UUID `json:"originator_user_id"` + AccountableUserID pgtype.UUID `json:"accountable_user_id"` + RuntimeMcpOverlay []byte `json:"runtime_mcp_overlay"` + RuntimeConnectedApps []byte `json:"runtime_connected_apps"` + OriginatorSource pgtype.Text `json:"originator_source"` + DelegatedFromTaskID pgtype.UUID `json:"delegated_from_task_id"` + RuleVersionID pgtype.UUID `json:"rule_version_id"` + RerunOfTaskID pgtype.UUID `json:"rerun_of_task_id"` + TriggerEvidenceKind pgtype.Text `json:"trigger_evidence_kind"` + TriggerEvidenceRefID pgtype.UUID `json:"trigger_evidence_ref_id"` + DispatchedAutopilotRunID pgtype.UUID `json:"dispatched_autopilot_run_id"` } // head_sha stamps the commit under review into the task's context JSONB so the @@ -1496,6 +1511,7 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams arg.RerunOfTaskID, arg.TriggerEvidenceKind, arg.TriggerEvidenceRefID, + arg.DispatchedAutopilotRunID, ) var i AgentTaskQueue err := row.Scan( @@ -1546,6 +1562,7 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -1572,7 +1589,7 @@ VALUES ( $15, $16 ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CreateDeferredAgentTaskParams struct { @@ -1669,6 +1686,7 @@ func (q *Queries) CreateDeferredAgentTask(ctx context.Context, arg CreateDeferre &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -1689,7 +1707,7 @@ VALUES ( $10, $11 ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CreateQuickCreateTaskParams struct { @@ -1775,6 +1793,7 @@ func (q *Queries) CreateQuickCreateTask(ctx context.Context, arg CreateQuickCrea &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -1810,7 +1829,7 @@ SELECT p.chat_input_task_id, $2 FROM agent_task_queue p WHERE p.id = $1 -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CreateRetryTaskParams struct { @@ -1930,6 +1949,7 @@ func (q *Queries) CreateRetryTask(ctx context.Context, arg CreateRetryTaskParams &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -1966,7 +1986,7 @@ FROM victims v WHERE t.id = v.id AND t.status = 'queued' AND t.created_at < now() - make_interval(secs => $1::double precision) -RETURNING t.id, t.agent_id, t.issue_id, t.status, t.priority, t.dispatched_at, t.started_at, t.completed_at, t.result, t.error, t.created_at, t.context, t.runtime_id, t.session_id, t.work_dir, t.trigger_comment_id, t.chat_session_id, t.autopilot_run_id, t.attempt, t.max_attempts, t.parent_task_id, t.failure_reason, t.trigger_summary, t.force_fresh_session, t.is_leader_task, t.wait_reason, t.initiator_user_id, t.handoff_note, t.prepare_lease_expires_at, t.squad_id, t.runtime_mcp_overlay, t.escalation_for_task_id, t.fire_at, t.originator_user_id, t.runtime_connected_apps, t.coalesced_comment_ids, t.delivered_comment_ids, t.chat_input_task_id, t.chat_finalize_deferred_at, t.originator_source, t.delegated_from_task_id, t.retry_of_task_id, t.rerun_of_task_id, t.rule_version_id, t.trigger_evidence_kind, t.trigger_evidence_ref_id, t.accountable_user_id +RETURNING t.id, t.agent_id, t.issue_id, t.status, t.priority, t.dispatched_at, t.started_at, t.completed_at, t.result, t.error, t.created_at, t.context, t.runtime_id, t.session_id, t.work_dir, t.trigger_comment_id, t.chat_session_id, t.autopilot_run_id, t.attempt, t.max_attempts, t.parent_task_id, t.failure_reason, t.trigger_summary, t.force_fresh_session, t.is_leader_task, t.wait_reason, t.initiator_user_id, t.handoff_note, t.prepare_lease_expires_at, t.squad_id, t.runtime_mcp_overlay, t.escalation_for_task_id, t.fire_at, t.originator_user_id, t.runtime_connected_apps, t.coalesced_comment_ids, t.delivered_comment_ids, t.chat_input_task_id, t.chat_finalize_deferred_at, t.originator_source, t.delegated_from_task_id, t.retry_of_task_id, t.rerun_of_task_id, t.rule_version_id, t.trigger_evidence_kind, t.trigger_evidence_ref_id, t.accountable_user_id, t.dispatched_autopilot_run_id ` type ExpireStaleQueuedTasksParams struct { @@ -2054,6 +2074,7 @@ func (q *Queries) ExpireStaleQueuedTasks(ctx context.Context, arg ExpireStaleQue &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -2072,7 +2093,7 @@ WHERE id = $1 AND runtime_id = $2 AND status IN ('dispatched', 'waiting_local_directory') AND started_at IS NULL -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type ExtendAgentTaskPrepareLeaseParams struct { @@ -2136,6 +2157,7 @@ func (q *Queries) ExtendAgentTaskPrepareLease(ctx context.Context, arg ExtendAge &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -2150,7 +2172,7 @@ SET status = 'failed', work_dir = COALESCE($5, work_dir), prepare_lease_expires_at = NULL WHERE id = $1 AND status IN ('dispatched', 'running', 'waiting_local_directory') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type FailAgentTaskParams struct { @@ -2227,6 +2249,7 @@ func (q *Queries) FailAgentTask(ctx context.Context, arg FailAgentTaskParams) (A &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -2251,7 +2274,7 @@ WHERE ( AND r.last_seen_at >= now() - make_interval(secs => $3::double precision) ) ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type FailStaleTasksParams struct { @@ -2354,6 +2377,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams) &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -2489,7 +2513,7 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa } const getAgentTask = `-- name: GetAgentTask :one -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE id = $1 ` @@ -2544,12 +2568,13 @@ func (q *Queries) GetAgentTask(ctx context.Context, id pgtype.UUID) (AgentTaskQu &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } const getAgentTaskInWorkspace = `-- name: GetAgentTaskInWorkspace :one -SELECT atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.escalation_for_task_id, atq.fire_at, atq.originator_user_id, atq.runtime_connected_apps, atq.coalesced_comment_ids, atq.delivered_comment_ids, atq.chat_input_task_id, atq.chat_finalize_deferred_at, atq.originator_source, atq.delegated_from_task_id, atq.retry_of_task_id, atq.rerun_of_task_id, atq.rule_version_id, atq.trigger_evidence_kind, atq.trigger_evidence_ref_id, atq.accountable_user_id FROM agent_task_queue atq +SELECT atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.escalation_for_task_id, atq.fire_at, atq.originator_user_id, atq.runtime_connected_apps, atq.coalesced_comment_ids, atq.delivered_comment_ids, atq.chat_input_task_id, atq.chat_finalize_deferred_at, atq.originator_source, atq.delegated_from_task_id, atq.retry_of_task_id, atq.rerun_of_task_id, atq.rule_version_id, atq.trigger_evidence_kind, atq.trigger_evidence_ref_id, atq.accountable_user_id, atq.dispatched_autopilot_run_id FROM agent_task_queue atq JOIN agent a ON a.id = atq.agent_id WHERE atq.id = $1 AND a.workspace_id = $2 ` @@ -2617,6 +2642,7 @@ func (q *Queries) GetAgentTaskInWorkspace(ctx context.Context, arg GetAgentTaskI &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -3115,7 +3141,7 @@ func (q *Queries) ListActiveAgentsByRuntimeForUpdate(ctx context.Context, runtim } const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory') ORDER BY created_at DESC ` @@ -3182,6 +3208,7 @@ func (q *Queries) ListActiveTasksByIssue(ctx context.Context, issueID pgtype.UUI &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -3194,7 +3221,7 @@ func (q *Queries) ListActiveTasksByIssue(ctx context.Context, issueID pgtype.UUI } const listAgentTasks = `-- name: ListAgentTasks :many -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE agent_id = $1 ORDER BY created_at DESC ` @@ -3256,6 +3283,7 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -3374,7 +3402,7 @@ func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([ } const listChatFinalizeDeferredExpired = `-- name: ListChatFinalizeDeferredExpired :many -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE chat_finalize_deferred_at IS NOT NULL AND chat_finalize_deferred_at < now() - make_interval(secs => $1::double precision) ORDER BY chat_finalize_deferred_at @@ -3446,6 +3474,7 @@ func (q *Queries) ListChatFinalizeDeferredExpired(ctx context.Context, arg ListC &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -3458,7 +3487,7 @@ func (q *Queries) ListChatFinalizeDeferredExpired(ctx context.Context, arg ListC } const listPendingTasksByRuntime = `-- name: ListPendingTasksByRuntime :many -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE runtime_id = $1 AND status IN ('queued', 'dispatched') ORDER BY priority DESC, created_at ASC ` @@ -3520,6 +3549,7 @@ func (q *Queries) ListPendingTasksByRuntime(ctx context.Context, runtimeID pgtyp &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -3532,7 +3562,7 @@ func (q *Queries) ListPendingTasksByRuntime(ctx context.Context, runtimeID pgtyp } const listQueuedClaimCandidatesByRuntime = `-- name: ListQueuedClaimCandidatesByRuntime :many -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE runtime_id = $1 AND status = 'queued' ORDER BY priority DESC, created_at ASC ` @@ -3602,6 +3632,7 @@ func (q *Queries) ListQueuedClaimCandidatesByRuntime(ctx context.Context, runtim &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -3614,7 +3645,7 @@ func (q *Queries) ListQueuedClaimCandidatesByRuntime(ctx context.Context, runtim } const listQueuedClaimCandidatesByRuntimes = `-- name: ListQueuedClaimCandidatesByRuntimes :many -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE runtime_id = ANY($1::uuid[]) AND status = 'queued' ORDER BY priority DESC, created_at ASC ` @@ -3686,6 +3717,7 @@ func (q *Queries) ListQueuedClaimCandidatesByRuntimes(ctx context.Context, runti &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -3698,7 +3730,7 @@ func (q *Queries) ListQueuedClaimCandidatesByRuntimes(ctx context.Context, runti } const listTasksByIssue = `-- name: ListTasksByIssue :many -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE issue_id = $1 ORDER BY created_at DESC ` @@ -3760,6 +3792,7 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([] &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -3772,15 +3805,15 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([] } const listWorkspaceAgentTaskSnapshot = `-- name: ListWorkspaceAgentTaskSnapshot :many -SELECT atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.escalation_for_task_id, atq.fire_at, atq.originator_user_id, atq.runtime_connected_apps, atq.coalesced_comment_ids, atq.delivered_comment_ids, atq.chat_input_task_id, atq.chat_finalize_deferred_at, atq.originator_source, atq.delegated_from_task_id, atq.retry_of_task_id, atq.rerun_of_task_id, atq.rule_version_id, atq.trigger_evidence_kind, atq.trigger_evidence_ref_id, atq.accountable_user_id FROM agent_task_queue atq +SELECT atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.escalation_for_task_id, atq.fire_at, atq.originator_user_id, atq.runtime_connected_apps, atq.coalesced_comment_ids, atq.delivered_comment_ids, atq.chat_input_task_id, atq.chat_finalize_deferred_at, atq.originator_source, atq.delegated_from_task_id, atq.retry_of_task_id, atq.rerun_of_task_id, atq.rule_version_id, atq.trigger_evidence_kind, atq.trigger_evidence_ref_id, atq.accountable_user_id, atq.dispatched_autopilot_run_id FROM agent_task_queue atq JOIN agent a ON a.id = atq.agent_id WHERE a.workspace_id = $1 AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory') UNION ALL -SELECT t.id, t.agent_id, t.issue_id, t.status, t.priority, t.dispatched_at, t.started_at, t.completed_at, t.result, t.error, t.created_at, t.context, t.runtime_id, t.session_id, t.work_dir, t.trigger_comment_id, t.chat_session_id, t.autopilot_run_id, t.attempt, t.max_attempts, t.parent_task_id, t.failure_reason, t.trigger_summary, t.force_fresh_session, t.is_leader_task, t.wait_reason, t.initiator_user_id, t.handoff_note, t.prepare_lease_expires_at, t.squad_id, t.runtime_mcp_overlay, t.escalation_for_task_id, t.fire_at, t.originator_user_id, t.runtime_connected_apps, t.coalesced_comment_ids, t.delivered_comment_ids, t.chat_input_task_id, t.chat_finalize_deferred_at, t.originator_source, t.delegated_from_task_id, t.retry_of_task_id, t.rerun_of_task_id, t.rule_version_id, t.trigger_evidence_kind, t.trigger_evidence_ref_id, t.accountable_user_id FROM ( - SELECT DISTINCT ON (atq.agent_id) atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.escalation_for_task_id, atq.fire_at, atq.originator_user_id, atq.runtime_connected_apps, atq.coalesced_comment_ids, atq.delivered_comment_ids, atq.chat_input_task_id, atq.chat_finalize_deferred_at, atq.originator_source, atq.delegated_from_task_id, atq.retry_of_task_id, atq.rerun_of_task_id, atq.rule_version_id, atq.trigger_evidence_kind, atq.trigger_evidence_ref_id, atq.accountable_user_id +SELECT t.id, t.agent_id, t.issue_id, t.status, t.priority, t.dispatched_at, t.started_at, t.completed_at, t.result, t.error, t.created_at, t.context, t.runtime_id, t.session_id, t.work_dir, t.trigger_comment_id, t.chat_session_id, t.autopilot_run_id, t.attempt, t.max_attempts, t.parent_task_id, t.failure_reason, t.trigger_summary, t.force_fresh_session, t.is_leader_task, t.wait_reason, t.initiator_user_id, t.handoff_note, t.prepare_lease_expires_at, t.squad_id, t.runtime_mcp_overlay, t.escalation_for_task_id, t.fire_at, t.originator_user_id, t.runtime_connected_apps, t.coalesced_comment_ids, t.delivered_comment_ids, t.chat_input_task_id, t.chat_finalize_deferred_at, t.originator_source, t.delegated_from_task_id, t.retry_of_task_id, t.rerun_of_task_id, t.rule_version_id, t.trigger_evidence_kind, t.trigger_evidence_ref_id, t.accountable_user_id, t.dispatched_autopilot_run_id FROM ( + SELECT DISTINCT ON (atq.agent_id) atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.escalation_for_task_id, atq.fire_at, atq.originator_user_id, atq.runtime_connected_apps, atq.coalesced_comment_ids, atq.delivered_comment_ids, atq.chat_input_task_id, atq.chat_finalize_deferred_at, atq.originator_source, atq.delegated_from_task_id, atq.retry_of_task_id, atq.rerun_of_task_id, atq.rule_version_id, atq.trigger_evidence_kind, atq.trigger_evidence_ref_id, atq.accountable_user_id, atq.dispatched_autopilot_run_id FROM agent_task_queue atq JOIN agent a ON a.id = atq.agent_id WHERE a.workspace_id = $1 @@ -3864,6 +3897,7 @@ func (q *Queries) ListWorkspaceAgentTaskSnapshot(ctx context.Context, workspaceI &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -3881,7 +3915,7 @@ SET status = 'waiting_local_directory', wait_reason = $2, prepare_lease_expires_at = now() + make_interval(secs => $3::double precision) WHERE id = $1 AND status = 'dispatched' -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type MarkAgentTaskWaitingLocalDirectoryParams struct { @@ -3950,6 +3984,7 @@ func (q *Queries) MarkAgentTaskWaitingLocalDirectory(ctx context.Context, arg Ma &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -3958,7 +3993,7 @@ const markChatFinalizeDeferred = `-- name: MarkChatFinalizeDeferred :one UPDATE agent_task_queue SET chat_finalize_deferred_at = now() WHERE id = $1 -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Arms the deferred chat-finalize marker for a cancelled chat task whose @@ -4014,6 +4049,7 @@ func (q *Queries) MarkChatFinalizeDeferred(ctx context.Context, id pgtype.UUID) &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -4145,7 +4181,7 @@ SET status = 'queued' WHERE runtime_id = $1 AND status = 'deferred' AND fire_at <= now() -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` func (q *Queries) PromoteDueDeferredTasksForRuntime(ctx context.Context, runtimeID pgtype.UUID) ([]AgentTaskQueue, error) { @@ -4205,6 +4241,7 @@ func (q *Queries) PromoteDueDeferredTasksForRuntime(ctx context.Context, runtime &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -4222,7 +4259,7 @@ SET status = 'queued' WHERE runtime_id = ANY($1::uuid[]) AND status = 'deferred' AND fire_at <= now() -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Batch variant of PromoteDueDeferredTasksForRuntime (MUL-4257): promotes all @@ -4284,6 +4321,7 @@ func (q *Queries) PromoteDueDeferredTasksForRuntimes(ctx context.Context, runtim &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -4310,7 +4348,7 @@ WHERE id = ( LIMIT 1 FOR UPDATE SKIP LOCKED ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type ReclaimStaleDispatchedTaskForRuntimeParams struct { @@ -4375,6 +4413,7 @@ func (q *Queries) ReclaimStaleDispatchedTaskForRuntime(ctx context.Context, arg &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -4394,7 +4433,7 @@ WHERE id IN ( LIMIT $4::int FOR UPDATE SKIP LOCKED ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type ReclaimStaleDispatchedTasksForRuntimesParams struct { @@ -4473,6 +4512,7 @@ func (q *Queries) ReclaimStaleDispatchedTasksForRuntimes(ctx context.Context, ar &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -4493,7 +4533,7 @@ SET status = 'failed', wait_reason = NULL, prepare_lease_expires_at = NULL WHERE runtime_id = $1 AND status IN ('dispatched', 'running', 'waiting_local_directory') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Called by the daemon at startup. Atomically fails any dispatched/running/ @@ -4559,6 +4599,7 @@ func (q *Queries) RecoverOrphanedTasksForRuntime(ctx context.Context, runtimeID &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -4626,7 +4667,7 @@ WHERE id = $1 AND status = 'dispatched' AND started_at IS NULL AND dispatched_at = $3 -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type RequeueAgentTaskAfterClaimFailureParams struct { @@ -4691,6 +4732,7 @@ func (q *Queries) RequeueAgentTaskAfterClaimFailure(ctx context.Context, arg Req &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -4789,7 +4831,7 @@ SET status = 'running', wait_reason = NULL, prepare_lease_expires_at = NULL WHERE id = $1 AND status IN ('dispatched', 'waiting_local_directory') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Transitions a task to running. Accepts either 'dispatched' (the normal @@ -4849,6 +4891,7 @@ func (q *Queries) StartAgentTask(ctx context.Context, id pgtype.UUID) (AgentTask &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index dcffc192928..e3b446f8157 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -288,7 +288,7 @@ VALUES ( $10, $11 ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CreateAutopilotTaskParams struct { @@ -383,6 +383,7 @@ func (q *Queries) CreateAutopilotTask(ctx context.Context, arg CreateAutopilotTa &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -515,86 +516,6 @@ func (q *Queries) FailAutopilotRunsByIssue(ctx context.Context, issueID pgtype.U return err } -const findAutopilotDispatchedTaskForIssue = `-- name: FindAutopilotDispatchedTaskForIssue :one -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue -WHERE issue_id = $1 - AND agent_id = $2 - AND retry_of_task_id IS NULL - AND created_at <= $3 -ORDER BY created_at ASC, id ASC -LIMIT 1 -` - -type FindAutopilotDispatchedTaskForIssueParams struct { - IssueID pgtype.UUID `json:"issue_id"` - AgentID pgtype.UUID `json:"agent_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -// Recovers the task a create_issue run dispatched when the run's task_id was never -// bound — a crash between enqueue and bind, or a run dispatched by a pre-§4.1 pod -// mid rolling-deploy (MUL-4809 §4.1 item 4). The dispatched task is the FIRST root -// attempt (retry_of_task_id IS NULL) queued for the autopilot's issue, by the run's -// dispatch target (agent_id), within the bind crash window after issue creation. -// Ordinary comment/chat tasks on the same issue are queued later, by a different -// agent, or outside the window, so none of them matches — that is what stops a -// stray task from being misattributed as the run's dispatched work and finalizing -// the run off the wrong outcome. -func (q *Queries) FindAutopilotDispatchedTaskForIssue(ctx context.Context, arg FindAutopilotDispatchedTaskForIssueParams) (AgentTaskQueue, error) { - row := q.db.QueryRow(ctx, findAutopilotDispatchedTaskForIssue, arg.IssueID, arg.AgentID, arg.CreatedAt) - var i AgentTaskQueue - err := row.Scan( - &i.ID, - &i.AgentID, - &i.IssueID, - &i.Status, - &i.Priority, - &i.DispatchedAt, - &i.StartedAt, - &i.CompletedAt, - &i.Result, - &i.Error, - &i.CreatedAt, - &i.Context, - &i.RuntimeID, - &i.SessionID, - &i.WorkDir, - &i.TriggerCommentID, - &i.ChatSessionID, - &i.AutopilotRunID, - &i.Attempt, - &i.MaxAttempts, - &i.ParentTaskID, - &i.FailureReason, - &i.TriggerSummary, - &i.ForceFreshSession, - &i.IsLeaderTask, - &i.WaitReason, - &i.InitiatorUserID, - &i.HandoffNote, - &i.PrepareLeaseExpiresAt, - &i.SquadID, - &i.RuntimeMcpOverlay, - &i.EscalationForTaskID, - &i.FireAt, - &i.OriginatorUserID, - &i.RuntimeConnectedApps, - &i.CoalescedCommentIds, - &i.DeliveredCommentIds, - &i.ChatInputTaskID, - &i.ChatFinalizeDeferredAt, - &i.OriginatorSource, - &i.DelegatedFromTaskID, - &i.RetryOfTaskID, - &i.RerunOfTaskID, - &i.RuleVersionID, - &i.TriggerEvidenceKind, - &i.TriggerEvidenceRefID, - &i.AccountableUserID, - ) - return i, err -} - const getActiveAutopilotRuleVersion = `-- name: GetActiveAutopilotRuleVersion :one SELECT id, autopilot_id, workspace_id, published_by_type, published_by_id, config_summary, created_at FROM autopilot_rule_version WHERE workspace_id = $1 AND autopilot_id = $2 @@ -827,7 +748,7 @@ func (q *Queries) GetAutopilotRunByWebhookDelivery(ctx context.Context, webhookD } const getAutopilotTaskByRun = `-- name: GetAutopilotTaskByRun :one -SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id FROM agent_task_queue +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE autopilot_run_id = $1 ORDER BY created_at LIMIT 1 @@ -886,6 +807,7 @@ func (q *Queries) GetAutopilotTaskByRun(ctx context.Context, autopilotRunID pgty &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -955,6 +877,78 @@ func (q *Queries) GetLatestAutopilotRunByIssue(ctx context.Context, issueID pgty return i, err } +const getTaskByDispatchedAutopilotRun = `-- name: GetTaskByDispatchedAutopilotRun :one +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue +WHERE dispatched_autopilot_run_id = $1 + AND retry_of_task_id IS NULL +ORDER BY created_at ASC, id ASC +LIMIT 1 +` + +// Recovers the task a create_issue run dispatched when the run's task_id was never +// bound — a crash between task enqueue and run.task_id bind, or an unbound run at +// gate-flip time (MUL-4809 §4.1). The dispatch stamps dispatched_autopilot_run_id +// on the task atomically at INSERT, so this is a PRECISE provenance lookup, not a +// time/agent heuristic: an ordinary comment/chat task is never stamped and can +// never be returned here, so it can never be misattributed as the run's work. +// The stamp is 1:1 with the run, but keep it deterministic under any accidental +// duplicate by returning the earliest root attempt. +func (q *Queries) GetTaskByDispatchedAutopilotRun(ctx context.Context, dispatchedAutopilotRunID pgtype.UUID) (AgentTaskQueue, error) { + row := q.db.QueryRow(ctx, getTaskByDispatchedAutopilotRun, dispatchedAutopilotRunID) + var i AgentTaskQueue + err := row.Scan( + &i.ID, + &i.AgentID, + &i.IssueID, + &i.Status, + &i.Priority, + &i.DispatchedAt, + &i.StartedAt, + &i.CompletedAt, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.Context, + &i.RuntimeID, + &i.SessionID, + &i.WorkDir, + &i.TriggerCommentID, + &i.ChatSessionID, + &i.AutopilotRunID, + &i.Attempt, + &i.MaxAttempts, + &i.ParentTaskID, + &i.FailureReason, + &i.TriggerSummary, + &i.ForceFreshSession, + &i.IsLeaderTask, + &i.WaitReason, + &i.InitiatorUserID, + &i.HandoffNote, + &i.PrepareLeaseExpiresAt, + &i.SquadID, + &i.RuntimeMcpOverlay, + &i.EscalationForTaskID, + &i.FireAt, + &i.OriginatorUserID, + &i.RuntimeConnectedApps, + &i.CoalescedCommentIds, + &i.DeliveredCommentIds, + &i.ChatInputTaskID, + &i.ChatFinalizeDeferredAt, + &i.OriginatorSource, + &i.DelegatedFromTaskID, + &i.RetryOfTaskID, + &i.RerunOfTaskID, + &i.RuleVersionID, + &i.TriggerEvidenceKind, + &i.TriggerEvidenceRefID, + &i.AccountableUserID, + &i.DispatchedAutopilotRunID, + ) + return i, err +} + const getWebhookTriggerByToken = `-- name: GetWebhookTriggerByToken :one SELECT t.id, t.autopilot_id, t.kind, t.enabled, t.cron_expression, t.timezone, t.next_run_at, t.webhook_token, t.label, t.last_fired_at, t.created_at, t.updated_at, t.provider, t.signing_secret, t.event_filters, t.published_by_type, t.published_by_id, a.workspace_id AS autopilot_workspace_id FROM autopilot_trigger t diff --git a/server/pkg/db/generated/chat.sql.go b/server/pkg/db/generated/chat.sql.go index 4457f05e0d5..f3c64b8b02e 100644 --- a/server/pkg/db/generated/chat.sql.go +++ b/server/pkg/db/generated/chat.sql.go @@ -173,7 +173,7 @@ VALUES ( $12, $13 ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CreateChatTaskParams struct { @@ -260,6 +260,7 @@ func (q *Queries) CreateChatTask(ctx context.Context, arg CreateChatTaskParams) &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } @@ -1268,7 +1269,7 @@ const setChatTaskInputOwnerSelf = `-- name: SetChatTaskInputOwnerSelf :one UPDATE agent_task_queue SET chat_input_task_id = id WHERE id = $1 -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Stamps a freshly-created direct-chat task as the owner of its own input batch @@ -1328,6 +1329,7 @@ func (q *Queries) SetChatTaskInputOwnerSelf(ctx context.Context, id pgtype.UUID) &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index b4152c78c77..07a44b1aa8e 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -147,7 +147,8 @@ type AgentTaskQueue struct { // The row id referenced by trigger_evidence_kind (a comment id, autopilot_run id, rule_version id, source task id, ...). No FK; resolvable per-kind in the app layer (MUL-4302 §2). TriggerEvidenceRefID pgtype.UUID `json:"trigger_evidence_ref_id"` // The one human accountable for this run, for audit / visibility / cost only — NEVER consulted for authorization (that is originator_user_id). Invariant: when originator_user_id IS NOT NULL, this equals it; the two diverge only when originator_user_id IS NULL (autopilot rule_owner / degraded owner_fallback name an accountable human while authorization carries none). No FK, no cascade (MUL-4302 §1/§7). NULL means no accountable human was resolved: a pre-migration row, OR a NEW row whose audit source is not-yet-resolved / unattributed (e.g. run_only autopilot until rule_owner lands) — NOT pre-migration only. - AccountableUserID pgtype.UUID `json:"accountable_user_id"` + AccountableUserID pgtype.UUID `json:"accountable_user_id"` + DispatchedAutopilotRunID pgtype.UUID `json:"dispatched_autopilot_run_id"` } type AgentToLabel struct { diff --git a/server/pkg/db/generated/runtime.sql.go b/server/pkg/db/generated/runtime.sql.go index e2999d62d18..44c3dd326ad 100644 --- a/server/pkg/db/generated/runtime.sql.go +++ b/server/pkg/db/generated/runtime.sql.go @@ -16,7 +16,7 @@ UPDATE agent_task_queue SET status = 'cancelled', completed_at = now() WHERE (runtime_id = ANY($1::uuid[]) OR agent_id = ANY($2::uuid[])) AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory') -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` type CancelAgentTasksByRuntimeOrAgentParams struct { @@ -95,6 +95,7 @@ func (q *Queries) CancelAgentTasksByRuntimeOrAgent(ctx context.Context, arg Canc &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } @@ -226,7 +227,7 @@ WHERE status IN ('dispatched', 'running', 'waiting_local_directory') AND runtime_id IN ( SELECT id FROM agent_runtime WHERE status = 'offline' ) -RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id ` // Marks dispatched/running/waiting_local_directory tasks as failed when @@ -289,6 +290,7 @@ func (q *Queries) FailTasksForOfflineRuntimes(ctx context.Context) ([]AgentTaskQ &i.TriggerEvidenceKind, &i.TriggerEvidenceRefID, &i.AccountableUserID, + &i.DispatchedAutopilotRunID, ); err != nil { return nil, err } diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 2a45a938a4a..facb7028abe 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -194,7 +194,8 @@ INSERT INTO agent_task_queue ( agent_id, runtime_id, issue_id, status, priority, trigger_comment_id, coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task, handoff_note, squad_id, context, originator_user_id, accountable_user_id, runtime_mcp_overlay, runtime_connected_apps, - originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id, trigger_evidence_kind, trigger_evidence_ref_id + originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id, trigger_evidence_kind, trigger_evidence_ref_id, + dispatched_autopilot_run_id ) VALUES ( $1, $2, $3, 'queued', $4, sqlc.narg(trigger_comment_id), @@ -218,7 +219,8 @@ VALUES ( sqlc.narg(rule_version_id), sqlc.narg(rerun_of_task_id), sqlc.narg(trigger_evidence_kind), - sqlc.narg(trigger_evidence_ref_id) + sqlc.narg(trigger_evidence_ref_id), + sqlc.narg(dispatched_autopilot_run_id) ) RETURNING *; diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index 7a4d620c743..c0c17dd3218 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -471,21 +471,18 @@ SELECT count(*) > 0 AS has_pending FROM agent_task_queue WHERE retry_of_task_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory'); --- name: FindAutopilotDispatchedTaskForIssue :one +-- name: GetTaskByDispatchedAutopilotRun :one -- Recovers the task a create_issue run dispatched when the run's task_id was never --- bound — a crash between enqueue and bind, or a run dispatched by a pre-§4.1 pod --- mid rolling-deploy (MUL-4809 §4.1 item 4). The dispatched task is the FIRST root --- attempt (retry_of_task_id IS NULL) queued for the autopilot's issue, by the run's --- dispatch target (agent_id), within the bind crash window after issue creation. --- Ordinary comment/chat tasks on the same issue are queued later, by a different --- agent, or outside the window, so none of them matches — that is what stops a --- stray task from being misattributed as the run's dispatched work and finalizing --- the run off the wrong outcome. +-- bound — a crash between task enqueue and run.task_id bind, or an unbound run at +-- gate-flip time (MUL-4809 §4.1). The dispatch stamps dispatched_autopilot_run_id +-- on the task atomically at INSERT, so this is a PRECISE provenance lookup, not a +-- time/agent heuristic: an ordinary comment/chat task is never stamped and can +-- never be returned here, so it can never be misattributed as the run's work. +-- The stamp is 1:1 with the run, but keep it deterministic under any accidental +-- duplicate by returning the earliest root attempt. SELECT * FROM agent_task_queue -WHERE issue_id = $1 - AND agent_id = $2 +WHERE dispatched_autopilot_run_id = $1 AND retry_of_task_id IS NULL - AND created_at <= $3 ORDER BY created_at ASC, id ASC LIMIT 1; From 749aab22c971f821cb7de716fe63cc00b45656f0 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 15:10:38 +0800 Subject: [PATCH 21/41] =?UTF-8?q?fix(autopilot):=20webhook=20crash=20repai?= =?UTF-8?q?r=20binds=20proven=20task=20via=20CAS,=20ignores=20issue=20stat?= =?UTF-8?q?us=20(MUL-4809=20=C2=A74.1=20P0-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server/internal/service/autopilot.go | 85 ++++++++--- .../autopilot_create_issue_sync_test.go | 4 +- .../service/autopilot_webhook_repair_test.go | 144 ++++++++++++++++++ 3 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 server/internal/service/autopilot_webhook_repair_test.go diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index 08f18ad9b22..46387975506 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -291,38 +291,81 @@ func (s *AutopilotService) DispatchAutopilotForWebhookDelivery( return dispatched, err } -// ensureWebhookCreateIssueTask repairs the create_issue crash window after the -// issue/run transaction commits but before the ordinary task enqueue commits. -// Any existing issue task is sufficient evidence that ownership has already -// moved downstream; otherwise enqueue exactly the same assignee path used by -// the original dispatch. +// ensureWebhookCreateIssueTask repairs the create_issue crash window on a reclaimed +// webhook delivery: the issue/run committed but the dispatched task's enqueue or the +// run.task_id bind did not. It always ends with the run bound (via CAS) to a task +// PROVEN to be this run's dispatched task — never on the loose "any issue task +// exists" evidence, and never gated on the issue status (MUL-4809 §4.1 P0-2; a run +// is finalized by task outcome, so the issue being in_review/blocked must not stop +// the repair and leave the run hanging). func (s *AutopilotService) ensureWebhookCreateIssueTask(ctx context.Context, autopilot db.Autopilot, run db.AutopilotRun) error { - tasks, err := s.Queries.ListTasksByIssue(ctx, run.IssueID) - if err != nil { - return fmt.Errorf("dispatch for webhook delivery: inspect issue tasks: %w", err) - } - if len(tasks) > 0 { + // Already bound (dispatch finished) or already finalized — nothing to repair. + if run.TaskID.Valid || isAutopilotRunTerminalStatus(run.Status) { return nil } + + // Enqueue committed but the bind crashed: the task carries this run's provenance + // stamp. Bind it precisely — a stray comment task on the same issue is unstamped + // and never matches here. + dispatched, err := s.Queries.GetTaskByDispatchedAutopilotRun(ctx, run.ID) + switch { + case err == nil: + return s.bindAndWakeWebhookTask(ctx, run, dispatched) + case !errors.Is(err, pgx.ErrNoRows): + return fmt.Errorf("dispatch for webhook delivery: lookup dispatched task: %w", err) + } + + // No task was ever dispatched (crash before enqueue). Enqueue the same assignee + // path the original dispatch would, stamped with this run's provenance, and bind + // the run to it in the same repair. issue, err := s.Queries.GetIssue(ctx, run.IssueID) if err != nil { return fmt.Errorf("dispatch for webhook delivery: load linked issue: %w", err) } - if issue.Status != "todo" && issue.Status != "in_progress" { - return nil - } + dispatchCtx := withDispatchedAutopilotRun(ctx, run.ID) + var task db.AgentTaskQueue if autopilot.AssigneeType == "squad" { - leader, _, err := s.resolveAutopilotLeader(ctx, autopilot) - if err != nil { - return fmt.Errorf("dispatch for webhook delivery: resolve squad leader: %w", err) + leader, _, lerr := s.resolveAutopilotLeader(ctx, autopilot) + if lerr != nil { + return fmt.Errorf("dispatch for webhook delivery: resolve squad leader: %w", lerr) } - if _, err := s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, autopilot.AssigneeID, pgtype.UUID{}); err != nil { - return fmt.Errorf("dispatch for webhook delivery: repair squad task: %w", err) + task, err = s.TaskSvc.EnqueueTaskForSquadLeader(dispatchCtx, issue, leader.ID, autopilot.AssigneeID, pgtype.UUID{}) + } else { + task, err = s.TaskSvc.EnqueueTaskForIssue(dispatchCtx, issue) + } + if err != nil { + // The agent already holds the one pending task allowed per (issue, agent) + // (idx_one_pending_task_per_issue_agent). That task will process the issue; + // we must not enqueue a duplicate, and must NOT bind the run to a task that + // isn't its own dispatch. Treat the delivery as handled rather than + // crash-looping the webhook worker; the stuck-run monitor reconciles a run + // left unbound by this rare race. + if isAutopilotUniqueViolation(err) { + slog.Warn("autopilot webhook repair: a pending task already exists; leaving run unbound", + "run_id", util.UUIDToString(run.ID), "issue_id", util.UUIDToString(run.IssueID)) + return nil } - return nil + return fmt.Errorf("dispatch for webhook delivery: repair dispatched task: %w", err) + } + return s.bindAndWakeWebhookTask(ctx, run, task) +} + +// isAutopilotUniqueViolation reports whether err is (or wraps) a Postgres unique +// constraint violation (SQLSTATE 23505). +func isAutopilotUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +// bindAndWakeWebhookTask CAS-binds the run to its dispatched task and, unless a +// racing finalizer already ended the run, wakes the daemon to claim the task. +func (s *AutopilotService) bindAndWakeWebhookTask(ctx context.Context, run db.AutopilotRun, task db.AgentTaskQueue) error { + bound, bindErr := s.bindAutopilotRunTask(ctx, run.ID, task.ID) + if bindErr != nil { + return fmt.Errorf("dispatch for webhook delivery: bind dispatched task: %w", bindErr) } - if _, err := s.TaskSvc.EnqueueTaskForIssue(ctx, issue); err != nil { - return fmt.Errorf("dispatch for webhook delivery: repair issue task: %w", err) + if !isAutopilotRunTerminalStatus(bound.Status) { + s.TaskSvc.NotifyTaskEnqueued(ctx, task) } return nil } diff --git a/server/internal/service/autopilot_create_issue_sync_test.go b/server/internal/service/autopilot_create_issue_sync_test.go index 1e0d30e9f57..b8620f290b8 100644 --- a/server/internal/service/autopilot_create_issue_sync_test.go +++ b/server/internal/service/autopilot_create_issue_sync_test.go @@ -70,8 +70,8 @@ func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.Autop var issueIDStr string if err := pool.QueryRow(ctx, ` - INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number, origin_type, origin_id) - VALUES ($1, 'ci run issue', 'todo', 'none', 'agent', $2, + INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id, number, origin_type, origin_id) + VALUES ($1, 'ci run issue', 'todo', 'none', 'agent', $2, 'agent', $2, COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1, 'autopilot', $3) RETURNING id::text `, workspaceID, agentID, util.UUIDToString(ap.ID)).Scan(&issueIDStr); err != nil { diff --git a/server/internal/service/autopilot_webhook_repair_test.go b/server/internal/service/autopilot_webhook_repair_test.go new file mode 100644 index 00000000000..e109bf58f13 --- /dev/null +++ b/server/internal/service/autopilot_webhook_repair_test.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// The webhook create_issue repair (ensureWebhookCreateIssueTask) must always leave +// the run bound (via CAS) to a task PROVEN to be its dispatched task, regardless of +// how long ago the crash happened or what the issue status is (MUL-4809 §4.1 P0-2). + +// TestWebhookRepairBindsDispatchedTaskAfterWindow covers delayed recovery: the +// dispatched task was enqueued (stamped) but the bind crashed, and reclaim happens +// long after issue creation. A time-window heuristic would never find it; precise +// provenance binds it. +func TestWebhookRepairBindsDispatchedTaskAfterWindow(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) + ap, err := svc.Queries.GetAutopilot(ctx, run.AutopilotID) + if err != nil { + t.Fatalf("get autopilot: %v", err) + } + + // Dispatched task queued 20 minutes ago, stamped, but run.task_id never bound. + dispatched := insertTask(agentID, -20*time.Minute, "queued", run.ID) + + if err := svc.ensureWebhookCreateIssueTask(ctx, ap, run); err != nil { + t.Fatalf("ensureWebhookCreateIssueTask: %v", err) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if !got.TaskID.Valid || got.TaskID.Bytes != dispatched.ID.Bytes { + t.Fatalf("webhook repair did not bind the (aged) dispatched task: task_id valid=%v", got.TaskID.Valid) + } + if got.Status != "running" { + t.Fatalf("webhook repair did not advance the run to running: status=%q", got.Status) + } +} + +// TestWebhookRepairIgnoresStrayCommentTask covers the in-window stray comment: a +// (settled) unstamped comment task exists on the issue. The repair must NOT adopt it +// (that was the old "any issue task exists → success" bug); it enqueues a fresh +// dispatched task carrying this run's provenance and binds that instead. +func TestWebhookRepairIgnoresStrayCommentTask(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) + ap, err := svc.Queries.GetAutopilot(ctx, run.AutopilotID) + if err != nil { + t.Fatalf("get autopilot: %v", err) + } + + // A settled (completed) unstamped comment task — the old code returned success + // on "any issue task exists" and left the run unbound forever. + comment := insertTask(agentID, 0, "completed", pgtype.UUID{}) + + if err := svc.ensureWebhookCreateIssueTask(ctx, ap, run); err != nil { + t.Fatalf("ensureWebhookCreateIssueTask: %v", err) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if !got.TaskID.Valid { + t.Fatal("webhook repair left the run unbound") + } + if got.TaskID.Bytes == comment.ID.Bytes { + t.Fatal("webhook repair bound the run to an unstamped stray comment task") + } + // The bound task must be a real dispatched task carrying this run's provenance. + bound, err := svc.Queries.GetAgentTask(ctx, got.TaskID) + if err != nil { + t.Fatalf("get bound task: %v", err) + } + if !bound.DispatchedAutopilotRunID.Valid || bound.DispatchedAutopilotRunID.Bytes != run.ID.Bytes { + t.Fatal("webhook repair bound a task without this run's provenance stamp") + } +} + +// TestWebhookRepairLeavesRunUnboundOnPendingCollision covers the rarest race: the +// agent already holds its one pending task per (issue, agent) — an unstamped comment +// task — so a fresh dispatched enqueue would violate the constraint. The repair must +// neither crash-loop the webhook worker nor bind the run to that pending comment +// task; it leaves the run unbound for the stuck-run monitor to reconcile. +func TestWebhookRepairLeavesRunUnboundOnPendingCollision(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) + ap, err := svc.Queries.GetAutopilot(ctx, run.AutopilotID) + if err != nil { + t.Fatalf("get autopilot: %v", err) + } + + insertTask(agentID, 0, "queued", pgtype.UUID{}) // pending unstamped comment task holds the slot + + if err := svc.ensureWebhookCreateIssueTask(ctx, ap, run); err != nil { + t.Fatalf("ensureWebhookCreateIssueTask must not error on a pending-task collision: %v", err) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.TaskID.Valid { + t.Fatalf("webhook repair bound the run despite the pending-task collision: task_id=%x", got.TaskID.Bytes) + } +} + +// TestWebhookRepairProceedsWhenIssueInReview covers the status-token bug: an issue +// sitting in in_review/blocked must NOT stop the repair. The run is finalized by +// task outcome, so the repair still enqueues + binds the dispatched task rather than +// declaring the delivery handled and leaving the run hung (MUL-4809 §4.1 P0-2). +func TestWebhookRepairProceedsWhenIssueInReview(t *testing.T) { + ctx := context.Background() + svc, _, run, pool, _ := newCreateIssueRunFixture(t) + ap, err := svc.Queries.GetAutopilot(ctx, run.AutopilotID) + if err != nil { + t.Fatalf("get autopilot: %v", err) + } + if _, err := pool.Exec(ctx, `UPDATE issue SET status = 'in_review' WHERE id = $1`, run.IssueID); err != nil { + t.Fatalf("set issue in_review: %v", err) + } + + if err := svc.ensureWebhookCreateIssueTask(ctx, ap, run); err != nil { + t.Fatalf("ensureWebhookCreateIssueTask: %v", err) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if !got.TaskID.Valid || got.Status != "running" { + t.Fatalf("webhook repair was blocked by issue status: task_id valid=%v status=%q", got.TaskID.Valid, got.Status) + } + var bound db.AgentTaskQueue + if bound, err = svc.Queries.GetAgentTask(ctx, got.TaskID); err != nil { + t.Fatalf("get bound task: %v", err) + } + if !bound.DispatchedAutopilotRunID.Valid || bound.DispatchedAutopilotRunID.Bytes != run.ID.Bytes { + t.Fatal("repaired task missing provenance stamp") + } +} From 650113fb65edce165c7a2529aa7448eabdc6f1d0 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 15:29:20 +0800 Subject: [PATCH 22/41] =?UTF-8?q?test(issue-status):=20sweeper=20resets=20?= =?UTF-8?q?in=5Freview=20like=20in=5Fprogress=20(MUL-4809=20=C2=A74.2=20P1?= =?UTF-8?q?=20decision)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server/cmd/server/runtime_sweeper_test.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/server/cmd/server/runtime_sweeper_test.go b/server/cmd/server/runtime_sweeper_test.go index 050bd33e967..51ef73fe423 100644 --- a/server/cmd/server/runtime_sweeper_test.go +++ b/server/cmd/server/runtime_sweeper_test.go @@ -565,10 +565,13 @@ func TestSweepResetsInProgressIssueToTodo(t *testing.T) { } } -// TestSweepDoesNotResetIssueAlreadyInReview verifies that the sweeper only resets -// issues that are truly stuck in in_progress — it must not clobber issues whose -// agents already moved them forward (e.g. to in_review) before the task timed out. -func TestSweepDoesNotResetIssueAlreadyInReview(t *testing.T) { +// TestSweepResetsInReviewIssueLikeInProgress verifies the §4.2 Category-uniform +// reset: in_review belongs to the in_progress Category, so an in_review issue whose +// task went stale (no active task) is reset to todo exactly like a literal +// in_progress issue. This is a deliberate product-decision change from the old +// token-specific "preserve in_review" carve-out — machine logic keys off Category, +// not the display token (MUL-4809 §4.2 P1). +func TestSweepResetsInReviewIssueLikeInProgress(t *testing.T) { if testPool == nil { t.Skip("no database connection") } @@ -630,14 +633,15 @@ func TestSweepDoesNotResetIssueAlreadyInReview(t *testing.T) { broadcastFailedTasks(ctx, queries, nil, bus, failedTasks) - // Issue should remain in_review — the sweeper must not clobber agent progress. + // in_review is an in_progress-Category status, so the stuck issue is reset to + // todo just like a literal in_progress issue (MUL-4809 §4.2 P1). var issueStatus string err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus) if err != nil { t.Fatalf("failed to query issue status: %v", err) } - if issueStatus != "in_review" { - t.Fatalf("expected issue status 'in_review' to be preserved, got '%s'", issueStatus) + if issueStatus != "todo" { + t.Fatalf("expected in_review issue to be reset to 'todo', got '%s'", issueStatus) } } From 199ee04abe02e8d8f0578c6973cf18b9b03311d0 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 15:29:30 +0800 Subject: [PATCH 23/41] =?UTF-8?q?feat(autopilot):=20default-off=20two-phas?= =?UTF-8?q?e=20gate=20for=20task-driven=20run=20finalization=20(MUL-4809?= =?UTF-8?q?=20=C2=A74.1=20P0-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- .env.example | 7 + .../autopilot_dispatch_for_plan_test.go | 2 + server/cmd/server/autopilot_flags_test.go | 15 ++ .../server/autopilot_legacy_dispatch_test.go | 85 +++++++++++ server/cmd/server/autopilot_listeners.go | 45 +++++- server/cmd/server/autopilot_listeners_test.go | 5 + server/cmd/server/main.go | 3 + server/cmd/server/router.go | 3 + server/internal/featureflags/keys.go | 17 +++ server/internal/service/autopilot.go | 132 +++++++++++++++--- .../autopilot_create_issue_sync_test.go | 2 + .../internal/service/autopilot_flags_test.go | 15 ++ .../service/autopilot_rollout_gate_test.go | 108 ++++++++++++++ 13 files changed, 411 insertions(+), 28 deletions(-) create mode 100644 server/cmd/server/autopilot_flags_test.go create mode 100644 server/cmd/server/autopilot_legacy_dispatch_test.go create mode 100644 server/internal/service/autopilot_flags_test.go create mode 100644 server/internal/service/autopilot_rollout_gate_test.go diff --git a/.env.example b/.env.example index 27500735d60..c185eb4b658 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,13 @@ MULTICA_CODEX_TIMEOUT=20m # or any variant string). The env override beats the YAML, which is the # Ops kill-switch path — flip a flag without redeploying by restarting the # process with the env var set. +# +# Release gate (MUL-4809 §4.1, delete after full rollout): finalize create_issue +# autopilot runs off task outcome instead of issue status. Default OFF (legacy). +# Two-phase deploy: ship this binary with the gate off and drain old pods, then +# restart with FF_AUTOPILOT_TASK_DRIVEN_RUNS=true so all (guarded-SQL) pods switch +# to task-driven finalization together. +# FF_AUTOPILOT_TASK_DRIVEN_RUNS=false MULTICA_FEATURE_FLAGS_FILE= # Self-host image channel diff --git a/server/cmd/server/autopilot_dispatch_for_plan_test.go b/server/cmd/server/autopilot_dispatch_for_plan_test.go index b3ae3c1809c..dcd562ff7c3 100644 --- a/server/cmd/server/autopilot_dispatch_for_plan_test.go +++ b/server/cmd/server/autopilot_dispatch_for_plan_test.go @@ -32,6 +32,7 @@ func TestDispatchAutopilotForPlanIsIdempotent(t *testing.T) { bus := events.New() taskSvc := service.NewTaskService(queries, testPool, nil, bus) autopilotSvc := service.NewAutopilotService(queries, testPool, bus, taskSvc) + autopilotSvc.FeatureFlags = autopilotTaskDrivenFlags(true) var agentID string if err := testPool.QueryRow(ctx, @@ -155,6 +156,7 @@ func TestDispatchAutopilotSuppressesRecentDuplicateIssue(t *testing.T) { bus := events.New() taskSvc := service.NewTaskService(queries, testPool, nil, bus) autopilotSvc := service.NewAutopilotService(queries, testPool, bus, taskSvc) + autopilotSvc.FeatureFlags = autopilotTaskDrivenFlags(true) var agentID string if err := testPool.QueryRow(ctx, diff --git a/server/cmd/server/autopilot_flags_test.go b/server/cmd/server/autopilot_flags_test.go new file mode 100644 index 00000000000..4ea00161a3d --- /dev/null +++ b/server/cmd/server/autopilot_flags_test.go @@ -0,0 +1,15 @@ +package main + +import ( + "github.com/multica-ai/multica/server/internal/featureflags" + "github.com/multica-ai/multica/server/pkg/featureflag" +) + +// autopilotTaskDrivenFlags builds a feature-flag Service that forces the +// AutopilotTaskDrivenRuns gate on or off, for exercising both rollout phases +// (MUL-4809 §4.1 P0-3). +func autopilotTaskDrivenFlags(enabled bool) *featureflag.Service { + provider := featureflag.NewStaticProvider() + provider.Set(featureflags.AutopilotTaskDrivenRuns, featureflag.Rule{Default: enabled}) + return featureflag.NewService(provider) +} diff --git a/server/cmd/server/autopilot_legacy_dispatch_test.go b/server/cmd/server/autopilot_legacy_dispatch_test.go new file mode 100644 index 00000000000..9a6ace0ce0e --- /dev/null +++ b/server/cmd/server/autopilot_legacy_dispatch_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/service" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// TestLegacyDispatchLeavesRunIssueStatusDriven is the end-to-end enablement-boundary +// case for the dispatch path (MUL-4809 §4.1 P0-3): with the gate OFF, a create_issue +// dispatch still creates the issue and enqueues (and provenance-stamps) the task, +// but it must NOT bind the run or advance it to running — the run stays in +// issue_created for issue-status-driven finalization, exactly like the old pods a +// gate-off new pod runs alongside. The stamp lets a later gate flip bind it. +func TestLegacyDispatchLeavesRunIssueStatusDriven(t *testing.T) { + if testPool == nil { + t.Skip("no database connection") + } + ctx := context.Background() + queries := db.New(testPool) + bus := events.New() + taskSvc := service.NewTaskService(queries, testPool, nil, bus) + autopilotSvc := service.NewAutopilotService(queries, testPool, bus, taskSvc) + autopilotSvc.FeatureFlags = autopilotTaskDrivenFlags(false) // legacy / rolling-deploy default + + var agentID string + if err := testPool.QueryRow(ctx, + `SELECT id::text FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`, + testWorkspaceID, + ).Scan(&agentID); err != nil { + t.Fatalf("load fixture agent: %v", err) + } + + ap, err := queries.CreateAutopilot(ctx, db.CreateAutopilotParams{ + WorkspaceID: parseUUID(testWorkspaceID), + Title: "Legacy-mode dispatch", + AssigneeType: "agent", + AssigneeID: parseUUID(agentID), + Status: "active", + ExecutionMode: "create_issue", + IssueTitleTemplate: pgtype.Text{String: "Legacy issue", Valid: true}, + CreatedByType: "member", + CreatedByID: parseUUID(testUserID), + }) + if err != nil { + t.Fatalf("CreateAutopilot: %v", err) + } + t.Cleanup(func() { _, _ = testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID) }) + + run, err := autopilotSvc.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "schedule", nil) + if err != nil { + t.Fatalf("DispatchAutopilot: %v", err) + } + if run == nil || !run.IssueID.Valid { + t.Fatalf("legacy dispatch did not create an issue: %+v", run) + } + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, run.IssueID) + _, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, run.IssueID) + }) + + // Legacy: run stays issue_created and unbound. + if run.Status != "issue_created" { + t.Fatalf("legacy dispatch advanced the run: status=%q, want issue_created", run.Status) + } + if run.TaskID.Valid { + t.Fatalf("legacy dispatch bound the run's task_id; it must stay unbound in legacy mode") + } + + // The dispatched task exists and carries the run's provenance stamp. + tasks, err := queries.ListTasksByIssue(ctx, run.IssueID) + if err != nil { + t.Fatalf("ListTasksByIssue: %v", err) + } + if len(tasks) != 1 { + t.Fatalf("expected one dispatched task, got %d", len(tasks)) + } + if !tasks[0].DispatchedAutopilotRunID.Valid || tasks[0].DispatchedAutopilotRunID.Bytes != run.ID.Bytes { + t.Fatal("legacy-dispatched task not provenance-stamped; a later gate flip could not bind it") + } +} diff --git a/server/cmd/server/autopilot_listeners.go b/server/cmd/server/autopilot_listeners.go index 119ed546703..7fc8c507802 100644 --- a/server/cmd/server/autopilot_listeners.go +++ b/server/cmd/server/autopilot_listeners.go @@ -2,19 +2,51 @@ package main import ( "context" + "log/slog" "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/handler" "github.com/multica-ai/multica/server/internal/service" "github.com/multica-ai/multica/server/pkg/protocol" ) -// registerAutopilotListeners hooks into task terminal events to keep autopilot -// runs in sync with the task each run dispatched. Runs are finalized purely by -// task outcome (MUL-4809 §4.1) — issue status no longer ends or fails a run, so -// there is no EventIssueUpdated subscription here anymore. +// registerAutopilotListeners hooks into issue and task events to keep autopilot +// runs in sync. Which listener actually finalizes a create_issue run is decided at +// event time by the AutopilotTaskDrivenRuns gate (MUL-4809 §4.1 P0-3), so BOTH the +// issue-status listener (legacy) and the task-terminal listeners (task-driven) are +// registered and self-gate: exactly one path is live for a given process. func registerAutopilotListeners(bus *events.Bus, svc *service.AutopilotService) { ctx := context.Background() + // Legacy path: when an autopilot-origin issue reaches a run-finalizing status, + // SyncRunFromIssue finalizes the run — but only while the task-driven gate is + // off (it self-gates to a no-op when on). Registered so a gate-off pod finalizes + // runs like the old pods it runs alongside during a rolling deploy. + bus.Subscribe(protocol.EventIssueUpdated, func(e events.Event) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + if statusChanged, _ := payload["status_changed"].(bool); !statusChanged { + return + } + issue, ok := payload["issue"].(handler.IssueResponse) + if !ok { + return + } + // Cheap pre-filter: only these statuses finalize a run (SyncRunFromIssue's + // switch), so skip the DB load for any other transition. + if issue.Status != "done" && issue.Status != "in_review" && issue.Status != "cancelled" && issue.Status != "blocked" { + return + } + dbIssue, err := svc.Queries.GetIssue(ctx, parseUUID(issue.ID)) + if err != nil { + slog.Debug("autopilot listener: failed to load issue", "issue_id", issue.ID, "error", err) + return + } + svc.SyncRunFromIssue(ctx, dbIssue) + }) + bus.Subscribe(protocol.EventTaskCompleted, func(e events.Event) { syncRunFromTaskEvent(ctx, svc, e) }) @@ -39,8 +71,9 @@ func syncRunFromTaskEvent(ctx context.Context, svc *service.AutopilotService, e if err != nil { return } - // run_only tasks carry autopilot_run_id; create_issue tasks carry only - // issue_id and are matched to their run via run.task_id + retry lineage. + // run_only tasks carry autopilot_run_id and are ALWAYS task-driven (unchanged by + // §4.1). create_issue tasks carry only issue_id; SyncRunFromCreateIssueTask + // self-gates so it finalizes only when the task-driven gate is on. if task.AutopilotRunID.Valid { svc.SyncRunFromTask(ctx, task) return diff --git a/server/cmd/server/autopilot_listeners_test.go b/server/cmd/server/autopilot_listeners_test.go index 69a2496eba9..52fc734c6b0 100644 --- a/server/cmd/server/autopilot_listeners_test.go +++ b/server/cmd/server/autopilot_listeners_test.go @@ -146,6 +146,9 @@ func dispatchCreateIssueAutopilot(t *testing.T, title string) linkedIssueAutopil bus := events.New() taskSvc := service.NewTaskService(queries, testPool, nil, bus) autopilotSvc := service.NewAutopilotService(queries, testPool, bus, taskSvc) + // This fixture asserts task-driven behavior (bind + running); force the gate on + // for both dispatch and the terminal listeners registered below (MUL-4809 §4.1). + autopilotSvc.FeatureFlags = autopilotTaskDrivenFlags(true) registerAutopilotListeners(bus, autopilotSvc) var agentID string @@ -481,6 +484,7 @@ func TestAutopilotCreateIssueDispatchCreatesIssueWhenRuntimeOffline(t *testing.T bus := events.New() taskSvc := service.NewTaskService(queries, testPool, nil, bus) autopilotSvc := service.NewAutopilotService(queries, testPool, bus, taskSvc) + autopilotSvc.FeatureFlags = autopilotTaskDrivenFlags(true) var runtimeID, agentID string if err := testPool.QueryRow(ctx, ` @@ -593,6 +597,7 @@ func TestManualTriggerDoesNotErrorOnPostAdmissionSkip(t *testing.T) { bus := events.New() taskSvc := service.NewTaskService(queries, testPool, nil, bus) autopilotSvc := service.NewAutopilotService(queries, testPool, bus, taskSvc) + autopilotSvc.FeatureFlags = autopilotTaskDrivenFlags(true) var runtimeID, agentID string if err := testPool.QueryRow(ctx, ` diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 2848c20bdc4..ab5c0f7b813 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -410,6 +410,9 @@ func main() { taskSvc.Analytics = analyticsClient taskSvc.Metrics = businessMetrics autopilotSvc := service.NewAutopilotService(queries, pool, bus, taskSvc) + // The background task-terminal / issue listeners consult the two-phase rollout + // gate (MUL-4809 §4.1 P0-3); without this they would default to legacy mode. + autopilotSvc.FeatureFlags = flags registerAutopilotListeners(bus, autopilotSvc) // Construct a LivenessStore that mirrors the one wired into the HTTP diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 4eb29448282..a3420a66779 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -220,6 +220,9 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus h.Metrics = opts.BusinessMetrics h.FeatureFlags = opts.FeatureFlags h.TaskService.FeatureFlags = opts.FeatureFlags + // The HTTP-triggered create_issue dispatch path consults the two-phase rollout + // gate for whether to bind the run to its dispatched task (MUL-4809 §4.1 P0-3). + h.AutopilotService.FeatureFlags = opts.FeatureFlags h.TaskService.Metrics = opts.BusinessMetrics h.IssueService.Metrics = opts.BusinessMetrics if opts.BusinessMetrics != nil { diff --git a/server/internal/featureflags/keys.go b/server/internal/featureflags/keys.go index 7c864729c37..79fbbca9376 100644 --- a/server/internal/featureflags/keys.go +++ b/server/internal/featureflags/keys.go @@ -24,6 +24,15 @@ const ( // key as enabled so installed v0.4.0 desktop clients, which still gate the // switch on this config decision, receive the permanently enabled behavior. agentSkillTogglesCompat = "agents_skill_toggles" + // AutopilotTaskDrivenRuns is the two-phase rollout gate for finalizing + // create_issue autopilot runs off task outcome (MUL-4809 §4.1). Default OFF: + // the process finalizes create_issue runs the legacy way (issue status → + // SyncRunFromIssue) so it stays consistent with old pods whose terminal SQL is + // unguarded. Phase 1 deploys this binary with the gate off and drains old pods; + // Phase 2 flips FF_AUTOPILOT_TASK_DRIVEN_RUNS=true so all pods (now on guarded + // CAS SQL) switch to task-driven finalization together. Server-only; not a + // frontend public flag. Delete once fully rolled out. + AutopilotTaskDrivenRuns = "autopilot_task_driven_runs" ) var frontendPublicFlags = []string{ @@ -39,6 +48,14 @@ func ResourceLabelsEnabled(ctx context.Context, flags *featureflag.Service) bool return flags.IsEnabled(ctx, ResourceLabels, false) } +// AutopilotTaskDrivenRunsEnabled reports whether create_issue autopilot runs are +// finalized off task outcome (the new path) rather than issue status (legacy). +// Default OFF for the two-phase rollout (MUL-4809 §4.1). Nil-safe: a nil Service +// returns the default. +func AutopilotTaskDrivenRunsEnabled(ctx context.Context, flags *featureflag.Service) bool { + return flags.IsEnabled(ctx, AutopilotTaskDrivenRuns, false) +} + func EvaluateFrontendPublicFlags(ctx context.Context, flags *featureflag.Service) map[string]bool { out := make(map[string]bool, len(frontendPublicFlags)+2) for _, key := range frontendPublicFlags { diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index 46387975506..fc83eb5284e 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -17,11 +17,13 @@ import ( "github.com/multica-ai/multica/server/internal/attribution" "github.com/multica-ai/multica/server/internal/dispatch" "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/featureflags" "github.com/multica-ai/multica/server/internal/issueguard" "github.com/multica-ai/multica/server/internal/issueposition" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/featureflag" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -35,6 +37,18 @@ type AutopilotService struct { TxStarter TxStarter Bus *events.Bus TaskSvc *TaskService + // FeatureFlags is the two-phase rollout gate router (MUL-4809 §4.1). Nil is + // valid and resolves every gate to its default (task-driven runs OFF = legacy + // issue-status finalization), so a service constructed without it stays on the + // safe path during a rolling deploy. + FeatureFlags *featureflag.Service +} + +// taskDrivenRunsEnabled reports whether this process finalizes create_issue runs +// off task outcome (new path) rather than issue status (legacy). Default OFF for +// the two-phase rollout (MUL-4809 §4.1 P0-3). +func (s *AutopilotService) taskDrivenRunsEnabled(ctx context.Context) bool { + return featureflags.AutopilotTaskDrivenRunsEnabled(ctx, s.FeatureFlags) } // DefaultAutopilotTriggerTimezone is the timezone used to render Autopilot @@ -292,32 +306,37 @@ func (s *AutopilotService) DispatchAutopilotForWebhookDelivery( } // ensureWebhookCreateIssueTask repairs the create_issue crash window on a reclaimed -// webhook delivery: the issue/run committed but the dispatched task's enqueue or the -// run.task_id bind did not. It always ends with the run bound (via CAS) to a task -// PROVEN to be this run's dispatched task — never on the loose "any issue task -// exists" evidence, and never gated on the issue status (MUL-4809 §4.1 P0-2; a run -// is finalized by task outcome, so the issue being in_review/blocked must not stop -// the repair and leave the run hanging). +// webhook delivery: the issue/run committed but the dispatched task's enqueue (and, +// in task-driven mode, the run.task_id bind) did not. It guarantees the dispatched +// task exists, keyed on PROVEN provenance — never the loose "any issue task exists" +// evidence — and never gated on the issue status (MUL-4809 §4.1 P0-2). In +// task-driven mode it also binds the run to that task via CAS; in legacy mode (gate +// off, rolling-deploy default) it does NOT bind — the run is finalized by issue +// status like the old pods it runs alongside (MUL-4809 §4.1 P0-3). func (s *AutopilotService) ensureWebhookCreateIssueTask(ctx context.Context, autopilot db.Autopilot, run db.AutopilotRun) error { - // Already bound (dispatch finished) or already finalized — nothing to repair. + // Already bound (task-driven dispatch finished) or already finalized — nothing + // to repair. if run.TaskID.Valid || isAutopilotRunTerminalStatus(run.Status) { return nil } + taskDriven := s.taskDrivenRunsEnabled(ctx) // Enqueue committed but the bind crashed: the task carries this run's provenance - // stamp. Bind it precisely — a stray comment task on the same issue is unstamped - // and never matches here. + // stamp. A stray comment task on the same issue is unstamped and never matches. dispatched, err := s.Queries.GetTaskByDispatchedAutopilotRun(ctx, run.ID) switch { case err == nil: - return s.bindAndWakeWebhookTask(ctx, run, dispatched) + if taskDriven { + return s.bindAndWakeWebhookTask(ctx, run, dispatched) + } + return nil // legacy: the dispatched task exists and was already notified; issue status finalizes the run case !errors.Is(err, pgx.ErrNoRows): return fmt.Errorf("dispatch for webhook delivery: lookup dispatched task: %w", err) } // No task was ever dispatched (crash before enqueue). Enqueue the same assignee - // path the original dispatch would, stamped with this run's provenance, and bind - // the run to it in the same repair. + // path the original dispatch would, stamped with this run's provenance. The + // enqueue itself notifies the daemon. issue, err := s.Queries.GetIssue(ctx, run.IssueID) if err != nil { return fmt.Errorf("dispatch for webhook delivery: load linked issue: %w", err) @@ -347,7 +366,15 @@ func (s *AutopilotService) ensureWebhookCreateIssueTask(ctx context.Context, aut } return fmt.Errorf("dispatch for webhook delivery: repair dispatched task: %w", err) } - return s.bindAndWakeWebhookTask(ctx, run, task) + if !taskDriven { + return nil // legacy: the enqueue already notified the daemon; issue status finalizes the run + } + // Task-driven: bind the run to the task we just enqueued (which already notified + // the daemon, so bind without re-waking). + if _, bindErr := s.bindAutopilotRunTask(ctx, run.ID, task.ID); bindErr != nil { + return fmt.Errorf("dispatch for webhook delivery: bind repaired task: %w", bindErr) + } + return nil } // isAutopilotUniqueViolation reports whether err is (or wraps) a Postgres unique @@ -801,15 +828,15 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi } } - // Bind the run to its dispatched task and advance issue_created -> running - // (MUL-4809 §4.1). The run now finalizes on THIS task's terminal state (and - // its retry lineage), not on the issue status. The task itself keeps no - // autopilot_run_id, so it stays an ordinary issue task for context / - // classification; the run owns the pointer instead. A crash between the - // enqueue above and this bind leaves the run in issue_created (task_id NULL) — - // the same pre-existing hang class as a crash before enqueue, and the sync's - // issue-scoped fallback still finalizes such a run. - if dispatchedTask.ID.Valid { + // Bind the run to its dispatched task and advance issue_created -> running, but + // ONLY when task-driven finalization is enabled (MUL-4809 §4.1 P0-3). When the + // gate is off (legacy / rolling-deploy default) the run stays in issue_created + // and is finalized by issue status (SyncRunFromIssue), matching the old pods it + // runs alongside. The task was provenance-stamped above in EITHER mode, so a + // later gate flip can bind + finalize the run precisely. The task itself keeps + // no autopilot_run_id — it stays an ordinary issue task; the run owns the + // pointer instead. + if dispatchedTask.ID.Valid && s.taskDrivenRunsEnabled(ctx) { updatedRun, bindErr := s.bindAutopilotRunTask(ctx, run.ID, dispatchedTask.ID) if bindErr != nil { return bindErr @@ -1147,6 +1174,13 @@ func (s *AutopilotService) retryRootTaskID(ctx context.Context, task db.AgentTas // issue-scoped "no task still active" check so the run still finalizes rather // than hanging. func (s *AutopilotService) SyncRunFromCreateIssueTask(ctx context.Context, task db.AgentTaskQueue) { + // Two-phase rollout gate (MUL-4809 §4.1 P0-3): while task-driven finalization is + // off, create_issue runs are finalized from issue status (SyncRunFromIssue), so + // task outcome must not touch them — otherwise a gate-off new pod and an old pod + // would run two termination semantics at once. + if !s.taskDrivenRunsEnabled(ctx) { + return + } // run_only tasks carry autopilot_run_id and are handled by SyncRunFromTask. if task.AutopilotRunID.Valid || !task.IssueID.Valid || !isAutopilotTaskTerminal(task.Status) { return @@ -1290,6 +1324,60 @@ func (s *AutopilotService) SyncRunFromTask(ctx context.Context, task db.AgentTas } } +// SyncRunFromIssue finalizes a create_issue autopilot run from its linked issue's +// status — the LEGACY path, used only while task-driven finalization is gated off +// (MUL-4809 §4.1 P0-3). When the task-driven gate is ON this is a no-op: the run is +// finalized by task outcome (SyncRunFromCreateIssueTask) and issue status must not +// touch it. Kept behaviorally identical to the pre-§4.1 logic so that during a +// rolling deploy, a gate-off new pod finalizes runs exactly like the old pods it +// runs alongside — the guarded terminal SQL only adds first-writer-wins safety. +func (s *AutopilotService) SyncRunFromIssue(ctx context.Context, issue db.Issue) { + if s.taskDrivenRunsEnabled(ctx) { + return + } + if !issue.OriginType.Valid || issue.OriginType.String != "autopilot" { + return + } + run, err := s.Queries.GetAutopilotRunByIssue(ctx, issue.ID) + if err != nil { + return // no active run linked to this issue + } + autopilot, err := s.Queries.GetAutopilot(ctx, run.AutopilotID) + if err != nil { + return + } + wsID := util.UUIDToString(issue.WorkspaceID) + + switch issue.Status { + case "done", "in_review": + updatedRun, err := s.Queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{ID: run.ID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return // CAS lost: the run was already finalized by another path + } + slog.Warn("failed to complete autopilot run from issue", "run_id", util.UUIDToString(run.ID), "error", err) + return + } + s.captureAutopilotRunCompleted(autopilot, updatedRun) + s.publishRunDone(wsID, updatedRun, "completed") + case "cancelled", "blocked": + reason := "issue " + issue.Status + updatedRun, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{ + ID: run.ID, + FailureReason: pgtype.Text{String: reason, Valid: true}, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return // CAS lost: the run was already finalized by another path + } + slog.Warn("failed to fail autopilot run from issue", "run_id", util.UUIDToString(run.ID), "error", err) + return + } + s.captureAutopilotRunFailed(autopilot, updatedRun, updatedRun.Source, reason) + s.publishRunDone(wsID, updatedRun, "failed") + } +} + func taskFailureReasonForAutopilotRun(task db.AgentTaskQueue) string { if task.Error.Valid && strings.TrimSpace(task.Error.String) != "" { return task.Error.String diff --git a/server/internal/service/autopilot_create_issue_sync_test.go b/server/internal/service/autopilot_create_issue_sync_test.go index b8620f290b8..240e579a55f 100644 --- a/server/internal/service/autopilot_create_issue_sync_test.go +++ b/server/internal/service/autopilot_create_issue_sync_test.go @@ -113,6 +113,8 @@ func newCreateIssueRunFixture(t *testing.T) (*AutopilotService, string, db.Autop bus := events.New() taskSvc := NewTaskService(queries, pool, nil, bus) svc := NewAutopilotService(queries, pool, bus, taskSvc) + // These fixtures exercise the task-driven finalization path; force the gate on. + svc.FeatureFlags = autopilotTaskDrivenFlags(true) return svc, agentID, run, pool, insertTask } diff --git a/server/internal/service/autopilot_flags_test.go b/server/internal/service/autopilot_flags_test.go new file mode 100644 index 00000000000..3875b7fc0e4 --- /dev/null +++ b/server/internal/service/autopilot_flags_test.go @@ -0,0 +1,15 @@ +package service + +import ( + "github.com/multica-ai/multica/server/internal/featureflags" + "github.com/multica-ai/multica/server/pkg/featureflag" +) + +// autopilotTaskDrivenFlags builds a feature-flag Service that forces the +// AutopilotTaskDrivenRuns gate on or off, for testing both rollout phases +// (MUL-4809 §4.1 P0-3). +func autopilotTaskDrivenFlags(enabled bool) *featureflag.Service { + provider := featureflag.NewStaticProvider() + provider.Set(featureflags.AutopilotTaskDrivenRuns, featureflag.Rule{Default: enabled}) + return featureflag.NewService(provider) +} diff --git a/server/internal/service/autopilot_rollout_gate_test.go b/server/internal/service/autopilot_rollout_gate_test.go new file mode 100644 index 00000000000..b7188075ee3 --- /dev/null +++ b/server/internal/service/autopilot_rollout_gate_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "context" + "testing" +) + +// These tests cover the two-phase rollout gate (MUL-4809 §4.1 P0-3): exactly one +// finalization path is live per process, and a run dispatched while the gate was +// off is still finalized correctly after the gate flips on. + +// TestLegacyModeTaskOutcomeDoesNotFinalizeRun: with the gate OFF, a terminal +// create_issue task must NOT finalize the run — issue status owns finalization, so +// task outcome must be inert (otherwise a gate-off new pod and an old pod would run +// two termination semantics at once). +func TestLegacyModeTaskOutcomeDoesNotFinalizeRun(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) + svc.FeatureFlags = autopilotTaskDrivenFlags(false) // legacy + + dispatched := insertTask(agentID, 0, "completed", run.ID) + svc.SyncRunFromCreateIssueTask(ctx, dispatched) + + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "issue_created" { + t.Fatalf("legacy mode: task outcome finalized the run (status=%q); issue status should own finalization", got.Status) + } +} + +// TestLegacyModeIssueStatusFinalizesRun: with the gate OFF, SyncRunFromIssue +// finalizes the run from the linked issue's terminal status — the behavior a +// gate-off pod must keep so it matches the old pods it runs alongside. +func TestLegacyModeIssueStatusFinalizesRun(t *testing.T) { + ctx := context.Background() + svc, _, run, pool, _ := newCreateIssueRunFixture(t) + svc.FeatureFlags = autopilotTaskDrivenFlags(false) // legacy + + if _, err := pool.Exec(ctx, `UPDATE issue SET status = 'done' WHERE id = $1`, run.IssueID); err != nil { + t.Fatalf("set issue done: %v", err) + } + issue, err := svc.Queries.GetIssue(ctx, run.IssueID) + if err != nil { + t.Fatalf("get issue: %v", err) + } + svc.SyncRunFromIssue(ctx, issue) + + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "completed" { + t.Fatalf("legacy mode: issue status did not finalize the run: status=%q", got.Status) + } +} + +// TestTaskDrivenModeIssueStatusIsNoOp: with the gate ON, issue status must NOT +// touch the run — the run is finalized by task outcome, so an agent moving the +// issue to done/in_review/etc. is a pure issue-workflow action. +func TestTaskDrivenModeIssueStatusIsNoOp(t *testing.T) { + ctx := context.Background() + svc, _, run, pool, _ := newCreateIssueRunFixture(t) // gate ON by default + + if _, err := pool.Exec(ctx, `UPDATE issue SET status = 'done' WHERE id = $1`, run.IssueID); err != nil { + t.Fatalf("set issue done: %v", err) + } + issue, err := svc.Queries.GetIssue(ctx, run.IssueID) + if err != nil { + t.Fatalf("get issue: %v", err) + } + svc.SyncRunFromIssue(ctx, issue) + + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "issue_created" { + t.Fatalf("task-driven mode: issue status finalized the run (status=%q); it must be inert", got.Status) + } +} + +// TestGateFlipFinalizesLegacyDispatchedRun is the enablement-boundary case: a run +// dispatched while the gate was off is left unbound (issue_created), and its task is +// provenance-stamped in either mode. After the gate flips on, the task's terminal +// event repairs the binding precisely and finalizes the run — nothing is stranded +// across the flip. +func TestGateFlipFinalizesLegacyDispatchedRun(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) // gate ON (post-flip) + + // A task the legacy dispatch stamped but never bound (run stays issue_created). + dispatched := insertTask(agentID, 0, "completed", run.ID) + + svc.SyncRunFromCreateIssueTask(ctx, dispatched) + + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "completed" { + t.Fatalf("gate flip: legacy-dispatched run not finalized after enable: status=%q", got.Status) + } + if !got.TaskID.Valid || got.TaskID.Bytes != dispatched.ID.Bytes { + t.Fatalf("gate flip: run not repaired to its stamped task: task_id valid=%v", got.TaskID.Valid) + } +} From f4565fd0774d7ecf319a4d907e1b0cd08fe58a11 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 15:47:22 +0800 Subject: [PATCH 24/41] =?UTF-8?q?test(autopilot):=20enable=20task-driven?= =?UTF-8?q?=20gate=20in=20handler=20test=20harness=20(MUL-4809=20=C2=A74.1?= =?UTF-8?q?=20P0-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server/internal/handler/handler_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index e6884f9f7bc..665011c47f3 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -17,9 +17,11 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/featureflags" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/featureflag" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -59,6 +61,13 @@ func TestMain(m *testing.M) { bus := events.New() emailSvc := service.NewEmailService() testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil, analytics.NoopClient{}, Config{AllowSignup: true}) + // The autopilot handler tests assert task-driven finalization (bind + running), + // so enable the two-phase rollout gate for the suite (MUL-4809 §4.1 P0-3). + { + provider := featureflag.NewStaticProvider() + provider.Set(featureflags.AutopilotTaskDrivenRuns, featureflag.Rule{Default: true}) + testHandler.AutopilotService.FeatureFlags = featureflag.NewService(provider) + } // httptest.NewRequest defaults RemoteAddr to 192.0.2.1, so every webhook // test in the suite shares one IP bucket. With the production default // (30/min) the budget runs out partway through the suite and unrelated From a65b45e1b8e1a9f3f34cd9d561ac970936d1e4cd Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 17:12:48 +0800 Subject: [PATCH 25/41] =?UTF-8?q?fix(autopilot):=20converge=20webhook=20pe?= =?UTF-8?q?nding-collision=20and=20gate-flip=20stranded=20runs=20(MUL-4809?= =?UTF-8?q?=20=C2=A74.1=20P0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - webhook pending-collision now fails the run as a traceable dispatch collision (propagated to the delivery status) instead of leaving it permanently active - ON-boot reconcile finalizes create_issue runs whose task terminated while the gate was off (no event replay), advisory-locked, gated, walks the retry leaf - worker-level + reconcile counter-example tests; drop the misleading gate-flip test Co-authored-by: multica-agent --- server/cmd/server/main.go | 16 +++ server/internal/service/autopilot.go | 130 ++++++++++++++++-- .../service/autopilot_reconcile_test.go | 108 +++++++++++++++ .../service/autopilot_rollout_gate_test.go | 31 +---- .../service/autopilot_webhook_repair_test.go | 89 ++++++++++-- server/pkg/db/generated/autopilot.sql.go | 116 ++++++++++++++++ server/pkg/db/queries/autopilot.sql | 21 +++ 7 files changed, 463 insertions(+), 48 deletions(-) create mode 100644 server/internal/service/autopilot_reconcile_test.go diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index ab5c0f7b813..75fa51aa385 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -437,6 +437,22 @@ func main() { slog.Info("issue status backfill complete", "workspaces", n) }() + // ON-boot reconcile for task-driven autopilot runs (MUL-4809 §4.1 P0-3). When + // the two-phase gate is flipped on, the event bus does not replay task events + // that fired while it was off, so this converges any create_issue run whose + // dispatched task already terminated. No-op while the gate is off; advisory- + // locked so a single replica walks during a rolling deploy. + go func() { + n, err := autopilotSvc.ReconcileTaskDrivenRunsAtBoot(sweepCtx, pool) + if err != nil { + slog.Error("autopilot task-driven run reconcile failed", "error", err) + return + } + if n > 0 { + slog.Info("autopilot task-driven run reconcile complete", "runs_finalized", n) + } + }() + // Start background sweeper to mark stale runtimes as offline. go runRuntimeSweeper(sweepCtx, queries, liveness, taskSvc, bus) go heartbeatScheduler.Run(sweepCtx) diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index fc83eb5284e..eb1d32747b0 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -13,6 +13,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/attribution" "github.com/multica-ai/multica/server/internal/dispatch" @@ -270,7 +271,7 @@ func (s *AutopilotService) DispatchAutopilotForWebhookDelivery( } if isAutopilotRunComplete(*run) { if autopilot.ExecutionMode == "create_issue" && run.IssueID.Valid { - if repairErr := s.ensureWebhookCreateIssueTask(ctx, autopilot, *run); repairErr != nil { + if repairErr := s.ensureWebhookCreateIssueTask(ctx, autopilot, run); repairErr != nil { return run, repairErr } } @@ -312,8 +313,10 @@ func (s *AutopilotService) DispatchAutopilotForWebhookDelivery( // evidence — and never gated on the issue status (MUL-4809 §4.1 P0-2). In // task-driven mode it also binds the run to that task via CAS; in legacy mode (gate // off, rolling-deploy default) it does NOT bind — the run is finalized by issue -// status like the old pods it runs alongside (MUL-4809 §4.1 P0-3). -func (s *AutopilotService) ensureWebhookCreateIssueTask(ctx context.Context, autopilot db.Autopilot, run db.AutopilotRun) error { +// status like the old pods it runs alongside (MUL-4809 §4.1 P0-3). run is a pointer +// so a terminal outcome (a dispatch collision failing the run) propagates to the +// caller and, in turn, to the webhook delivery status. +func (s *AutopilotService) ensureWebhookCreateIssueTask(ctx context.Context, autopilot db.Autopilot, run *db.AutopilotRun) error { // Already bound (task-driven dispatch finished) or already finalized — nothing // to repair. if run.TaskID.Valid || isAutopilotRunTerminalStatus(run.Status) { @@ -327,7 +330,7 @@ func (s *AutopilotService) ensureWebhookCreateIssueTask(ctx context.Context, aut switch { case err == nil: if taskDriven { - return s.bindAndWakeWebhookTask(ctx, run, dispatched) + return s.bindAndWakeWebhookTask(ctx, *run, dispatched) } return nil // legacy: the dispatched task exists and was already notified; issue status finalizes the run case !errors.Is(err, pgx.ErrNoRows): @@ -354,13 +357,21 @@ func (s *AutopilotService) ensureWebhookCreateIssueTask(ctx context.Context, aut } if err != nil { // The agent already holds the one pending task allowed per (issue, agent) - // (idx_one_pending_task_per_issue_agent). That task will process the issue; - // we must not enqueue a duplicate, and must NOT bind the run to a task that - // isn't its own dispatch. Treat the delivery as handled rather than - // crash-looping the webhook worker; the stuck-run monitor reconciles a run - // left unbound by this rare race. + // (idx_one_pending_task_per_issue_agent), so the autopilot's own dispatched + // task can't be enqueued and this run has no provably-dispatched task to bind. + // There is no stuck-run monitor, so leaving the run active would strand it + // forever. Close the loop by failing the run as a traceable dispatch + // collision — the webhook worker then records the delivery as failed. We must + // NOT bind the run to the unrelated pending task (that would misattribute the + // run's outcome to a comment task). if isAutopilotUniqueViolation(err) { - slog.Warn("autopilot webhook repair: a pending task already exists; leaving run unbound", + reason := "dispatch collision: agent already has a pending task for this issue" + if failed, won := s.failRun(ctx, run.ID, reason); won { + *run = failed + s.captureAutopilotRunFailed(autopilot, failed, failed.Source, reason) + s.publishRunDone(util.UUIDToString(autopilot.WorkspaceID), failed, "failed") + } + slog.Warn("autopilot webhook repair: pending-task collision; failed run as dispatch collision", "run_id", util.UUIDToString(run.ID), "issue_id", util.UUIDToString(run.IssueID)) return nil } @@ -1271,6 +1282,105 @@ func (s *AutopilotService) SyncRunFromCreateIssueTask(ctx context.Context, task } } +// autopilotReconcileAdvisoryLockKey serializes the ON-boot task-driven reconcile +// across replicas during a rolling deploy (distinct from issuestatus.Backfill's +// 4809). Finalization is CAS-safe, so the lock is only an optimization to avoid N +// replicas each scanning; a loser simply skips. +const autopilotReconcileAdvisoryLockKey int64 = 48091 + +// ReconcileTaskDrivenRunsAtBoot runs ReconcileTaskDrivenRuns under a Postgres +// session advisory lock so a single replica does the walk on an ON boot (MUL-4809 +// §4.1 P0-3). No-op when the gate is off; a replica that loses the lock returns +// (0, nil). This is the "no event replay" closure: the event bus never re-delivers +// task events that fired while the gate was off, so enabling the gate must actively +// converge runs whose task already terminated. +func (s *AutopilotService) ReconcileTaskDrivenRunsAtBoot(ctx context.Context, pool *pgxpool.Pool) (int, error) { + if !s.taskDrivenRunsEnabled(ctx) { + return 0, nil + } + conn, err := pool.Acquire(ctx) + if err != nil { + return 0, fmt.Errorf("acquire conn: %w", err) + } + defer conn.Release() + var locked bool + if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", autopilotReconcileAdvisoryLockKey).Scan(&locked); err != nil { + return 0, fmt.Errorf("try advisory lock: %w", err) + } + if !locked { + return 0, nil + } + defer func() { + _, _ = conn.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", autopilotReconcileAdvisoryLockKey) + }() + return s.ReconcileTaskDrivenRuns(ctx) +} + +// ReconcileTaskDrivenRuns converges create_issue runs whose dispatched task already +// reached a terminal result while task-driven finalization was gated off (MUL-4809 +// §4.1 P0-3). It manually replays the terminal leaf of each active run's dispatched +// lineage through the normal CAS finalizer — the event bus does not re-deliver past +// task events. Idempotent and safe under concurrent replicas. Returns the number of +// runs finalized. No-op when the gate is off. +func (s *AutopilotService) ReconcileTaskDrivenRuns(ctx context.Context) (int, error) { + if !s.taskDrivenRunsEnabled(ctx) { + return 0, nil + } + runs, err := s.Queries.ListActiveCreateIssueRuns(ctx) + if err != nil { + return 0, fmt.Errorf("list active create_issue runs: %w", err) + } + reconciled := 0 + for _, run := range runs { + if s.reconcileCreateIssueRun(ctx, run) { + reconciled++ + } + } + return reconciled, nil +} + +// reconcileCreateIssueRun finalizes one active create_issue run if the final attempt +// of its dispatched lineage is already terminal. Returns true when it moved the run +// to a terminal status. +func (s *AutopilotService) reconcileCreateIssueRun(ctx context.Context, run db.AutopilotRun) bool { + // The dispatched task: the bound task_id, else the provenance-stamped root. A + // run with neither has no dispatched task yet and is legitimately still pending. + var root db.AgentTaskQueue + var err error + if run.TaskID.Valid { + root, err = s.Queries.GetAgentTask(ctx, run.TaskID) + } else { + root, err = s.Queries.GetTaskByDispatchedAutopilotRun(ctx, run.ID) + } + if err != nil { + return false + } + // Walk the linear system-retry chain forward to the final attempt. + leaf := root + for { + succ, serr := s.Queries.GetRetrySuccessorTask(ctx, leaf.ID) + if errors.Is(serr, pgx.ErrNoRows) { + break // leaf reached + } + if serr != nil { + return false // transient error walking the chain — don't finalize off a non-leaf + } + leaf = succ + } + if !isAutopilotTaskTerminal(leaf.Status) { + return false // the final attempt is still in flight + } + before := run.Status + // Replay the terminal leaf through the normal finalizer: it repairs an unbound + // run via provenance and CAS-finalizes. The gate is on here. + s.SyncRunFromCreateIssueTask(ctx, leaf) + after, err := s.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + return false + } + return after.Status != before && isAutopilotRunTerminalStatus(after.Status) +} + // SyncRunFromTask updates the autopilot run when a run_only task completes or fails. func (s *AutopilotService) SyncRunFromTask(ctx context.Context, task db.AgentTaskQueue) { if !task.AutopilotRunID.Valid { diff --git a/server/internal/service/autopilot_reconcile_test.go b/server/internal/service/autopilot_reconcile_test.go new file mode 100644 index 00000000000..84e2abe0772 --- /dev/null +++ b/server/internal/service/autopilot_reconcile_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "context" + "testing" +) + +// TestReconcileFinalizesRunWhoseTaskTerminatedWhileGateOff is the "no event replay" +// counter-example Elon asked for (MUL-4809 §4.1 P0-3). A create_issue task is stamped +// and completes while the gate is OFF, so its terminal event is a no-op and the run +// stays issue_created (issue status stays in_progress, so legacy never finalizes it). +// The gate then flips ON WITHOUT any new event being published — only the boot +// reconcile runs — and the run must converge to completed off the already-persisted +// task result. +func TestReconcileFinalizesRunWhoseTaskTerminatedWhileGateOff(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) // gate ON by default + + // Phase 1 — OFF: the dispatched task completes; its terminal event is consumed but + // legacy mode leaves the run alone (only issue status finalizes runs when OFF). + svc.FeatureFlags = autopilotTaskDrivenFlags(false) + dispatched := insertTask(agentID, 0, "completed", run.ID) + svc.SyncRunFromCreateIssueTask(ctx, dispatched) // OFF → no-op + off, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if off.Status != "issue_created" { + t.Fatalf("precondition: an OFF-mode task event must not finalize the run, got %q", off.Status) + } + + // Phase 2 — flip ON, publish NO new event, run only the boot reconcile. + svc.FeatureFlags = autopilotTaskDrivenFlags(true) + n, err := svc.ReconcileTaskDrivenRuns(ctx) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if n < 1 { + t.Fatalf("reconcile finalized %d runs, want >= 1 (this run)", n) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "completed" { + t.Fatalf("reconcile did not finalize the stranded run: status=%q", got.Status) + } + if !got.TaskID.Valid || got.TaskID.Bytes != dispatched.ID.Bytes { + t.Fatalf("reconcile did not bind the run to its dispatched task") + } +} + +// TestReconcileFinalizesOffRetryLeaf verifies the reconcile walks the retry lineage +// to the FINAL attempt: the dispatched attempt failed while the gate was OFF but its +// system retry completed. The reconcile must converge the run to completed (off the +// retry leaf), not failed (off the dispatched root). +func TestReconcileFinalizesOffRetryLeaf(t *testing.T) { + ctx := context.Background() + svc, agentID, run, pool, insertTask := newCreateIssueRunFixture(t) + + svc.FeatureFlags = autopilotTaskDrivenFlags(false) + dispatched := insertTask(agentID, 0, "failed", run.ID) // stamped root, failed + // Its system retry (inherits lineage via retry_of_task_id, not the stamp) completed. + var retryID string + if err := pool.QueryRow(ctx, + `INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, retry_of_task_id, created_at) + VALUES ($1, $2, $3, 'completed', 0, $4, now()) RETURNING id`, + dispatched.AgentID, dispatched.RuntimeID, dispatched.IssueID, dispatched.ID).Scan(&retryID); err != nil { + t.Fatalf("insert retry: %v", err) + } + + svc.FeatureFlags = autopilotTaskDrivenFlags(true) + if _, err := svc.ReconcileTaskDrivenRuns(ctx); err != nil { + t.Fatalf("reconcile: %v", err) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "completed" { + t.Fatalf("reconcile finalized off the wrong lineage attempt: status=%q, want completed (retry leaf)", got.Status) + } +} + +// TestReconcileAtBootNoopWhenGateOff verifies the boot reconcile is inert while the +// gate is off — it must not finalize anything (that would defeat legacy mode). +func TestReconcileAtBootNoopWhenGateOff(t *testing.T) { + ctx := context.Background() + svc, agentID, run, pool, insertTask := newCreateIssueRunFixture(t) + svc.FeatureFlags = autopilotTaskDrivenFlags(false) + + insertTask(agentID, 0, "completed", run.ID) + + n, err := svc.ReconcileTaskDrivenRunsAtBoot(ctx, pool) + if err != nil { + t.Fatalf("boot reconcile: %v", err) + } + if n != 0 { + t.Fatalf("boot reconcile finalized %d runs while gate off, want 0", n) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "issue_created" { + t.Fatalf("gate-off boot reconcile changed the run: status=%q", got.Status) + } +} diff --git a/server/internal/service/autopilot_rollout_gate_test.go b/server/internal/service/autopilot_rollout_gate_test.go index b7188075ee3..64e9f52abe5 100644 --- a/server/internal/service/autopilot_rollout_gate_test.go +++ b/server/internal/service/autopilot_rollout_gate_test.go @@ -81,28 +81,9 @@ func TestTaskDrivenModeIssueStatusIsNoOp(t *testing.T) { } } -// TestGateFlipFinalizesLegacyDispatchedRun is the enablement-boundary case: a run -// dispatched while the gate was off is left unbound (issue_created), and its task is -// provenance-stamped in either mode. After the gate flips on, the task's terminal -// event repairs the binding precisely and finalizes the run — nothing is stranded -// across the flip. -func TestGateFlipFinalizesLegacyDispatchedRun(t *testing.T) { - ctx := context.Background() - svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) // gate ON (post-flip) - - // A task the legacy dispatch stamped but never bound (run stays issue_created). - dispatched := insertTask(agentID, 0, "completed", run.ID) - - svc.SyncRunFromCreateIssueTask(ctx, dispatched) - - got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) - if err != nil { - t.Fatalf("get run: %v", err) - } - if got.Status != "completed" { - t.Fatalf("gate flip: legacy-dispatched run not finalized after enable: status=%q", got.Status) - } - if !got.TaskID.Valid || got.TaskID.Bytes != dispatched.ID.Bytes { - t.Fatalf("gate flip: run not repaired to its stamped task: task_id valid=%v", got.TaskID.Valid) - } -} +// The real gate-flip enablement boundary — an OFF-mode terminal task event that is +// NOT replayed after the flip, converged only by the boot reconcile — is covered by +// TestReconcileFinalizesRunWhoseTaskTerminatedWhileGateOff in autopilot_reconcile_test.go. +// (The earlier TestGateFlipFinalizesLegacyDispatchedRun manually re-invoked the sync +// after the flip, which only proved the finalizer handles an old row, not that a real +// flip triggers convergence; it is replaced by the reconcile coverage.) diff --git a/server/internal/service/autopilot_webhook_repair_test.go b/server/internal/service/autopilot_webhook_repair_test.go index e109bf58f13..b65158d8fa9 100644 --- a/server/internal/service/autopilot_webhook_repair_test.go +++ b/server/internal/service/autopilot_webhook_repair_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "strings" "testing" "time" @@ -28,7 +29,7 @@ func TestWebhookRepairBindsDispatchedTaskAfterWindow(t *testing.T) { // Dispatched task queued 20 minutes ago, stamped, but run.task_id never bound. dispatched := insertTask(agentID, -20*time.Minute, "queued", run.ID) - if err := svc.ensureWebhookCreateIssueTask(ctx, ap, run); err != nil { + if err := svc.ensureWebhookCreateIssueTask(ctx, ap, &run); err != nil { t.Fatalf("ensureWebhookCreateIssueTask: %v", err) } got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) @@ -59,7 +60,7 @@ func TestWebhookRepairIgnoresStrayCommentTask(t *testing.T) { // on "any issue task exists" and left the run unbound forever. comment := insertTask(agentID, 0, "completed", pgtype.UUID{}) - if err := svc.ensureWebhookCreateIssueTask(ctx, ap, run); err != nil { + if err := svc.ensureWebhookCreateIssueTask(ctx, ap, &run); err != nil { t.Fatalf("ensureWebhookCreateIssueTask: %v", err) } got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) @@ -82,12 +83,14 @@ func TestWebhookRepairIgnoresStrayCommentTask(t *testing.T) { } } -// TestWebhookRepairLeavesRunUnboundOnPendingCollision covers the rarest race: the -// agent already holds its one pending task per (issue, agent) — an unstamped comment -// task — so a fresh dispatched enqueue would violate the constraint. The repair must -// neither crash-loop the webhook worker nor bind the run to that pending comment -// task; it leaves the run unbound for the stuck-run monitor to reconcile. -func TestWebhookRepairLeavesRunUnboundOnPendingCollision(t *testing.T) { +// TestWebhookRepairFailsRunOnPendingCollision covers the convergence closure for the +// rarest race (MUL-4809 §4.1 P0-3): the agent already holds its one pending task per +// (issue, agent) — an unstamped comment task — so the autopilot's dispatched task +// can't be enqueued. The repair must NOT bind the run to that unrelated pending task, +// and must NOT leave the run permanently active. It closes the loop by failing the +// run with a traceable dispatch-collision reason; `run` (a pointer) reflects that so +// the webhook worker records the delivery as failed rather than dispatched. +func TestWebhookRepairFailsRunOnPendingCollision(t *testing.T) { ctx := context.Background() svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) ap, err := svc.Queries.GetAutopilot(ctx, run.AutopilotID) @@ -95,17 +98,77 @@ func TestWebhookRepairLeavesRunUnboundOnPendingCollision(t *testing.T) { t.Fatalf("get autopilot: %v", err) } - insertTask(agentID, 0, "queued", pgtype.UUID{}) // pending unstamped comment task holds the slot + pending := insertTask(agentID, 0, "queued", pgtype.UUID{}) // pending unstamped comment task holds the slot - if err := svc.ensureWebhookCreateIssueTask(ctx, ap, run); err != nil { + if err := svc.ensureWebhookCreateIssueTask(ctx, ap, &run); err != nil { t.Fatalf("ensureWebhookCreateIssueTask must not error on a pending-task collision: %v", err) } + // The run must be terminally failed (not active/unbound) and the failure reason + // must be traceable. got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) if err != nil { t.Fatalf("get run: %v", err) } - if got.TaskID.Valid { - t.Fatalf("webhook repair bound the run despite the pending-task collision: task_id=%x", got.TaskID.Bytes) + if got.Status != "failed" { + t.Fatalf("pending collision did not converge the run to failed: status=%q", got.Status) + } + if !got.FailureReason.Valid || !strings.Contains(strings.ToLower(got.FailureReason.String), "collision") { + t.Fatalf("dispatch-collision reason not traceable: %q", got.FailureReason.String) + } + if got.TaskID.Bytes == pending.ID.Bytes { + t.Fatal("webhook repair misattributed the run to the unrelated pending comment task") + } + // The pointer the caller passes must reflect the terminal status so the worker + // records delivery=failed, not delivery=dispatched. + if run.Status != "failed" { + t.Fatalf("collision did not propagate to the caller's run: status=%q", run.Status) + } +} + +// TestWebhookDeliveryDispatchFailsRunOnPendingCollision is the worker-level closure +// (MUL-4809 §4.1 P0-3): driving the exact entry point the webhook worker calls +// (DispatchAutopilotForWebhookDelivery) on a reclaimed delivery whose dispatched +// enqueue hits a pending-task collision must return a FAILED run. The worker maps a +// failed run to delivery=failed, so the forbidden `delivery=dispatched && run +// active/unbound` state can never occur. +func TestWebhookDeliveryDispatchFailsRunOnPendingCollision(t *testing.T) { + ctx := context.Background() + svc, agentID, run, pool, insertTask := newCreateIssueRunFixture(t) + ap, err := svc.Queries.GetAutopilot(ctx, run.AutopilotID) + if err != nil { + t.Fatalf("get autopilot: %v", err) + } + + // Link the run to a webhook delivery so the reclaim path finds it by delivery id. + var deliveryID pgtype.UUID + if err := pool.QueryRow(ctx, + `UPDATE autopilot_run SET webhook_delivery_id = gen_random_uuid() WHERE id = $1 RETURNING webhook_delivery_id`, + run.ID).Scan(&deliveryID); err != nil { + t.Fatalf("set webhook_delivery_id: %v", err) + } + + // A pending comment task holds the (issue, agent) slot → the dispatched enqueue collides. + insertTask(agentID, 0, "queued", pgtype.UUID{}) + + got, err := svc.DispatchAutopilotForWebhookDelivery(ctx, ap, pgtype.UUID{}, []byte("{}"), deliveryID) + if err != nil { + t.Fatalf("DispatchAutopilotForWebhookDelivery: %v", err) + } + if got == nil { + t.Fatal("DispatchAutopilotForWebhookDelivery returned nil run on collision") + } + if got.Status != "failed" { + t.Fatalf("collision must return a failed run (worker then records delivery=failed), got status=%q", got.Status) + } + stored, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if stored.Status != "failed" { + t.Fatalf("run left active after collision: status=%q", stored.Status) + } + if stored.TaskID.Valid { + t.Fatalf("run bound to the stray pending task on collision: task_id=%x", stored.TaskID.Bytes) } } @@ -124,7 +187,7 @@ func TestWebhookRepairProceedsWhenIssueInReview(t *testing.T) { t.Fatalf("set issue in_review: %v", err) } - if err := svc.ensureWebhookCreateIssueTask(ctx, ap, run); err != nil { + if err := svc.ensureWebhookCreateIssueTask(ctx, ap, &run); err != nil { t.Fatalf("ensureWebhookCreateIssueTask: %v", err) } got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index e3b446f8157..98bcd584dfe 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -877,6 +877,73 @@ func (q *Queries) GetLatestAutopilotRunByIssue(ctx context.Context, issueID pgty return i, err } +const getRetrySuccessorTask = `-- name: GetRetrySuccessorTask :one +SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue +WHERE retry_of_task_id = $1 +ORDER BY created_at ASC, id ASC +LIMIT 1 +` + +// The system-retry successor of a task (the task whose retry_of_task_id points at +// it). System retries form a linear chain, so walking successors from the root +// reaches the final attempt — used by the ON-boot reconcile to find a lineage's +// terminal leaf (MUL-4809 §4.1 P0-3). ErrNoRows means this task is the leaf. +func (q *Queries) GetRetrySuccessorTask(ctx context.Context, retryOfTaskID pgtype.UUID) (AgentTaskQueue, error) { + row := q.db.QueryRow(ctx, getRetrySuccessorTask, retryOfTaskID) + var i AgentTaskQueue + err := row.Scan( + &i.ID, + &i.AgentID, + &i.IssueID, + &i.Status, + &i.Priority, + &i.DispatchedAt, + &i.StartedAt, + &i.CompletedAt, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.Context, + &i.RuntimeID, + &i.SessionID, + &i.WorkDir, + &i.TriggerCommentID, + &i.ChatSessionID, + &i.AutopilotRunID, + &i.Attempt, + &i.MaxAttempts, + &i.ParentTaskID, + &i.FailureReason, + &i.TriggerSummary, + &i.ForceFreshSession, + &i.IsLeaderTask, + &i.WaitReason, + &i.InitiatorUserID, + &i.HandoffNote, + &i.PrepareLeaseExpiresAt, + &i.SquadID, + &i.RuntimeMcpOverlay, + &i.EscalationForTaskID, + &i.FireAt, + &i.OriginatorUserID, + &i.RuntimeConnectedApps, + &i.CoalescedCommentIds, + &i.DeliveredCommentIds, + &i.ChatInputTaskID, + &i.ChatFinalizeDeferredAt, + &i.OriginatorSource, + &i.DelegatedFromTaskID, + &i.RetryOfTaskID, + &i.RerunOfTaskID, + &i.RuleVersionID, + &i.TriggerEvidenceKind, + &i.TriggerEvidenceRefID, + &i.AccountableUserID, + &i.DispatchedAutopilotRunID, + ) + return i, err +} + const getTaskByDispatchedAutopilotRun = `-- name: GetTaskByDispatchedAutopilotRun :one SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, dispatched_autopilot_run_id FROM agent_task_queue WHERE dispatched_autopilot_run_id = $1 @@ -1045,6 +1112,55 @@ func (q *Queries) IsAutopilotCollaborator(ctx context.Context, arg IsAutopilotCo return is_collaborator, err } +const listActiveCreateIssueRuns = `-- name: ListActiveCreateIssueRuns :many +SELECT r.id, r.autopilot_id, r.trigger_id, r.source, r.status, r.issue_id, r.task_id, r.triggered_at, r.completed_at, r.failure_reason, r.trigger_payload, r.result, r.created_at, r.squad_id, r.planned_at, r.webhook_delivery_id FROM autopilot_run r +JOIN autopilot a ON a.id = r.autopilot_id +WHERE r.status IN ('issue_created', 'running') + AND a.execution_mode = 'create_issue' +` + +// Active create_issue runs (issue_created / running) for the ON-boot reconcile that +// converges runs whose dispatched task already reached a terminal result while +// task-driven finalization was gated off — the event bus does not replay those past +// task events (MUL-4809 §4.1 P0-3). Joined to autopilot only to filter +// execution_mode; no FK (join, not a constraint). +func (q *Queries) ListActiveCreateIssueRuns(ctx context.Context) ([]AutopilotRun, error) { + rows, err := q.db.Query(ctx, listActiveCreateIssueRuns) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AutopilotRun{} + for rows.Next() { + var i AutopilotRun + if err := rows.Scan( + &i.ID, + &i.AutopilotID, + &i.TriggerID, + &i.Source, + &i.Status, + &i.IssueID, + &i.TaskID, + &i.TriggeredAt, + &i.CompletedAt, + &i.FailureReason, + &i.TriggerPayload, + &i.Result, + &i.CreatedAt, + &i.SquadID, + &i.PlannedAt, + &i.WebhookDeliveryID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAutopilotCollaborators = `-- name: ListAutopilotCollaborators :many SELECT autopilot_id, user_type, user_id, granted_by, created_at FROM autopilot_collaborator diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index c0c17dd3218..10207e31265 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -486,6 +486,27 @@ WHERE dispatched_autopilot_run_id = $1 ORDER BY created_at ASC, id ASC LIMIT 1; +-- name: GetRetrySuccessorTask :one +-- The system-retry successor of a task (the task whose retry_of_task_id points at +-- it). System retries form a linear chain, so walking successors from the root +-- reaches the final attempt — used by the ON-boot reconcile to find a lineage's +-- terminal leaf (MUL-4809 §4.1 P0-3). ErrNoRows means this task is the leaf. +SELECT * FROM agent_task_queue +WHERE retry_of_task_id = $1 +ORDER BY created_at ASC, id ASC +LIMIT 1; + +-- name: ListActiveCreateIssueRuns :many +-- Active create_issue runs (issue_created / running) for the ON-boot reconcile that +-- converges runs whose dispatched task already reached a terminal result while +-- task-driven finalization was gated off — the event bus does not replay those past +-- task events (MUL-4809 §4.1 P0-3). Joined to autopilot only to filter +-- execution_mode; no FK (join, not a constraint). +SELECT r.* FROM autopilot_run r +JOIN autopilot a ON a.id = r.autopilot_id +WHERE r.status IN ('issue_created', 'running') + AND a.execution_mode = 'create_issue'; + -- name: FailAutopilotRunsByIssue :exec -- Fails active autopilot runs linked to a given issue. -- Must be called BEFORE issue deletion (ON DELETE SET NULL clears issue_id). From 868bb4bae2a7f767a3fcc00ad3110d461e090ca9 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 21:12:26 +0800 Subject: [PATCH 26/41] =?UTF-8?q?fix(autopilot):=20close=20collision=20err?= =?UTF-8?q?or=20propagation,=20reconcile/retry=20race,=20and=20one-shot=20?= =?UTF-8?q?reconcile=20(MUL-4809=20=C2=A74.1=20P0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-1 collision fail-transition error propagation: failRun now returns (run, won, err). On the webhook dispatch-collision path a real DB error propagates (delivery stays retryable) and a CAS miss reloads the authoritative run — the caller never returns nil over a still-active run, so the forbidden delivery=dispatched && run active/unbound state can no longer occur. P0-2 reconcile/retry concurrency boundary: the boot reconcile no longer finalizes a terminal-FAILED leaf that is still retry-eligible (the sweeper marks failed, then creates the retry in a separate step). It reuses the same retryEligible contract the retry path uses, so a run is only finalized once its lineage settles past the retry-creation window; a later tick converges it. P0-3 one-shot best-effort -> bounded periodic reconcile: RunTaskDrivenReconcileLoop re-scans on a cadence (backing off after transient errors) instead of a single boot pass, so a run left behind by a transient query error, a lock loser, or an unsettled lineage converges on a later tick. reconcileCreateIssueRun distinguishes not-ready from transient-error; the scan is keyset-paginated (ListActiveCreateIssueRunsPaged) so it never materializes every active run. Counter-example tests: pending-collision fail-transition; reconcile skips a retry-eligible failed leaf then converges once the retry settles; converge on a later tick after a first-round query error; advisory-lock loser skips then a later tick takes over; and a real worker-level (WebhookDeliveryWorker) collision that asserts the persisted delivery row is failed. Co-authored-by: multica-agent --- server/cmd/server/main.go | 23 +- .../internal/handler/webhook_delivery_test.go | 76 ++++++ server/internal/service/autopilot.go | 254 ++++++++++++++---- .../service/autopilot_reconcile_test.go | 192 ++++++++++++- server/pkg/db/generated/autopilot.sql.go | 28 +- server/pkg/db/queries/autopilot.sql | 20 +- 6 files changed, 499 insertions(+), 94 deletions(-) diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 75fa51aa385..e286a23a0bf 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -437,21 +437,14 @@ func main() { slog.Info("issue status backfill complete", "workspaces", n) }() - // ON-boot reconcile for task-driven autopilot runs (MUL-4809 §4.1 P0-3). When - // the two-phase gate is flipped on, the event bus does not replay task events - // that fired while it was off, so this converges any create_issue run whose - // dispatched task already terminated. No-op while the gate is off; advisory- - // locked so a single replica walks during a rolling deploy. - go func() { - n, err := autopilotSvc.ReconcileTaskDrivenRunsAtBoot(sweepCtx, pool) - if err != nil { - slog.Error("autopilot task-driven run reconcile failed", "error", err) - return - } - if n > 0 { - slog.Info("autopilot task-driven run reconcile complete", "runs_finalized", n) - } - }() + // Periodic reconcile for task-driven autopilot runs (MUL-4809 §4.1 P0-3). When the + // two-phase gate is flipped on, the event bus does not replay task events that + // fired while it was off, so this converges any create_issue run whose dispatched + // task already terminated. It runs on a bounded periodic cadence (not one-shot) so + // a run left behind by a transient error or an unsettled retry lineage converges + // on a later tick. No-op while the gate is off; each tick is advisory-locked so a + // single replica walks during a rolling deploy. + go autopilotSvc.RunTaskDrivenReconcileLoop(sweepCtx, pool) // Start background sweeper to mark stale runtimes as offline. go runRuntimeSweeper(sweepCtx, queries, liveness, taskSvc, bus) diff --git a/server/internal/handler/webhook_delivery_test.go b/server/internal/handler/webhook_delivery_test.go index 1c01af6ab66..d6c77f8fac9 100644 --- a/server/internal/handler/webhook_delivery_test.go +++ b/server/internal/handler/webhook_delivery_test.go @@ -856,6 +856,82 @@ func TestWebhookDeliveryWorker_RepairsCreateIssueTaskCrashWindow(t *testing.T) { } } +// TestWebhookDeliveryWorker_PendingCollisionFailsDeliveryAndRun drives the REAL +// WebhookDeliveryWorker (not just the service entry point) through a create_issue +// dispatch collision and asserts the persisted delivery row, closing the P0-1 loop at +// the worker level (MUL-4809 §4.1 P0-1). The dispatched task's crash-window repair +// collides with an unrelated pending comment task the agent already holds, so the run +// is finalized as a traceable dispatch collision. The forbidden +// `delivery=dispatched && run active/unbound` state must never appear: the delivery +// row must read `failed` and the run must be `failed`, not bound to the stray task. +func TestWebhookDeliveryWorker_PendingCollisionFailsDeliveryAndRun(t *testing.T) { + agentID := createWebhookTestAgent(t, "WorkerCollision Agent") + apID := createWebhookTestAutopilot(t, agentID, "active", "create_issue") + trig := createWebhookTriggerViaHandler(t, apID) + + post := postWebhook(t, *trig.WebhookToken, map[string]any{"event": "collision"}, map[string]string{ + "Idempotency-Key": "worker-collision", + }) + deliveryID := requireAcceptedWebhookResponse(t, post) + first := processQueuedWebhookDelivery(t, deliveryID) + run, err := testHandler.Queries.GetAutopilotRun(context.Background(), first.AutopilotRunID) + if err != nil || !run.IssueID.Valid { + t.Fatalf("load create_issue run: run=%#v err=%v", run, err) + } + issueID := uuidToString(run.IssueID) + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) + testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Model a pre-bind crash: drop the stamped dispatched task and reset the run to + // issue_created + NULL task_id so the worker re-enters the repair path. + if _, err := testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID); err != nil { + t.Fatalf("remove dispatched task: %v", err) + } + if _, err := testPool.Exec(context.Background(), + `UPDATE autopilot_run SET status = 'issue_created', task_id = NULL WHERE id = $1`, + first.AutopilotRunID); err != nil { + t.Fatalf("reset run to pre-bind crash state: %v", err) + } + + // The agent now holds its one pending task per (issue, agent): an UNSTAMPED comment + // task. The repair's dispatched-task enqueue collides with it (idx_one_pending_task + // _per_issue_agent), leaving the run no provably-dispatched task to bind. + var strayID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, created_at) + SELECT a.id, a.runtime_id, $2::uuid, 'queued', 0, now() FROM agent a WHERE a.id = $1 + RETURNING id`, + agentID, issueID).Scan(&strayID); err != nil { + t.Fatalf("insert stray pending task: %v", err) + } + + // Requeue the delivery so the worker reclaims it and re-runs dispatch → repair. + if _, err := testPool.Exec(context.Background(), ` + UPDATE webhook_delivery + SET status = 'queued', autopilot_run_id = NULL, available_at = now(), + lease_token = NULL, lease_expires_at = NULL + WHERE id = $1`, deliveryID); err != nil { + t.Fatalf("requeue delivery: %v", err) + } + + final := processQueuedWebhookDelivery(t, deliveryID) + if final.Status != deliveryStatusFailed { + t.Fatalf("dispatch collision must record delivery=failed, got %q", final.Status) + } + got, err := testHandler.Queries.GetAutopilotRun(context.Background(), first.AutopilotRunID) + if err != nil { + t.Fatalf("load run after collision: %v", err) + } + if got.Status != "failed" { + t.Fatalf("dispatch collision must fail the run, got %q", got.Status) + } + if got.TaskID.Valid && uuidToString(got.TaskID) == strayID { + t.Fatal("run must not be bound to the unrelated pending comment task") + } +} + func TestWebhookHandler_InvalidSignatureCountsAgainstRateLimit(t *testing.T) { // Only requests classified as bad credentials consume the shared-IP debt // budget; valid traffic behind the same NAT does not. diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index eb1d32747b0..eb350bb4113 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -366,13 +366,36 @@ func (s *AutopilotService) ensureWebhookCreateIssueTask(ctx context.Context, aut // run's outcome to a comment task). if isAutopilotUniqueViolation(err) { reason := "dispatch collision: agent already has a pending task for this issue" - if failed, won := s.failRun(ctx, run.ID, reason); won { + failed, won, ferr := s.failRun(ctx, run.ID, reason) + if ferr != nil { + // The fail transition itself errored (timeout / connection). The run may + // still be active/unbound, so we must NOT report success — that would let + // the worker record delivery=dispatched over a live run, exactly the state + // this closure forbids. Propagate so the delivery stays retryable. + return fmt.Errorf("dispatch for webhook delivery: fail run on dispatch collision: %w", ferr) + } + if won { *run = failed s.captureAutopilotRunFailed(autopilot, failed, failed.Source, reason) s.publishRunDone(util.UUIDToString(autopilot.WorkspaceID), failed, "failed") + } else { + // CAS miss: another path already finalized this run. Reload the + // authoritative row so the caller — and, in turn, the webhook delivery + // status — reflect the real terminal state, never the stale active/unbound + // run we started from. + authoritative, rerr := s.Queries.GetAutopilotRun(ctx, run.ID) + if rerr != nil { + return fmt.Errorf("dispatch for webhook delivery: reload run after collision CAS miss: %w", rerr) + } + if !isAutopilotRunTerminalStatus(authoritative.Status) { + // A CAS miss means the row left ('issue_created','running'); if it is + // somehow still active, do not claim success — keep the delivery retryable. + return fmt.Errorf("dispatch for webhook delivery: run %s still active after collision CAS miss", util.UUIDToString(run.ID)) + } + *run = authoritative } - slog.Warn("autopilot webhook repair: pending-task collision; failed run as dispatch collision", - "run_id", util.UUIDToString(run.ID), "issue_id", util.UUIDToString(run.IssueID)) + slog.Warn("autopilot webhook repair: pending-task collision; run finalized as dispatch collision", + "run_id", util.UUIDToString(run.ID), "issue_id", util.UUIDToString(run.IssueID), "status", run.Status) return nil } return fmt.Errorf("dispatch for webhook delivery: repair dispatched task: %w", err) @@ -593,7 +616,7 @@ func (s *AutopilotService) dispatchAutopilotRun( if skipped, code := s.handleDispatchSkip(ctx, autopilot, run, err); skipped != nil { return skipped, code, nil } - if failed, won := s.failRun(ctx, run.ID, err.Error()); won { + if failed, won, _ := s.failRun(ctx, run.ID, err.Error()); won { s.captureAutopilotRunFailed(autopilot, failed, source, err.Error()) } return run, dispatchFailReasonCode(err), fmt.Errorf("dispatch create_issue: %w", err) @@ -603,13 +626,13 @@ func (s *AutopilotService) dispatchAutopilotRun( if skipped, code := s.handleDispatchSkip(ctx, autopilot, run, err); skipped != nil { return skipped, code, nil } - if failed, won := s.failRun(ctx, run.ID, err.Error()); won { + if failed, won, _ := s.failRun(ctx, run.ID, err.Error()); won { s.captureAutopilotRunFailed(autopilot, failed, source, err.Error()) } return run, dispatchFailReasonCode(err), fmt.Errorf("dispatch run_only: %w", err) } default: - if failed, won := s.failRun(ctx, run.ID, "unknown execution_mode: "+autopilot.ExecutionMode); won { + if failed, won, _ := s.failRun(ctx, run.ID, "unknown execution_mode: "+autopilot.ExecutionMode); won { s.captureAutopilotRunFailed(autopilot, failed, source, "unknown execution_mode: "+autopilot.ExecutionMode) } return run, dispatch.ReasonInternalError, fmt.Errorf("unknown execution_mode: %s", autopilot.ExecutionMode) @@ -1282,33 +1305,101 @@ func (s *AutopilotService) SyncRunFromCreateIssueTask(ctx context.Context, task } } -// autopilotReconcileAdvisoryLockKey serializes the ON-boot task-driven reconcile -// across replicas during a rolling deploy (distinct from issuestatus.Backfill's -// 4809). Finalization is CAS-safe, so the lock is only an optimization to avoid N -// replicas each scanning; a loser simply skips. +// autopilotReconcileAdvisoryLockKey serializes the task-driven reconcile across +// replicas during a rolling deploy (distinct from issuestatus.Backfill's 4809). +// Finalization is CAS-safe, so the lock is only an optimization to avoid N replicas +// each scanning; a loser simply skips and the next tick tries again. const autopilotReconcileAdvisoryLockKey int64 = 48091 -// ReconcileTaskDrivenRunsAtBoot runs ReconcileTaskDrivenRuns under a Postgres -// session advisory lock so a single replica does the walk on an ON boot (MUL-4809 -// §4.1 P0-3). No-op when the gate is off; a replica that loses the lock returns -// (0, nil). This is the "no event replay" closure: the event bus never re-delivers -// task events that fired while the gate was off, so enabling the gate must actively -// converge runs whose task already terminated. -func (s *AutopilotService) ReconcileTaskDrivenRunsAtBoot(ctx context.Context, pool *pgxpool.Pool) (int, error) { +const ( + // autopilotReconcileBatchSize bounds one keyset page so the reconcile never + // materializes every active run at once (MUL-4809 §4.1 P0-3). + autopilotReconcileBatchSize int32 = 200 + // autopilotReconcileInterval is the steady-state re-scan cadence while the gate + // is ON — a bounded periodic reconcile, not a one-shot boot scan, so a run left + // behind by a transient error or an unsettled retry lineage converges on a later + // tick instead of being stranded forever (MUL-4809 §4.1 P0-3). + autopilotReconcileInterval = 2 * time.Minute + // autopilotReconcileRetryBackoff is the shorter cadence used for the next tick + // after one that reported transient errors, so recovery does not wait a full + // steady-state interval. + autopilotReconcileRetryBackoff = 15 * time.Second +) + +// reconcileOutcome classifies one run's reconcile attempt so the loop can tell a +// converged run from one that is merely not ready yet from one that hit a transient +// error and must be revisited on a later tick (MUL-4809 §4.1 P0-2 / P0-3). +type reconcileOutcome int + +const ( + // reconcileNotReady: no dispatched task yet, the final attempt is still running, + // or the retry lineage has not settled past the retry-creation window. A later + // tick re-examines it; nothing is stranded. + reconcileNotReady reconcileOutcome = iota + // reconcileFinalized: this attempt moved the run to a terminal status. + reconcileFinalized + // reconcileRetryLater: a transient query error — the run is left untouched and + // the loop schedules a sooner follow-up. + reconcileRetryLater +) + +// reconcileResult reports one reconcile pass's outcome counts. retryable > 0 means at +// least one run hit a transient error, so the caller should schedule a sooner tick. +type reconcileResult struct { + finalized int + retryable int +} + +// RunTaskDrivenReconcileLoop periodically converges create_issue runs stranded by the +// "no event replay" gap while the gate is ON (MUL-4809 §4.1 P0-3). Each tick is +// advisory-locked (one replica walks) and CAS-safe, so it is safe to run on every +// replica. A one-shot boot scan would permanently lose any run whose query +// transiently failed, whose lock winner errored, or whose retry lineage had not yet +// settled; this bounded periodic re-scan converges them on a later tick. It backs off +// to a short interval after a tick that reported transient errors. Returns when ctx +// is cancelled. +func (s *AutopilotService) RunTaskDrivenReconcileLoop(ctx context.Context, pool *pgxpool.Pool) { + for { + next := autopilotReconcileInterval + res, err := s.ReconcileTaskDrivenRunsAtBoot(ctx, pool) + switch { + case err != nil: + slog.Error("autopilot task-driven reconcile tick failed", "error", err) + next = autopilotReconcileRetryBackoff + case res.retryable > 0: + slog.Warn("autopilot task-driven reconcile left runs for retry", + "finalized", res.finalized, "retryable", res.retryable) + next = autopilotReconcileRetryBackoff + case res.finalized > 0: + slog.Info("autopilot task-driven reconcile finalized runs", "runs_finalized", res.finalized) + } + select { + case <-ctx.Done(): + return + case <-time.After(next): + } + } +} + +// ReconcileTaskDrivenRunsAtBoot runs ReconcileTaskDrivenRuns under a Postgres session +// advisory lock so a single replica does the walk per tick (MUL-4809 §4.1 P0-3). +// No-op when the gate is off; a replica that loses the lock returns a zero result so +// the next tick — or another replica — takes over. +func (s *AutopilotService) ReconcileTaskDrivenRunsAtBoot(ctx context.Context, pool *pgxpool.Pool) (reconcileResult, error) { if !s.taskDrivenRunsEnabled(ctx) { - return 0, nil + return reconcileResult{}, nil } conn, err := pool.Acquire(ctx) if err != nil { - return 0, fmt.Errorf("acquire conn: %w", err) + return reconcileResult{}, fmt.Errorf("acquire conn: %w", err) } defer conn.Release() var locked bool if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", autopilotReconcileAdvisoryLockKey).Scan(&locked); err != nil { - return 0, fmt.Errorf("try advisory lock: %w", err) + return reconcileResult{}, fmt.Errorf("try advisory lock: %w", err) } if !locked { - return 0, nil + return reconcileResult{}, nil } defer func() { _, _ = conn.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", autopilotReconcileAdvisoryLockKey) @@ -1318,33 +1409,57 @@ func (s *AutopilotService) ReconcileTaskDrivenRunsAtBoot(ctx context.Context, po // ReconcileTaskDrivenRuns converges create_issue runs whose dispatched task already // reached a terminal result while task-driven finalization was gated off (MUL-4809 -// §4.1 P0-3). It manually replays the terminal leaf of each active run's dispatched -// lineage through the normal CAS finalizer — the event bus does not re-deliver past -// task events. Idempotent and safe under concurrent replicas. Returns the number of -// runs finalized. No-op when the gate is off. -func (s *AutopilotService) ReconcileTaskDrivenRuns(ctx context.Context) (int, error) { +// §4.1 P0-3). It manually replays the settled terminal leaf of each active run's +// dispatched lineage through the normal CAS finalizer — the event bus does not +// re-deliver past task events. Keyset-paginated so it never materializes every active +// run at once. Idempotent and safe under concurrent replicas. No-op when the gate is +// off. A transient error on any single run is counted (retryable) and left for a +// later tick rather than stranding the run; only a page-load error aborts the pass. +func (s *AutopilotService) ReconcileTaskDrivenRuns(ctx context.Context) (reconcileResult, error) { + var res reconcileResult if !s.taskDrivenRunsEnabled(ctx) { - return 0, nil + return res, nil } - runs, err := s.Queries.ListActiveCreateIssueRuns(ctx) - if err != nil { - return 0, fmt.Errorf("list active create_issue runs: %w", err) - } - reconciled := 0 - for _, run := range runs { - if s.reconcileCreateIssueRun(ctx, run) { - reconciled++ + // Keyset cursor over (created_at, id); '-infinity' + zero-uuid selects the first + // page. Finalized runs drop out of the active-status filter, so paging forward + // never revisits or skips a run within a pass. + cursorCreatedAt := pgtype.Timestamptz{InfinityModifier: pgtype.NegativeInfinity, Valid: true} + cursorID := pgtype.UUID{Valid: true} // all-zero uuid + for { + batch, err := s.Queries.ListActiveCreateIssueRunsPaged(ctx, db.ListActiveCreateIssueRunsPagedParams{ + CursorCreatedAt: cursorCreatedAt, + CursorID: cursorID, + RowLimit: autopilotReconcileBatchSize, + }) + if err != nil { + return res, fmt.Errorf("list active create_issue runs: %w", err) + } + if len(batch) == 0 { + break + } + for _, run := range batch { + switch s.reconcileCreateIssueRun(ctx, run) { + case reconcileFinalized: + res.finalized++ + case reconcileRetryLater: + res.retryable++ + } + cursorCreatedAt = run.CreatedAt + cursorID = run.ID + } + if int32(len(batch)) < autopilotReconcileBatchSize { + break } } - return reconciled, nil + return res, nil } // reconcileCreateIssueRun finalizes one active create_issue run if the final attempt -// of its dispatched lineage is already terminal. Returns true when it moved the run -// to a terminal status. -func (s *AutopilotService) reconcileCreateIssueRun(ctx context.Context, run db.AutopilotRun) bool { - // The dispatched task: the bound task_id, else the provenance-stamped root. A - // run with neither has no dispatched task yet and is legitimately still pending. +// of its dispatched lineage has settled at a terminal status. It distinguishes "not +// ready" (still pending / retry pending) from "transient error" (revisit later) so a +// single DB blip never strands the run (MUL-4809 §4.1 P0-2 / P0-3). +func (s *AutopilotService) reconcileCreateIssueRun(ctx context.Context, run db.AutopilotRun) reconcileOutcome { + // The dispatched task: the bound task_id, else the provenance-stamped root. var root db.AgentTaskQueue var err error if run.TaskID.Valid { @@ -1352,8 +1467,11 @@ func (s *AutopilotService) reconcileCreateIssueRun(ctx context.Context, run db.A } else { root, err = s.Queries.GetTaskByDispatchedAutopilotRun(ctx, run.ID) } + if errors.Is(err, pgx.ErrNoRows) { + return reconcileNotReady // no dispatched task yet — legitimately still pending + } if err != nil { - return false + return reconcileRetryLater // transient error — revisit, do not strand the run } // Walk the linear system-retry chain forward to the final attempt. leaf := root @@ -1363,22 +1481,36 @@ func (s *AutopilotService) reconcileCreateIssueRun(ctx context.Context, run db.A break // leaf reached } if serr != nil { - return false // transient error walking the chain — don't finalize off a non-leaf + return reconcileRetryLater // transient error walking the chain — revisit } leaf = succ } if !isAutopilotTaskTerminal(leaf.Status) { - return false // the final attempt is still in flight + return reconcileNotReady // the final attempt is still in flight + } + // A failed leaf that is still retry-eligible has a retry the failure handler has + // not created yet: the bulk sweeper marks a task failed, then creates its retry in + // a separate step, so there is an observable window where the leaf is terminal- + // failed but not final. Finalizing the run now would fail it before the retry runs, + // and a later successful retry cannot un-fail a terminal run. Leave it; a later + // tick converges it once the lineage settles past the retry-creation window. The + // same retryEligible contract the retry path uses keeps the two decisions in + // lock-step (MUL-4809 §4.1 P0-2). + if leaf.Status == "failed" && retryEligible(leaf.FailureReason.String, leaf) { + return reconcileNotReady } before := run.Status - // Replay the terminal leaf through the normal finalizer: it repairs an unbound - // run via provenance and CAS-finalizes. The gate is on here. + // Replay the terminal leaf through the normal finalizer: it repairs an unbound run + // via provenance and CAS-finalizes. The gate is on here. s.SyncRunFromCreateIssueTask(ctx, leaf) after, err := s.Queries.GetAutopilotRun(ctx, run.ID) if err != nil { - return false + return reconcileRetryLater // couldn't confirm the outcome — revisit } - return after.Status != before && isAutopilotRunTerminalStatus(after.Status) + if after.Status != before && isAutopilotRunTerminalStatus(after.Status) { + return reconcileFinalized + } + return reconcileNotReady } // SyncRunFromTask updates the autopilot run when a run_only task completes or fails. @@ -1545,23 +1677,29 @@ func (s *AutopilotService) handleDispatchSkip(ctx context.Context, ap db.Autopil return run, skipErr.code } -// failRun marks a run failed as a compare-and-set. It returns the updated -// (failed) run and true only when THIS call actually won the terminal -// transition. On a CAS miss (the run was already finalized by another path) it -// returns false, and the caller MUST NOT record a failure — otherwise analytics -// would double-count against a run the DB no longer let it overwrite. -func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reason string) (db.AutopilotRun, bool) { +// failRun marks a run failed as a compare-and-set. It distinguishes three outcomes +// so callers on the webhook-delivery path never report success over a still-active +// run (MUL-4809 §4.1 P0-1): +// - (failed, true, nil): THIS call won the terminal transition; +// - (zero, false, nil): CAS miss — the run was already finalized by another path. +// The caller MUST NOT record a duplicate failure (analytics would double-count), +// and should reload the authoritative run before acting on its status; +// - (zero, false, err): a real database error — the transition did NOT happen and +// the run may still be active. The caller MUST treat the operation as retryable +// rather than reporting success. +func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reason string) (db.AutopilotRun, bool, error) { updated, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{ ID: runID, FailureReason: pgtype.Text{String: reason, Valid: true}, }) if err != nil { - if !errors.Is(err, pgx.ErrNoRows) { - slog.Warn("failed to mark autopilot run as failed", "run_id", util.UUIDToString(runID), "error", err) + if errors.Is(err, pgx.ErrNoRows) { + return db.AutopilotRun{}, false, nil // CAS miss: already finalized elsewhere } - return db.AutopilotRun{}, false + slog.Warn("failed to mark autopilot run as failed", "run_id", util.UUIDToString(runID), "error", err) + return db.AutopilotRun{}, false, err } - return updated, true + return updated, true, nil } // shouldSkipDispatch is the pre-flight admission check from MUL-1899. diff --git a/server/internal/service/autopilot_reconcile_test.go b/server/internal/service/autopilot_reconcile_test.go index 84e2abe0772..cd16e66bd9d 100644 --- a/server/internal/service/autopilot_reconcile_test.go +++ b/server/internal/service/autopilot_reconcile_test.go @@ -31,12 +31,12 @@ func TestReconcileFinalizesRunWhoseTaskTerminatedWhileGateOff(t *testing.T) { // Phase 2 — flip ON, publish NO new event, run only the boot reconcile. svc.FeatureFlags = autopilotTaskDrivenFlags(true) - n, err := svc.ReconcileTaskDrivenRuns(ctx) + res, err := svc.ReconcileTaskDrivenRuns(ctx) if err != nil { t.Fatalf("reconcile: %v", err) } - if n < 1 { - t.Fatalf("reconcile finalized %d runs, want >= 1 (this run)", n) + if res.finalized < 1 { + t.Fatalf("reconcile finalized %d runs, want >= 1 (this run)", res.finalized) } got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) if err != nil { @@ -91,12 +91,12 @@ func TestReconcileAtBootNoopWhenGateOff(t *testing.T) { insertTask(agentID, 0, "completed", run.ID) - n, err := svc.ReconcileTaskDrivenRunsAtBoot(ctx, pool) + res, err := svc.ReconcileTaskDrivenRunsAtBoot(ctx, pool) if err != nil { t.Fatalf("boot reconcile: %v", err) } - if n != 0 { - t.Fatalf("boot reconcile finalized %d runs while gate off, want 0", n) + if res.finalized != 0 { + t.Fatalf("boot reconcile finalized %d runs while gate off, want 0", res.finalized) } got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) if err != nil { @@ -106,3 +106,183 @@ func TestReconcileAtBootNoopWhenGateOff(t *testing.T) { t.Fatalf("gate-off boot reconcile changed the run: status=%q", got.Status) } } + +// TestReconcileSkipsRetryEligibleFailedLeafThenConverges is the P0-2 concurrency- +// boundary counter-example (MUL-4809 §4.1 P0-2). The dispatched task is terminal- +// FAILED with an infrastructure-shaped reason (runtime_offline, attempt 1/2) but its +// system retry has not been created yet — the sweeper marks the task failed, then +// creates the retry in a separate step. Finalizing the run now would fail it before +// the retry runs, and a later successful retry cannot un-fail a terminal run. The +// reconcile must SKIP the run while the leaf is still retry-eligible, then converge it +// on a later tick once the (now-completed) retry successor settles the lineage. +func TestReconcileSkipsRetryEligibleFailedLeafThenConverges(t *testing.T) { + ctx := context.Background() + svc, agentID, run, pool, insertTask := newCreateIssueRunFixture(t) // gate ON + + // Stamped dispatched root: terminal-failed, still within its retry budget. + dispatched := insertTask(agentID, 0, "failed", run.ID) // attempt=1, max_attempts=2 by default + if _, err := pool.Exec(ctx, + `UPDATE agent_task_queue SET failure_reason = 'runtime_offline' WHERE id = $1`, + dispatched.ID); err != nil { + t.Fatalf("set failure_reason: %v", err) + } + + // Tick 1: the retry successor does not exist yet, so the leaf is a retry-eligible + // failed root. The reconcile must NOT finalize the run. + res, err := svc.ReconcileTaskDrivenRuns(ctx) + if err != nil { + t.Fatalf("reconcile (pre-retry): %v", err) + } + if res.finalized != 0 { + t.Fatalf("reconcile finalized a run whose failed leaf is still retry-eligible: finalized=%d", res.finalized) + } + mid, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if mid.Status != "issue_created" { + t.Fatalf("reconcile prematurely finalized a retry-eligible run: status=%q", mid.Status) + } + + // The sweeper now creates the retry, which succeeds. The lineage has settled. + var retryID string + if err := pool.QueryRow(ctx, + `INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, retry_of_task_id, created_at) + VALUES ($1, $2, $3, 'completed', 0, $4, now()) RETURNING id`, + dispatched.AgentID, dispatched.RuntimeID, dispatched.IssueID, dispatched.ID).Scan(&retryID); err != nil { + t.Fatalf("insert retry: %v", err) + } + + // Tick 2: the leaf is now the completed retry. The reconcile converges the run to + // completed (off the retry leaf), not failed (off the earlier attempt). + res, err = svc.ReconcileTaskDrivenRuns(ctx) + if err != nil { + t.Fatalf("reconcile (post-retry): %v", err) + } + if res.finalized < 1 { + t.Fatalf("reconcile did not converge the settled lineage: finalized=%d", res.finalized) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "completed" { + t.Fatalf("settled lineage did not converge to completed: status=%q", got.Status) + } +} + +// TestReconcileConvergesOnLaterTickAfterTransientError is the P0-3 "first round query +// fails, next round succeeds" counter-example (MUL-4809 §4.1 P0-3). A one-shot boot +// scan would permanently strand a run whose reconcile query hit a transient DB error. +// Here the first pass fails on a cancelled context (the page-load query errors); the +// run must be left untouched, and a later pass with a live context must converge it — +// no event is ever replayed. +func TestReconcileConvergesOnLaterTickAfterTransientError(t *testing.T) { + ctx := context.Background() + svc, agentID, run, _, insertTask := newCreateIssueRunFixture(t) // gate ON + + // Stranded run: a completed stamped task exists, but no event was published, so + // the run is stuck at issue_created — exactly what the reconcile must converge. + dispatched := insertTask(agentID, 0, "completed", run.ID) + + // Tick 1: a cancelled context makes the page-load query fail. The pass aborts with + // an error and must not touch the run. + cancelled, cancel := context.WithCancel(ctx) + cancel() + if _, err := svc.ReconcileTaskDrivenRuns(cancelled); err == nil { + t.Fatal("expected the first reconcile pass to error on the cancelled context") + } + mid, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if mid.Status != "issue_created" { + t.Fatalf("a failed reconcile pass must leave the run untouched, got %q", mid.Status) + } + + // Tick 2: a live context converges the run off the already-persisted task result. + res, err := svc.ReconcileTaskDrivenRuns(ctx) + if err != nil { + t.Fatalf("reconcile (recovery): %v", err) + } + if res.finalized < 1 { + t.Fatalf("recovery tick did not finalize the stranded run: finalized=%d", res.finalized) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "completed" { + t.Fatalf("run not converged after recovery tick: status=%q", got.Status) + } + if !got.TaskID.Valid || got.TaskID.Bytes != dispatched.ID.Bytes { + t.Fatal("recovery tick did not bind the run to its dispatched task") + } +} + +// TestReconcileAtBootLockLoserSkipsThenTakesOver is the P0-3 "lock contention winner +// fails, loser / a subsequent round takes over" counter-example (MUL-4809 §4.1 P0-3). +// While another replica holds the reconcile advisory lock (its own tick in flight or +// errored), this replica must skip without stranding the run; once the lock frees, a +// later tick on this replica takes over and converges it. +func TestReconcileAtBootLockLoserSkipsThenTakesOver(t *testing.T) { + ctx := context.Background() + svc, agentID, run, pool, insertTask := newCreateIssueRunFixture(t) // gate ON + + insertTask(agentID, 0, "completed", run.ID) // stranded, reconcilable run + + // Simulate another replica ("winner") holding the reconcile advisory lock on its + // own session. + holder, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire lock holder: %v", err) + } + var locked bool + if err := holder.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", autopilotReconcileAdvisoryLockKey).Scan(&locked); err != nil || !locked { + holder.Release() + t.Fatalf("lock holder failed to take advisory lock: locked=%v err=%v", locked, err) + } + + // This replica loses the lock → skips this tick, leaving the run untouched. + res, err := svc.ReconcileTaskDrivenRunsAtBoot(ctx, pool) + if err != nil { + holder.Release() + t.Fatalf("boot reconcile (lock loser): %v", err) + } + if res.finalized != 0 { + holder.Release() + t.Fatalf("lock loser finalized %d runs, want 0", res.finalized) + } + mid, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + holder.Release() + t.Fatalf("get run: %v", err) + } + if mid.Status != "issue_created" { + holder.Release() + t.Fatalf("lock loser changed the run: status=%q", mid.Status) + } + + // The winner's tick ends (here: without converging) and releases the lock. A later + // tick on this replica takes over. + if _, err := holder.Exec(ctx, "SELECT pg_advisory_unlock($1)", autopilotReconcileAdvisoryLockKey); err != nil { + holder.Release() + t.Fatalf("release advisory lock: %v", err) + } + holder.Release() + + res, err = svc.ReconcileTaskDrivenRunsAtBoot(ctx, pool) + if err != nil { + t.Fatalf("boot reconcile (takeover): %v", err) + } + if res.finalized < 1 { + t.Fatalf("takeover tick did not finalize the stranded run: finalized=%d", res.finalized) + } + got, err := svc.Queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.Status != "completed" { + t.Fatalf("run not converged after lock takeover: status=%q", got.Status) + } +} diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index 98bcd584dfe..1806f57e11b 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -1112,20 +1112,32 @@ func (q *Queries) IsAutopilotCollaborator(ctx context.Context, arg IsAutopilotCo return is_collaborator, err } -const listActiveCreateIssueRuns = `-- name: ListActiveCreateIssueRuns :many +const listActiveCreateIssueRunsPaged = `-- name: ListActiveCreateIssueRunsPaged :many SELECT r.id, r.autopilot_id, r.trigger_id, r.source, r.status, r.issue_id, r.task_id, r.triggered_at, r.completed_at, r.failure_reason, r.trigger_payload, r.result, r.created_at, r.squad_id, r.planned_at, r.webhook_delivery_id FROM autopilot_run r JOIN autopilot a ON a.id = r.autopilot_id WHERE r.status IN ('issue_created', 'running') AND a.execution_mode = 'create_issue' + AND (r.created_at, r.id) > ($1::timestamptz, $2::uuid) +ORDER BY r.created_at ASC, r.id ASC +LIMIT $3 ` -// Active create_issue runs (issue_created / running) for the ON-boot reconcile that -// converges runs whose dispatched task already reached a terminal result while -// task-driven finalization was gated off — the event bus does not replay those past -// task events (MUL-4809 §4.1 P0-3). Joined to autopilot only to filter -// execution_mode; no FK (join, not a constraint). -func (q *Queries) ListActiveCreateIssueRuns(ctx context.Context) ([]AutopilotRun, error) { - rows, err := q.db.Query(ctx, listActiveCreateIssueRuns) +type ListActiveCreateIssueRunsPagedParams struct { + CursorCreatedAt pgtype.Timestamptz `json:"cursor_created_at"` + CursorID pgtype.UUID `json:"cursor_id"` + RowLimit int32 `json:"row_limit"` +} + +// One keyset page of active create_issue runs (issue_created / running) for the +// periodic task-driven reconcile that converges runs whose dispatched task already +// reached a terminal result while task-driven finalization was gated off — the event +// bus does not replay those past task events (MUL-4809 §4.1 P0-3). Ordered by +// (created_at, id); the caller pages forward with @cursor_created_at/@cursor_id and +// passes ('-infinity', zero-uuid) for the first page. Batching avoids materializing +// every active run at once. Joined to autopilot only to filter execution_mode; no FK +// (join, not a constraint). +func (q *Queries) ListActiveCreateIssueRunsPaged(ctx context.Context, arg ListActiveCreateIssueRunsPagedParams) ([]AutopilotRun, error) { + rows, err := q.db.Query(ctx, listActiveCreateIssueRunsPaged, arg.CursorCreatedAt, arg.CursorID, arg.RowLimit) if err != nil { return nil, err } diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index 10207e31265..e2b891146bc 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -496,16 +496,22 @@ WHERE retry_of_task_id = $1 ORDER BY created_at ASC, id ASC LIMIT 1; --- name: ListActiveCreateIssueRuns :many --- Active create_issue runs (issue_created / running) for the ON-boot reconcile that --- converges runs whose dispatched task already reached a terminal result while --- task-driven finalization was gated off — the event bus does not replay those past --- task events (MUL-4809 §4.1 P0-3). Joined to autopilot only to filter --- execution_mode; no FK (join, not a constraint). +-- name: ListActiveCreateIssueRunsPaged :many +-- One keyset page of active create_issue runs (issue_created / running) for the +-- periodic task-driven reconcile that converges runs whose dispatched task already +-- reached a terminal result while task-driven finalization was gated off — the event +-- bus does not replay those past task events (MUL-4809 §4.1 P0-3). Ordered by +-- (created_at, id); the caller pages forward with @cursor_created_at/@cursor_id and +-- passes ('-infinity', zero-uuid) for the first page. Batching avoids materializing +-- every active run at once. Joined to autopilot only to filter execution_mode; no FK +-- (join, not a constraint). SELECT r.* FROM autopilot_run r JOIN autopilot a ON a.id = r.autopilot_id WHERE r.status IN ('issue_created', 'running') - AND a.execution_mode = 'create_issue'; + AND a.execution_mode = 'create_issue' + AND (r.created_at, r.id) > (@cursor_created_at::timestamptz, @cursor_id::uuid) +ORDER BY r.created_at ASC, r.id ASC +LIMIT @row_limit; -- name: FailAutopilotRunsByIssue :exec -- Fails active autopilot runs linked to a given issue. From 90542a56aa8afdc9def9714e456fc0b9d0b80420 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 20 Jul 2026 22:44:54 +0800 Subject: [PATCH 27/41] =?UTF-8?q?fix(autopilot):=20make=20collision=20deli?= =?UTF-8?q?very=20retryable=20and=20retry-eligible=20reconcile=20durable?= =?UTF-8?q?=20(MUL-4809=20=C2=A74.1=20P0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-1 collision transient error keeps the delivery retryable: on any repair error DispatchAutopilotForWebhookDelivery now returns (nil, err) instead of a stale run, so the worker routes it to retryOrFail (delivery retried) rather than complete(failed) over a live run. A durable terminal outcome is still surfaced as (failedRun, nil). On retry exhaustion the worker calls FailActiveRunForWebhookDelivery, and the reconcile converges any dispatchless run whose delivery permanently failed (reconcileDispatchlessRun) — so a failed delivery never sits beside an active run. P0-2 retry-eligible leaf converges durably instead of skipping forever: - migration 211 adds a partial unique index on retry_of_task_id so a retry successor is concurrency-safe to back-fill; CreateRetryTask gains ON CONFLICT DO NOTHING, and FailTask / MaybeRetryFailedTask treat the resulting ErrNoRows as an idempotent "successor already exists". - the autopilot reconcile now BACK-FILLS the owed retry via MaybeRetryFailedTask when a failed leaf is still retry-eligible (crash between the sweeper's fail-commit and HandleFailedTasks, or a transient CreateRetryTask error), rather than returning not-ready every tick. - HandleFailedTasks no longer discards MaybeRetryFailedTask's error: on a retry-eligible failure it keeps the issue retry-pending, and the create_issue listener defers finalization while the leaf is retry-eligible, so the run is never failed off a non-final attempt. Counter-example tests: worker-level collision transient error stays retryable then converges; reconcile back-fills a missing retry then converges (idempotent); HandleFailedTasks retry error does not prematurely fail the run; MaybeRetryFailedTask is idempotent under the unique constraint; reconcile fails a dispatchless run off a permanently-failed delivery. Replaces the now-superseded skip-forever test. Co-authored-by: multica-agent --- .../internal/handler/webhook_delivery_test.go | 179 ++++++++++++ .../handler/webhook_delivery_worker.go | 6 + server/internal/service/autopilot.go | 137 ++++++++- .../service/autopilot_reconcile_test.go | 276 ++++++++++++++---- server/internal/service/task.go | 37 ++- .../211_agent_task_retry_of_unique.down.sql | 1 + .../211_agent_task_retry_of_unique.up.sql | 11 + server/pkg/db/generated/agent.sql.go | 6 + server/pkg/db/queries/agent.sql | 6 + 9 files changed, 585 insertions(+), 74 deletions(-) create mode 100644 server/migrations/211_agent_task_retry_of_unique.down.sql create mode 100644 server/migrations/211_agent_task_retry_of_unique.up.sql diff --git a/server/internal/handler/webhook_delivery_test.go b/server/internal/handler/webhook_delivery_test.go index d6c77f8fac9..4d4cf9dee98 100644 --- a/server/internal/handler/webhook_delivery_test.go +++ b/server/internal/handler/webhook_delivery_test.go @@ -932,6 +932,185 @@ func TestWebhookDeliveryWorker_PendingCollisionFailsDeliveryAndRun(t *testing.T) } } +// installAutopilotRunFailTransitionFault makes every UPDATE that moves an autopilot_run +// to 'failed' raise, simulating a transient error during the collision fail-transition. +func installAutopilotRunFailTransitionFault(t *testing.T) { + t.Helper() + ctx := context.Background() + if _, err := testPool.Exec(ctx, ` +CREATE OR REPLACE FUNCTION mul4809_run_fail_fault() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.status = 'failed' THEN + RAISE EXCEPTION 'forced autopilot_run fail-transition fault'; + END IF; + RETURN NEW; +END; +$$;`); err != nil { + t.Fatalf("install run-fail fault fn: %v", err) + } + if _, err := testPool.Exec(ctx, ` +CREATE TRIGGER mul4809_run_fail_fault_trg +BEFORE UPDATE ON autopilot_run +FOR EACH ROW EXECUTE FUNCTION mul4809_run_fail_fault();`); err != nil { + t.Fatalf("install run-fail fault trigger: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DROP TRIGGER IF EXISTS mul4809_run_fail_fault_trg ON autopilot_run`) + testPool.Exec(context.Background(), `DROP FUNCTION IF EXISTS mul4809_run_fail_fault()`) + }) +} + +// TestWebhookDeliveryWorker_CollisionTransientErrorRetriesThenConverges is the P0-1 +// "transient collision error stays retryable" counter-example driven through the real +// WebhookDeliveryWorker (MUL-4809 §4.1 P0-1). When the dispatch collision's +// fail-transition hits a transient error, the delivery must NOT be recorded failed over +// a still-active run — it must be retried. After the error clears, the next processing +// converges the run and the delivery to failed together. +func TestWebhookDeliveryWorker_CollisionTransientErrorRetriesThenConverges(t *testing.T) { + agentID := createWebhookTestAgent(t, "WorkerCollisionRetry Agent") + apID := createWebhookTestAutopilot(t, agentID, "active", "create_issue") + trig := createWebhookTriggerViaHandler(t, apID) + + post := postWebhook(t, *trig.WebhookToken, map[string]any{"event": "collision-retry"}, map[string]string{ + "Idempotency-Key": "worker-collision-retry", + }) + deliveryID := requireAcceptedWebhookResponse(t, post) + first := processQueuedWebhookDelivery(t, deliveryID) + run, err := testHandler.Queries.GetAutopilotRun(context.Background(), first.AutopilotRunID) + if err != nil || !run.IssueID.Valid { + t.Fatalf("load create_issue run: run=%#v err=%v", run, err) + } + issueID := uuidToString(run.IssueID) + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) + testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Pre-bind crash + a stray pending comment task so the repair's dispatched enqueue + // collides and takes the fail-transition path. + if _, err := testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID); err != nil { + t.Fatalf("remove dispatched task: %v", err) + } + if _, err := testPool.Exec(context.Background(), + `UPDATE autopilot_run SET status = 'issue_created', task_id = NULL WHERE id = $1`, first.AutopilotRunID); err != nil { + t.Fatalf("reset run: %v", err) + } + if _, err := testPool.Exec(context.Background(), ` + INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, created_at) + SELECT a.id, a.runtime_id, $2::uuid, 'queued', 0, now() FROM agent a WHERE a.id = $1`, + agentID, issueID); err != nil { + t.Fatalf("insert stray pending task: %v", err) + } + + // The collision's fail-transition errors on this pass. + installAutopilotRunFailTransitionFault(t) + if _, err := testPool.Exec(context.Background(), ` + UPDATE webhook_delivery SET status = 'queued', autopilot_run_id = NULL, available_at = now(), + dispatch_attempts = 0, lease_token = NULL, lease_expires_at = NULL WHERE id = $1`, deliveryID); err != nil { + t.Fatalf("requeue delivery: %v", err) + } + + worked, err := testHandler.WebhookDeliveryWorker.ProcessNext(context.Background()) + if err != nil || !worked { + t.Fatalf("fault pass: worked=%v err=%v", worked, err) + } + deferred, err := testHandler.Queries.GetWebhookDelivery(context.Background(), parseUUID(deliveryID)) + if err != nil { + t.Fatalf("load deferred delivery: %v", err) + } + if deferred.Status == deliveryStatusFailed { + t.Fatal("a transient fail-transition error must not terminate the delivery") + } + if deferred.Status != deliveryStatusQueued { + t.Fatalf("expected the delivery to stay retryable (queued), got %q", deferred.Status) + } + runMid, err := testHandler.Queries.GetAutopilotRun(context.Background(), first.AutopilotRunID) + if err != nil { + t.Fatalf("load run mid: %v", err) + } + if runMid.Status != "issue_created" { + t.Fatalf("run must stay active while the delivery is retryable, got %q", runMid.Status) + } + + // Clear the fault + backoff and reprocess: the fail-transition now succeeds, so the + // run and the delivery converge to failed together. + if _, err := testPool.Exec(context.Background(), `DROP TRIGGER IF EXISTS mul4809_run_fail_fault_trg ON autopilot_run`); err != nil { + t.Fatalf("clear fault: %v", err) + } + if _, err := testPool.Exec(context.Background(), `UPDATE webhook_delivery SET available_at = now() WHERE id = $1`, deliveryID); err != nil { + t.Fatalf("clear backoff: %v", err) + } + worked, err = testHandler.WebhookDeliveryWorker.ProcessNext(context.Background()) + if err != nil || !worked { + t.Fatalf("recovery pass: worked=%v err=%v", worked, err) + } + final, err := testHandler.Queries.GetWebhookDelivery(context.Background(), parseUUID(deliveryID)) + if err != nil { + t.Fatalf("load final delivery: %v", err) + } + if final.Status != deliveryStatusFailed { + t.Fatalf("recovered delivery must be failed, got %q", final.Status) + } + got, err := testHandler.Queries.GetAutopilotRun(context.Background(), first.AutopilotRunID) + if err != nil { + t.Fatalf("load final run: %v", err) + } + if got.Status != "failed" { + t.Fatalf("run must converge to failed together with the delivery, got %q", got.Status) + } +} + +// TestReconcileFailsDispatchlessRunWhenDeliveryPermanentlyFailed pins the durable +// backstop for the P0-1 exhaustion invariant (MUL-4809 §4.1 P0-1): if a collision run's +// fail-transition kept erroring until its delivery exhausted its retries (delivery +// permanently failed, run still active with no dispatched task), the reconcile converges +// the run off the delivery's durable terminal state — no failed delivery is left beside a +// live run. +func TestReconcileFailsDispatchlessRunWhenDeliveryPermanentlyFailed(t *testing.T) { + agentID := createWebhookTestAgent(t, "ReconcileDispatchless Agent") + apID := createWebhookTestAutopilot(t, agentID, "active", "create_issue") + trig := createWebhookTriggerViaHandler(t, apID) + + post := postWebhook(t, *trig.WebhookToken, map[string]any{"event": "dispatchless"}, map[string]string{ + "Idempotency-Key": "reconcile-dispatchless", + }) + deliveryID := requireAcceptedWebhookResponse(t, post) + first := processQueuedWebhookDelivery(t, deliveryID) + run, err := testHandler.Queries.GetAutopilotRun(context.Background(), first.AutopilotRunID) + if err != nil || !run.IssueID.Valid { + t.Fatalf("load create_issue run: run=%#v err=%v", run, err) + } + issueID := uuidToString(run.IssueID) + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) + testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Strand it: no dispatched task, run active, delivery permanently failed. + if _, err := testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID); err != nil { + t.Fatalf("remove dispatched task: %v", err) + } + if _, err := testPool.Exec(context.Background(), + `UPDATE autopilot_run SET status = 'issue_created', task_id = NULL WHERE id = $1`, first.AutopilotRunID); err != nil { + t.Fatalf("reset run: %v", err) + } + if _, err := testPool.Exec(context.Background(), + `UPDATE webhook_delivery SET status = 'failed' WHERE id = $1`, deliveryID); err != nil { + t.Fatalf("fail delivery: %v", err) + } + + if _, err := testHandler.AutopilotService.ReconcileTaskDrivenRuns(context.Background()); err != nil { + t.Fatalf("reconcile: %v", err) + } + got, err := testHandler.Queries.GetAutopilotRun(context.Background(), first.AutopilotRunID) + if err != nil { + t.Fatalf("load run: %v", err) + } + if got.Status != "failed" { + t.Fatalf("reconcile did not converge the dispatchless run off the failed delivery: %q", got.Status) + } +} + func TestWebhookHandler_InvalidSignatureCountsAgainstRateLimit(t *testing.T) { // Only requests classified as bad credentials consume the shared-IP debt // budget; valid traffic behind the same NAT does not. diff --git a/server/internal/handler/webhook_delivery_worker.go b/server/internal/handler/webhook_delivery_worker.go index f8598463368..6b37f90a653 100644 --- a/server/internal/handler/webhook_delivery_worker.go +++ b/server/internal/handler/webhook_delivery_worker.go @@ -248,6 +248,12 @@ func (w *WebhookDeliveryWorker) complete( func (w *WebhookDeliveryWorker) retryOrFail(ctx context.Context, delivery db.WebhookDelivery, cause error) error { if delivery.DispatchAttempts+1 >= webhookWorkerMaxAttempts { + // The delivery has permanently failed. A transient dispatch error (returned as + // a nil run) is what routed us here, so a run may still be active/unbound — + // converge it first, then record the delivery failed, so we never leave a failed + // delivery beside a live run (MUL-4809 §4.1 P0-1). + w.h.AutopilotService.FailActiveRunForWebhookDelivery(ctx, delivery.ID, + "webhook delivery permanently failed: "+cause.Error()) return w.complete(ctx, delivery, deliveryStatusFailed, pgtype.UUID{}, cause.Error()) } backoff := time.Second * time.Duration(1< Date: Tue, 21 Jul 2026 15:01:22 +0800 Subject: [PATCH 28/41] =?UTF-8?q?fix(autopilot):=20ungate=20delivery-exhau?= =?UTF-8?q?stion=20backstop,=20make=20migration=20211=20re-run=20safe,=20k?= =?UTF-8?q?eep=20deferred=20retries=20pending=20(MUL-4809=20=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-1 gate-off exhaustion backstop: the dispatchless-run convergence is no longer behind FF_AUTOPILOT_TASK_DRIVEN_RUNS. ReconcileAutopilotRuns now gates only its task-outcome half; a run that never got a task and whose webhook delivery permanently failed converges off that durable signal during the gate-off first rollout phase too. FailActiveRunForWebhookDelivery returns an error instead of swallowing lookup/update failures, and the worker logs a non-authoritative convergence rather than dropping it. Renamed the reconcile entry points (ReconcileAutopilotRuns / ...AtBoot / RunAutopilotReconcileLoop) since they are no longer purely task-driven. P0-2 migration 211 re-run safety: added a pre-migration hook for 211_agent_task_retry_of_unique. Nothing constrained retry_of_task_id before 211, so duplicates make the concurrent unique build fail and leave an INVALID index; `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS` would then succeed on re-run and record 211 as applied while ON CONFLICT still errors 42P10. The hook drops a leftover invalid index (search_path-precise via to_regclass) and hard-fails on pre-existing duplicates, so a failed build is never recorded. P1 deferred retry idempotent conflict: MaybeRetryFailedTask now re-reads and returns the winner's successor on a unique conflict instead of (nil, nil). A backoff-armed DEFERRED successor is invisible to HasActiveTaskForIssue, so reporting nil let HandleFailedTasks reset the issue to todo while a retry was still pending. Counter-example tests: gate-OFF delivery exhaustion converges the run without flipping the gate; migration 211 blocks pre-existing duplicates without being recorded, and clears a leftover invalid index before rebuilding it VALID; a losing HandleFailedTasks with a deferred successor keeps the issue in_progress. Updated the idempotency test to the same-successor contract. Co-authored-by: multica-agent --- server/cmd/migrate/main.go | 63 ++++++ .../cmd/migrate/retry_of_unique_hook_test.go | 182 ++++++++++++++++++ server/cmd/server/main.go | 2 +- .../internal/handler/webhook_delivery_test.go | 112 ++++++++++- .../handler/webhook_delivery_worker.go | 15 +- server/internal/service/autopilot.go | 96 +++++---- .../service/autopilot_reconcile_test.go | 106 +++++++--- server/internal/service/task.go | 17 +- 8 files changed, 525 insertions(+), 68 deletions(-) create mode 100644 server/cmd/migrate/retry_of_unique_hook_test.go diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index 7402cd63491..f0f30bb624f 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -51,6 +51,69 @@ type preMigrationHook func(ctx context.Context, pool *pgxpool.Pool) error var preMigrationHooks = map[string]preMigrationHook{ "103_drop_legacy_daily_rollups": runTaskUsageHourlyHook, "198_agent_task_attribution_strict_constraint_validate": runAttributionStrictHook, + "211_agent_task_retry_of_unique": runRetryOfUniqueIndexHook, +} + +// retryOfUniqueIndexName is the unique index migration 211 builds; CreateRetryTask's +// ON CONFLICT (retry_of_task_id) resolves against it. +const retryOfUniqueIndexName = "idx_agent_task_retry_of_task_id_unique" + +// runRetryOfUniqueIndexHook makes migration 211 safe to (re)run (MUL-4809 §4.1 P0-2). +// 211 builds a UNIQUE index CONCURRENTLY on agent_task_queue.retry_of_task_id, and the +// correctness of CreateRetryTask's idempotent ON CONFLICT depends on that index existing +// AND being valid. Two real PostgreSQL behaviours make a naive build unsafe: +// +// 1. Nothing constrained the column before 211, so pre-existing duplicate +// retry_of_task_id rows make the concurrent build fail — and it leaves an INVALID +// index (indisvalid=false) behind. +// 2. Re-running `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS` then finds that +// invalid index, reports "already exists" and SUCCEEDS, so the runner would record +// 211 as applied while every auto-retry still fails with 42P10 (no unique or +// exclusion constraint matching the ON CONFLICT specification). +// +// This hook runs before every attempt at 211 — a migration that fails is never recorded, +// so the next `migrate up` retries hook + migration. It drops a leftover INVALID index so +// the rebuild starts clean, and hard-fails on pre-existing duplicates instead of letting a +// silently-unenforced index through. +func runRetryOfUniqueIndexHook(ctx context.Context, pool *pgxpool.Pool) error { + // to_regclass resolves through the same search_path the DROP below uses, so we can + // never inspect one schema's index and drop another's. + var invalid bool + if err := pool.QueryRow(ctx, ` + SELECT COALESCE( + (SELECT NOT i.indisvalid FROM pg_index i WHERE i.indexrelid = to_regclass($1)), + false + )`, retryOfUniqueIndexName).Scan(&invalid); err != nil { + return fmt.Errorf("retry_of unique pre-211 hook: inspect index: %w", err) + } + if invalid { + // An invalid index is never used to answer queries and cannot satisfy ON CONFLICT, + // so dropping it is safe; the migration rebuilds it CONCURRENTLY right after. + if _, err := pool.Exec(ctx, `DROP INDEX IF EXISTS `+retryOfUniqueIndexName); err != nil { + return fmt.Errorf("retry_of unique pre-211 hook: drop invalid index: %w", err) + } + slog.Warn("dropped leftover invalid unique index before rebuild", "index", retryOfUniqueIndexName) + } + + var dupes int64 + if err := pool.QueryRow(ctx, ` + SELECT count(*) FROM ( + SELECT retry_of_task_id + FROM agent_task_queue + WHERE retry_of_task_id IS NOT NULL + GROUP BY retry_of_task_id + HAVING count(*) > 1 + LIMIT 100 + ) d`).Scan(&dupes); err != nil { + return fmt.Errorf("retry_of unique pre-211 hook: duplicate scan: %w", err) + } + if dupes > 0 { + return fmt.Errorf("retry_of unique pre-211 hook: %d parent task(s) have more than one "+ + "system-retry successor, so the unique index on agent_task_queue.retry_of_task_id "+ + "cannot be built. Keep the earliest successor per retry_of_task_id, delete the rest, "+ + "then re-run `migrate up`", dupes) + } + return nil } func runTaskUsageHourlyHook(ctx context.Context, pool *pgxpool.Pool) error { diff --git a/server/cmd/migrate/retry_of_unique_hook_test.go b/server/cmd/migrate/retry_of_unique_hook_test.go new file mode 100644 index 00000000000..338ada802db --- /dev/null +++ b/server/cmd/migrate/retry_of_unique_hook_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "fmt" + "math/rand/v2" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// MUL-4809 §4.1 P0-2 — migration 211 re-run safety. +// +// 211 builds a UNIQUE index CONCURRENTLY on agent_task_queue.retry_of_task_id, and +// CreateRetryTask's idempotent ON CONFLICT resolves against it. Real PostgreSQL leaves an +// INVALID index behind when a concurrent unique build fails on pre-existing duplicates, +// and `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS` then SUCCEEDS on the re-run — which +// would record 211 as applied while every auto-retry still fails with 42P10. These tests +// pin the pre-migration hook that makes the sequence safe. +// +// The hook references agent_task_queue unqualified, so the sandbox gives it a private +// schema on the search_path rather than touching the real table. + +// newRetryOfHookSandbox creates an isolated schema holding a minimal agent_task_queue and +// returns a pool whose search_path resolves to it. +func newRetryOfHookSandbox(t *testing.T) (*pgxpool.Pool, string) { + t.Helper() + admin := openTestPool(t) + ctx := context.Background() + schema := fmt.Sprintf("mul4809_hook_%d_%d", time.Now().UnixNano(), rand.Uint32()) + if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA %s`, pgx.Identifier{schema}.Sanitize())); err != nil { + t.Fatalf("create sandbox schema: %v", err) + } + t.Cleanup(func() { + c, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := admin.Exec(c, fmt.Sprintf(`DROP SCHEMA IF EXISTS %s CASCADE`, pgx.Identifier{schema}.Sanitize())); err != nil { + t.Logf("drop sandbox schema %s: %v", schema, err) + } + }) + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable" + } + cfg, err := pgxpool.ParseConfig(dbURL) + if err != nil { + t.Fatalf("parse sandbox config: %v", err) + } + cfg.ConnConfig.RuntimeParams["search_path"] = schema + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Fatalf("open sandbox pool: %v", err) + } + t.Cleanup(pool.Close) + + if _, err := pool.Exec(ctx, ` + CREATE TABLE agent_task_queue ( + id BIGSERIAL PRIMARY KEY, + retry_of_task_id BIGINT + )`); err != nil { + t.Fatalf("create sandbox agent_task_queue: %v", err) + } + return pool, schema +} + +// retryOfMigrationOpts writes the real 211 body to a temp dir and wires runOptions to the +// sandbox bookkeeping table plus the PRODUCTION hook map. +func retryOfMigrationOpts(t *testing.T, schema string) runOptions { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "211_agent_task_retry_of_unique.up.sql") + body := "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS " + retryOfUniqueIndexName + "\n" + + " ON agent_task_queue (retry_of_task_id)\n" + + " WHERE retry_of_task_id IS NOT NULL;\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write 211 migration: %v", err) + } + return runOptions{ + Direction: "up", + Files: []string{path}, + SchemaMigrationsTable: schema + ".schema_migrations", + AdvisoryLockKey: int64(rand.Uint64()&0x7fffffffffffffff) | 1, + Hooks: preMigrationHooks, + } +} + +// retryOfIndexState reports whether the sandbox index exists and whether it is valid, +// resolved through the pool's search_path (same resolution the hook uses). +func retryOfIndexState(t *testing.T, pool *pgxpool.Pool) (exists, valid bool) { + t.Helper() + var v *bool + if err := pool.QueryRow(context.Background(), + `SELECT (SELECT i.indisvalid FROM pg_index i WHERE i.indexrelid = to_regclass($1))`, + retryOfUniqueIndexName).Scan(&v); err != nil { + t.Fatalf("inspect index state: %v", err) + } + if v == nil { + return false, false + } + return true, *v +} + +func retryOfAppliedCount(t *testing.T, pool *pgxpool.Pool, table string) int { + t.Helper() + var n int + if err := pool.QueryRow(context.Background(), + fmt.Sprintf(`SELECT count(*) FROM %s WHERE version = $1`, table), + "211_agent_task_retry_of_unique").Scan(&n); err != nil { + t.Fatalf("count applied versions: %v", err) + } + return n +} + +// TestRetryOfUniqueHookBlocksPreexistingDuplicates: nothing constrained retry_of_task_id +// before 211, so a rolling deploy could carry duplicates. The hook must hard-fail the run +// rather than let a silently-unenforced index through, and the failed migration must NOT be +// recorded as applied. +func TestRetryOfUniqueHookBlocksPreexistingDuplicates(t *testing.T) { + pool, schema := newRetryOfHookSandbox(t) + ctx := context.Background() + + if _, err := pool.Exec(ctx, `INSERT INTO agent_task_queue (retry_of_task_id) VALUES (1), (1)`); err != nil { + t.Fatalf("seed duplicates: %v", err) + } + + opts := retryOfMigrationOpts(t, schema) + if err := runMigrations(ctx, pool, opts); err == nil { + t.Fatal("migration must fail while duplicate retry_of_task_id rows exist") + } + if n := retryOfAppliedCount(t, pool, opts.SchemaMigrationsTable); n != 0 { + t.Fatalf("a blocked migration must not be recorded as applied, got %d row(s)", n) + } + if exists, valid := retryOfIndexState(t, pool); exists && !valid { + t.Fatal("a blocked migration must not leave an invalid index behind") + } +} + +// TestRetryOfUniqueHookClearsLeftoverInvalidIndexAndRebuilds reproduces the exact +// PostgreSQL sequence: a first concurrent unique build fails on duplicates and leaves an +// INVALID index. Once the duplicates are resolved, re-running the migration must NOT be +// masked by `IF NOT EXISTS` — the hook drops the leftover invalid index and the migration +// rebuilds a VALID one, so ON CONFLICT keeps working. +func TestRetryOfUniqueHookClearsLeftoverInvalidIndexAndRebuilds(t *testing.T) { + pool, schema := newRetryOfHookSandbox(t) + ctx := context.Background() + + if _, err := pool.Exec(ctx, `INSERT INTO agent_task_queue (retry_of_task_id) VALUES (1), (1)`); err != nil { + t.Fatalf("seed duplicates: %v", err) + } + // The first build fails and leaves an invalid index behind — real PG behaviour. + if _, err := pool.Exec(ctx, `CREATE UNIQUE INDEX CONCURRENTLY `+retryOfUniqueIndexName+ + ` ON agent_task_queue (retry_of_task_id) WHERE retry_of_task_id IS NOT NULL`); err == nil { + t.Fatal("expected the first concurrent unique build to fail on duplicates") + } + if exists, valid := retryOfIndexState(t, pool); !exists || valid { + t.Fatalf("precondition: expected a leftover INVALID index, exists=%v valid=%v", exists, valid) + } + + // The operator resolves the duplicates, keeping the earliest successor. + if _, err := pool.Exec(ctx, ` + DELETE FROM agent_task_queue + WHERE id NOT IN (SELECT min(id) FROM agent_task_queue GROUP BY retry_of_task_id)`); err != nil { + t.Fatalf("resolve duplicates: %v", err) + } + + opts := retryOfMigrationOpts(t, schema) + if err := runMigrations(ctx, pool, opts); err != nil { + t.Fatalf("migration after duplicates were resolved: %v", err) + } + exists, valid := retryOfIndexState(t, pool) + if !exists || !valid { + t.Fatalf("expected a VALID unique index after rebuild, exists=%v valid=%v", exists, valid) + } + if n := retryOfAppliedCount(t, pool, opts.SchemaMigrationsTable); n != 1 { + t.Fatalf("successful migration should be recorded exactly once, got %d", n) + } +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index e286a23a0bf..f87e0a19042 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -444,7 +444,7 @@ func main() { // a run left behind by a transient error or an unsettled retry lineage converges // on a later tick. No-op while the gate is off; each tick is advisory-locked so a // single replica walks during a rolling deploy. - go autopilotSvc.RunTaskDrivenReconcileLoop(sweepCtx, pool) + go autopilotSvc.RunAutopilotReconcileLoop(sweepCtx, pool) // Start background sweeper to mark stale runtimes as offline. go runRuntimeSweeper(sweepCtx, queries, liveness, taskSvc, bus) diff --git a/server/internal/handler/webhook_delivery_test.go b/server/internal/handler/webhook_delivery_test.go index 4d4cf9dee98..303b80afd92 100644 --- a/server/internal/handler/webhook_delivery_test.go +++ b/server/internal/handler/webhook_delivery_test.go @@ -14,7 +14,9 @@ import ( "time" "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/featureflags" db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/featureflag" ) // ── Setup helpers ─────────────────────────────────────────────────────────── @@ -1099,7 +1101,7 @@ func TestReconcileFailsDispatchlessRunWhenDeliveryPermanentlyFailed(t *testing.T t.Fatalf("fail delivery: %v", err) } - if _, err := testHandler.AutopilotService.ReconcileTaskDrivenRuns(context.Background()); err != nil { + if _, err := testHandler.AutopilotService.ReconcileAutopilotRuns(context.Background()); err != nil { t.Fatalf("reconcile: %v", err) } got, err := testHandler.Queries.GetAutopilotRun(context.Background(), first.AutopilotRunID) @@ -1111,6 +1113,114 @@ func TestReconcileFailsDispatchlessRunWhenDeliveryPermanentlyFailed(t *testing.T } } +// withTaskDrivenGateOff forces the FIRST rollout phase (FF_AUTOPILOT_TASK_DRIVEN_RUNS +// default off) for the duration of a test. +func withTaskDrivenGateOff(t *testing.T) { + t.Helper() + prev := testHandler.AutopilotService.FeatureFlags + provider := featureflag.NewStaticProvider() + provider.Set(featureflags.AutopilotTaskDrivenRuns, featureflag.Rule{Default: false}) + testHandler.AutopilotService.FeatureFlags = featureflag.NewService(provider) + t.Cleanup(func() { testHandler.AutopilotService.FeatureFlags = prev }) +} + +// TestWebhookDeliveryExhaustionConvergesRunWhileGateOff is the P0-1 two-phase-rollout +// counter-example (MUL-4809 §4.1 P0-1). Deployed as the PR requires — task-driven gate +// OFF — a collision whose fail-transition fault persists all the way to delivery +// exhaustion leaves `delivery=failed + run=active/unbound`. The durable backstop must NOT +// be gated behind the task-driven flag: once the fault clears, the reconcile has to +// converge the run WITHOUT flipping the gate. +func TestWebhookDeliveryExhaustionConvergesRunWhileGateOff(t *testing.T) { + withTaskDrivenGateOff(t) + ctx := context.Background() + + agentID := createWebhookTestAgent(t, "GateOffExhaustion Agent") + apID := createWebhookTestAutopilot(t, agentID, "active", "create_issue") + trig := createWebhookTriggerViaHandler(t, apID) + + post := postWebhook(t, *trig.WebhookToken, map[string]any{"event": "gate-off-exhaustion"}, map[string]string{ + "Idempotency-Key": "gate-off-exhaustion", + }) + deliveryID := requireAcceptedWebhookResponse(t, post) + first := processQueuedWebhookDelivery(t, deliveryID) + run, err := testHandler.Queries.GetAutopilotRun(ctx, first.AutopilotRunID) + if err != nil || !run.IssueID.Valid { + t.Fatalf("load create_issue run: run=%#v err=%v", run, err) + } + issueID := uuidToString(run.IssueID) + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) + testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Pre-bind crash + stray pending task so the repair collides, and a fault that keeps + // the collision's fail-transition erroring for every attempt. + if _, err := testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID); err != nil { + t.Fatalf("remove dispatched task: %v", err) + } + if _, err := testPool.Exec(ctx, + `UPDATE autopilot_run SET status = 'issue_created', task_id = NULL WHERE id = $1`, first.AutopilotRunID); err != nil { + t.Fatalf("reset run: %v", err) + } + if _, err := testPool.Exec(ctx, ` + INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, created_at) + SELECT a.id, a.runtime_id, $2::uuid, 'queued', 0, now() FROM agent a WHERE a.id = $1`, + agentID, issueID); err != nil { + t.Fatalf("insert stray pending task: %v", err) + } + installAutopilotRunFailTransitionFault(t) + if _, err := testPool.Exec(ctx, ` + UPDATE webhook_delivery SET status = 'queued', autopilot_run_id = NULL, available_at = now(), + dispatch_attempts = 0, lease_token = NULL, lease_expires_at = NULL WHERE id = $1`, deliveryID); err != nil { + t.Fatalf("requeue delivery: %v", err) + } + + // Drive the worker until the delivery exhausts its retries. + var delivery db.WebhookDelivery + for i := 0; i < webhookWorkerMaxAttempts+1; i++ { + if _, err := testPool.Exec(ctx, `UPDATE webhook_delivery SET available_at = now() WHERE id = $1`, deliveryID); err != nil { + t.Fatalf("clear backoff: %v", err) + } + worked, err := testHandler.WebhookDeliveryWorker.ProcessNext(ctx) + if err != nil || !worked { + t.Fatalf("attempt %d: worked=%v err=%v", i, worked, err) + } + delivery, err = testHandler.Queries.GetWebhookDelivery(ctx, parseUUID(deliveryID)) + if err != nil { + t.Fatalf("load delivery: %v", err) + } + if delivery.Status == deliveryStatusFailed { + break + } + } + if delivery.Status != deliveryStatusFailed { + t.Fatalf("delivery should have exhausted its retries, got %q", delivery.Status) + } + // The fault also blocked the worker's immediate convergence, so the run is stranded. + stranded, err := testHandler.Queries.GetAutopilotRun(ctx, first.AutopilotRunID) + if err != nil { + t.Fatalf("load stranded run: %v", err) + } + if stranded.Status != "issue_created" { + t.Fatalf("precondition: expected the run to still be active, got %q", stranded.Status) + } + + // Fault clears. The gate stays OFF — the reconcile must still converge the run. + if _, err := testPool.Exec(ctx, `DROP TRIGGER IF EXISTS mul4809_run_fail_fault_trg ON autopilot_run`); err != nil { + t.Fatalf("clear fault: %v", err) + } + if _, err := testHandler.AutopilotService.ReconcileAutopilotRuns(ctx); err != nil { + t.Fatalf("gate-off reconcile: %v", err) + } + got, err := testHandler.Queries.GetAutopilotRun(ctx, first.AutopilotRunID) + if err != nil { + t.Fatalf("load run: %v", err) + } + if got.Status != "failed" { + t.Fatalf("gate-off reconcile did not converge the stranded run: %q", got.Status) + } +} + func TestWebhookHandler_InvalidSignatureCountsAgainstRateLimit(t *testing.T) { // Only requests classified as bad credentials consume the shared-IP debt // budget; valid traffic behind the same NAT does not. diff --git a/server/internal/handler/webhook_delivery_worker.go b/server/internal/handler/webhook_delivery_worker.go index 6b37f90a653..9cf62dec948 100644 --- a/server/internal/handler/webhook_delivery_worker.go +++ b/server/internal/handler/webhook_delivery_worker.go @@ -251,9 +251,18 @@ func (w *WebhookDeliveryWorker) retryOrFail(ctx context.Context, delivery db.Web // The delivery has permanently failed. A transient dispatch error (returned as // a nil run) is what routed us here, so a run may still be active/unbound — // converge it first, then record the delivery failed, so we never leave a failed - // delivery beside a live run (MUL-4809 §4.1 P0-1). - w.h.AutopilotService.FailActiveRunForWebhookDelivery(ctx, delivery.ID, - "webhook delivery permanently failed: "+cause.Error()) + // delivery beside a live run (MUL-4809 §4.1 P0-1). If that convergence is NOT + // authoritative (e.g. the same fault still blocks the fail-transition) we must not + // drop it silently: log it and rely on the dispatchless reconcile, which converges + // the run off this delivery's terminal state and runs even while the task-driven + // gate is off. + if failErr := w.h.AutopilotService.FailActiveRunForWebhookDelivery(ctx, delivery.ID, + "webhook delivery permanently failed: "+cause.Error()); failErr != nil { + slog.Warn("webhook worker: run not converged on delivery exhaustion; deferring to dispatchless reconcile", + "delivery_id", uuidToString(delivery.ID), + "error", failErr, + ) + } return w.complete(ctx, delivery, deliveryStatusFailed, pgtype.UUID{}, cause.Error()) } backoff := time.Second * time.Duration(1< Date: Tue, 21 Jul 2026 15:15:27 +0800 Subject: [PATCH 29/41] chore(migrations): renumber 202-211 to 203-212 after main added 202_runtime_profile_add_qwen main merged its own migration at prefix 202 while this branch owned 202-211, so the merged tree tripped TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Shift this branch's migrations up by one (issue_status 203-209, dispatched_autopilot_run 210-211, retry_of unique index 212) and leave main's 202_runtime_profile_add_qwen where it is. Updates the pre-migration hook key and its comments/tests to 212. Co-authored-by: multica-agent --- server/cmd/migrate/main.go | 22 +++++++++---------- .../cmd/migrate/retry_of_unique_hook_test.go | 16 +++++++------- ...tus.down.sql => 203_issue_status.down.sql} | 0 ..._status.up.sql => 203_issue_status.up.sql} | 0 ...l => 204_issue_status_name_index.down.sql} | 0 ...sql => 204_issue_status_name_index.up.sql} | 0 ...05_issue_status_system_key_index.down.sql} | 0 ... 205_issue_status_system_key_index.up.sql} | 0 ...ue_status_category_default_index.down.sql} | 0 ...ssue_status_category_default_index.up.sql} | 0 ..._issue_status_workspace_id_index.down.sql} | 0 ...07_issue_status_workspace_id_index.up.sql} | 0 ...ql => 208_issue_status_id_column.down.sql} | 0 ....sql => 208_issue_status_id_column.up.sql} | 0 ...sql => 209_issue_status_id_index.down.sql} | 0 ...p.sql => 209_issue_status_id_index.up.sql} | 0 ...nt_task_dispatched_autopilot_run.down.sql} | 0 ...gent_task_dispatched_autopilot_run.up.sql} | 0 ...k_dispatched_autopilot_run_index.down.sql} | 0 ...ask_dispatched_autopilot_run_index.up.sql} | 0 ...> 212_agent_task_retry_of_unique.down.sql} | 0 ... => 212_agent_task_retry_of_unique.up.sql} | 0 22 files changed, 19 insertions(+), 19 deletions(-) rename server/migrations/{202_issue_status.down.sql => 203_issue_status.down.sql} (100%) rename server/migrations/{202_issue_status.up.sql => 203_issue_status.up.sql} (100%) rename server/migrations/{203_issue_status_name_index.down.sql => 204_issue_status_name_index.down.sql} (100%) rename server/migrations/{203_issue_status_name_index.up.sql => 204_issue_status_name_index.up.sql} (100%) rename server/migrations/{204_issue_status_system_key_index.down.sql => 205_issue_status_system_key_index.down.sql} (100%) rename server/migrations/{204_issue_status_system_key_index.up.sql => 205_issue_status_system_key_index.up.sql} (100%) rename server/migrations/{205_issue_status_category_default_index.down.sql => 206_issue_status_category_default_index.down.sql} (100%) rename server/migrations/{205_issue_status_category_default_index.up.sql => 206_issue_status_category_default_index.up.sql} (100%) rename server/migrations/{206_issue_status_workspace_id_index.down.sql => 207_issue_status_workspace_id_index.down.sql} (100%) rename server/migrations/{206_issue_status_workspace_id_index.up.sql => 207_issue_status_workspace_id_index.up.sql} (100%) rename server/migrations/{207_issue_status_id_column.down.sql => 208_issue_status_id_column.down.sql} (100%) rename server/migrations/{207_issue_status_id_column.up.sql => 208_issue_status_id_column.up.sql} (100%) rename server/migrations/{208_issue_status_id_index.down.sql => 209_issue_status_id_index.down.sql} (100%) rename server/migrations/{208_issue_status_id_index.up.sql => 209_issue_status_id_index.up.sql} (100%) rename server/migrations/{209_agent_task_dispatched_autopilot_run.down.sql => 210_agent_task_dispatched_autopilot_run.down.sql} (100%) rename server/migrations/{209_agent_task_dispatched_autopilot_run.up.sql => 210_agent_task_dispatched_autopilot_run.up.sql} (100%) rename server/migrations/{210_agent_task_dispatched_autopilot_run_index.down.sql => 211_agent_task_dispatched_autopilot_run_index.down.sql} (100%) rename server/migrations/{210_agent_task_dispatched_autopilot_run_index.up.sql => 211_agent_task_dispatched_autopilot_run_index.up.sql} (100%) rename server/migrations/{211_agent_task_retry_of_unique.down.sql => 212_agent_task_retry_of_unique.down.sql} (100%) rename server/migrations/{211_agent_task_retry_of_unique.up.sql => 212_agent_task_retry_of_unique.up.sql} (100%) diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index f0f30bb624f..c87cff2656b 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -51,27 +51,27 @@ type preMigrationHook func(ctx context.Context, pool *pgxpool.Pool) error var preMigrationHooks = map[string]preMigrationHook{ "103_drop_legacy_daily_rollups": runTaskUsageHourlyHook, "198_agent_task_attribution_strict_constraint_validate": runAttributionStrictHook, - "211_agent_task_retry_of_unique": runRetryOfUniqueIndexHook, + "212_agent_task_retry_of_unique": runRetryOfUniqueIndexHook, } -// retryOfUniqueIndexName is the unique index migration 211 builds; CreateRetryTask's +// retryOfUniqueIndexName is the unique index migration 212 builds; CreateRetryTask's // ON CONFLICT (retry_of_task_id) resolves against it. const retryOfUniqueIndexName = "idx_agent_task_retry_of_task_id_unique" -// runRetryOfUniqueIndexHook makes migration 211 safe to (re)run (MUL-4809 §4.1 P0-2). -// 211 builds a UNIQUE index CONCURRENTLY on agent_task_queue.retry_of_task_id, and the +// runRetryOfUniqueIndexHook makes migration 212 safe to (re)run (MUL-4809 §4.1 P0-2). +// 212 builds a UNIQUE index CONCURRENTLY on agent_task_queue.retry_of_task_id, and the // correctness of CreateRetryTask's idempotent ON CONFLICT depends on that index existing // AND being valid. Two real PostgreSQL behaviours make a naive build unsafe: // -// 1. Nothing constrained the column before 211, so pre-existing duplicate +// 1. Nothing constrained the column before 212, so pre-existing duplicate // retry_of_task_id rows make the concurrent build fail — and it leaves an INVALID // index (indisvalid=false) behind. // 2. Re-running `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS` then finds that // invalid index, reports "already exists" and SUCCEEDS, so the runner would record -// 211 as applied while every auto-retry still fails with 42P10 (no unique or +// 212 as applied while every auto-retry still fails with 42P10 (no unique or // exclusion constraint matching the ON CONFLICT specification). // -// This hook runs before every attempt at 211 — a migration that fails is never recorded, +// This hook runs before every attempt at 212 — a migration that fails is never recorded, // so the next `migrate up` retries hook + migration. It drops a leftover INVALID index so // the rebuild starts clean, and hard-fails on pre-existing duplicates instead of letting a // silently-unenforced index through. @@ -84,13 +84,13 @@ func runRetryOfUniqueIndexHook(ctx context.Context, pool *pgxpool.Pool) error { (SELECT NOT i.indisvalid FROM pg_index i WHERE i.indexrelid = to_regclass($1)), false )`, retryOfUniqueIndexName).Scan(&invalid); err != nil { - return fmt.Errorf("retry_of unique pre-211 hook: inspect index: %w", err) + return fmt.Errorf("retry_of unique pre-212 hook: inspect index: %w", err) } if invalid { // An invalid index is never used to answer queries and cannot satisfy ON CONFLICT, // so dropping it is safe; the migration rebuilds it CONCURRENTLY right after. if _, err := pool.Exec(ctx, `DROP INDEX IF EXISTS `+retryOfUniqueIndexName); err != nil { - return fmt.Errorf("retry_of unique pre-211 hook: drop invalid index: %w", err) + return fmt.Errorf("retry_of unique pre-212 hook: drop invalid index: %w", err) } slog.Warn("dropped leftover invalid unique index before rebuild", "index", retryOfUniqueIndexName) } @@ -105,10 +105,10 @@ func runRetryOfUniqueIndexHook(ctx context.Context, pool *pgxpool.Pool) error { HAVING count(*) > 1 LIMIT 100 ) d`).Scan(&dupes); err != nil { - return fmt.Errorf("retry_of unique pre-211 hook: duplicate scan: %w", err) + return fmt.Errorf("retry_of unique pre-212 hook: duplicate scan: %w", err) } if dupes > 0 { - return fmt.Errorf("retry_of unique pre-211 hook: %d parent task(s) have more than one "+ + return fmt.Errorf("retry_of unique pre-212 hook: %d parent task(s) have more than one "+ "system-retry successor, so the unique index on agent_task_queue.retry_of_task_id "+ "cannot be built. Keep the earliest successor per retry_of_task_id, delete the rest, "+ "then re-run `migrate up`", dupes) diff --git a/server/cmd/migrate/retry_of_unique_hook_test.go b/server/cmd/migrate/retry_of_unique_hook_test.go index 338ada802db..c0e9b2c69e2 100644 --- a/server/cmd/migrate/retry_of_unique_hook_test.go +++ b/server/cmd/migrate/retry_of_unique_hook_test.go @@ -13,13 +13,13 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// MUL-4809 §4.1 P0-2 — migration 211 re-run safety. +// MUL-4809 §4.1 P0-2 — migration 212 re-run safety. // -// 211 builds a UNIQUE index CONCURRENTLY on agent_task_queue.retry_of_task_id, and +// 212 builds a UNIQUE index CONCURRENTLY on agent_task_queue.retry_of_task_id, and // CreateRetryTask's idempotent ON CONFLICT resolves against it. Real PostgreSQL leaves an // INVALID index behind when a concurrent unique build fails on pre-existing duplicates, // and `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS` then SUCCEEDS on the re-run — which -// would record 211 as applied while every auto-retry still fails with 42P10. These tests +// would record 212 as applied while every auto-retry still fails with 42P10. These tests // pin the pre-migration hook that makes the sequence safe. // // The hook references agent_task_queue unqualified, so the sandbox gives it a private @@ -68,17 +68,17 @@ func newRetryOfHookSandbox(t *testing.T) (*pgxpool.Pool, string) { return pool, schema } -// retryOfMigrationOpts writes the real 211 body to a temp dir and wires runOptions to the +// retryOfMigrationOpts writes the real 212 body to a temp dir and wires runOptions to the // sandbox bookkeeping table plus the PRODUCTION hook map. func retryOfMigrationOpts(t *testing.T, schema string) runOptions { t.Helper() dir := t.TempDir() - path := filepath.Join(dir, "211_agent_task_retry_of_unique.up.sql") + path := filepath.Join(dir, "212_agent_task_retry_of_unique.up.sql") body := "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS " + retryOfUniqueIndexName + "\n" + " ON agent_task_queue (retry_of_task_id)\n" + " WHERE retry_of_task_id IS NOT NULL;\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { - t.Fatalf("write 211 migration: %v", err) + t.Fatalf("write 212 migration: %v", err) } return runOptions{ Direction: "up", @@ -110,14 +110,14 @@ func retryOfAppliedCount(t *testing.T, pool *pgxpool.Pool, table string) int { var n int if err := pool.QueryRow(context.Background(), fmt.Sprintf(`SELECT count(*) FROM %s WHERE version = $1`, table), - "211_agent_task_retry_of_unique").Scan(&n); err != nil { + "212_agent_task_retry_of_unique").Scan(&n); err != nil { t.Fatalf("count applied versions: %v", err) } return n } // TestRetryOfUniqueHookBlocksPreexistingDuplicates: nothing constrained retry_of_task_id -// before 211, so a rolling deploy could carry duplicates. The hook must hard-fail the run +// before 212, so a rolling deploy could carry duplicates. The hook must hard-fail the run // rather than let a silently-unenforced index through, and the failed migration must NOT be // recorded as applied. func TestRetryOfUniqueHookBlocksPreexistingDuplicates(t *testing.T) { diff --git a/server/migrations/202_issue_status.down.sql b/server/migrations/203_issue_status.down.sql similarity index 100% rename from server/migrations/202_issue_status.down.sql rename to server/migrations/203_issue_status.down.sql diff --git a/server/migrations/202_issue_status.up.sql b/server/migrations/203_issue_status.up.sql similarity index 100% rename from server/migrations/202_issue_status.up.sql rename to server/migrations/203_issue_status.up.sql diff --git a/server/migrations/203_issue_status_name_index.down.sql b/server/migrations/204_issue_status_name_index.down.sql similarity index 100% rename from server/migrations/203_issue_status_name_index.down.sql rename to server/migrations/204_issue_status_name_index.down.sql diff --git a/server/migrations/203_issue_status_name_index.up.sql b/server/migrations/204_issue_status_name_index.up.sql similarity index 100% rename from server/migrations/203_issue_status_name_index.up.sql rename to server/migrations/204_issue_status_name_index.up.sql diff --git a/server/migrations/204_issue_status_system_key_index.down.sql b/server/migrations/205_issue_status_system_key_index.down.sql similarity index 100% rename from server/migrations/204_issue_status_system_key_index.down.sql rename to server/migrations/205_issue_status_system_key_index.down.sql diff --git a/server/migrations/204_issue_status_system_key_index.up.sql b/server/migrations/205_issue_status_system_key_index.up.sql similarity index 100% rename from server/migrations/204_issue_status_system_key_index.up.sql rename to server/migrations/205_issue_status_system_key_index.up.sql diff --git a/server/migrations/205_issue_status_category_default_index.down.sql b/server/migrations/206_issue_status_category_default_index.down.sql similarity index 100% rename from server/migrations/205_issue_status_category_default_index.down.sql rename to server/migrations/206_issue_status_category_default_index.down.sql diff --git a/server/migrations/205_issue_status_category_default_index.up.sql b/server/migrations/206_issue_status_category_default_index.up.sql similarity index 100% rename from server/migrations/205_issue_status_category_default_index.up.sql rename to server/migrations/206_issue_status_category_default_index.up.sql diff --git a/server/migrations/206_issue_status_workspace_id_index.down.sql b/server/migrations/207_issue_status_workspace_id_index.down.sql similarity index 100% rename from server/migrations/206_issue_status_workspace_id_index.down.sql rename to server/migrations/207_issue_status_workspace_id_index.down.sql diff --git a/server/migrations/206_issue_status_workspace_id_index.up.sql b/server/migrations/207_issue_status_workspace_id_index.up.sql similarity index 100% rename from server/migrations/206_issue_status_workspace_id_index.up.sql rename to server/migrations/207_issue_status_workspace_id_index.up.sql diff --git a/server/migrations/207_issue_status_id_column.down.sql b/server/migrations/208_issue_status_id_column.down.sql similarity index 100% rename from server/migrations/207_issue_status_id_column.down.sql rename to server/migrations/208_issue_status_id_column.down.sql diff --git a/server/migrations/207_issue_status_id_column.up.sql b/server/migrations/208_issue_status_id_column.up.sql similarity index 100% rename from server/migrations/207_issue_status_id_column.up.sql rename to server/migrations/208_issue_status_id_column.up.sql diff --git a/server/migrations/208_issue_status_id_index.down.sql b/server/migrations/209_issue_status_id_index.down.sql similarity index 100% rename from server/migrations/208_issue_status_id_index.down.sql rename to server/migrations/209_issue_status_id_index.down.sql diff --git a/server/migrations/208_issue_status_id_index.up.sql b/server/migrations/209_issue_status_id_index.up.sql similarity index 100% rename from server/migrations/208_issue_status_id_index.up.sql rename to server/migrations/209_issue_status_id_index.up.sql diff --git a/server/migrations/209_agent_task_dispatched_autopilot_run.down.sql b/server/migrations/210_agent_task_dispatched_autopilot_run.down.sql similarity index 100% rename from server/migrations/209_agent_task_dispatched_autopilot_run.down.sql rename to server/migrations/210_agent_task_dispatched_autopilot_run.down.sql diff --git a/server/migrations/209_agent_task_dispatched_autopilot_run.up.sql b/server/migrations/210_agent_task_dispatched_autopilot_run.up.sql similarity index 100% rename from server/migrations/209_agent_task_dispatched_autopilot_run.up.sql rename to server/migrations/210_agent_task_dispatched_autopilot_run.up.sql diff --git a/server/migrations/210_agent_task_dispatched_autopilot_run_index.down.sql b/server/migrations/211_agent_task_dispatched_autopilot_run_index.down.sql similarity index 100% rename from server/migrations/210_agent_task_dispatched_autopilot_run_index.down.sql rename to server/migrations/211_agent_task_dispatched_autopilot_run_index.down.sql diff --git a/server/migrations/210_agent_task_dispatched_autopilot_run_index.up.sql b/server/migrations/211_agent_task_dispatched_autopilot_run_index.up.sql similarity index 100% rename from server/migrations/210_agent_task_dispatched_autopilot_run_index.up.sql rename to server/migrations/211_agent_task_dispatched_autopilot_run_index.up.sql diff --git a/server/migrations/211_agent_task_retry_of_unique.down.sql b/server/migrations/212_agent_task_retry_of_unique.down.sql similarity index 100% rename from server/migrations/211_agent_task_retry_of_unique.down.sql rename to server/migrations/212_agent_task_retry_of_unique.down.sql diff --git a/server/migrations/211_agent_task_retry_of_unique.up.sql b/server/migrations/212_agent_task_retry_of_unique.up.sql similarity index 100% rename from server/migrations/211_agent_task_retry_of_unique.up.sql rename to server/migrations/212_agent_task_retry_of_unique.up.sql From c19e7c7e163a7a42229170d7464bd379308303a2 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Tue, 21 Jul 2026 16:12:44 +0800 Subject: [PATCH 30/41] =?UTF-8?q?feat(issue-status):=20workspace=20status?= =?UTF-8?q?=20catalog=20UI=20=E2=80=94=20settings=20management=20+=20catal?= =?UTF-8?q?og=20plumbing=20(MUL-4809=20=C2=A77.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the custom issue status catalog. The backend shipped the API in this PR already; nothing consumed it. Core: - IssueStatusDefinition / IssueStatusCatalog types plus the STATUS_COLORS and STATUS_ICONS allowlists, mirrored from the server so the pickers can only offer values the API accepts. - Zod schemas + EMPTY fallbacks; the catalog endpoint tolerates a 404 from a backend predating custom statuses by degrading to an empty catalog, so an old server never breaks the issue UI. - api.listIssueStatuses / createIssueStatus / updateIssueStatus / archiveIssueStatus, and a React Query layer (issueStatusKeys + catalog/list options + create/update/archive mutations) modelled on properties/. Every mutation also invalidates the issue caches because issue rows embed status_detail. - STATUS_COLOR_CONFIG + statusThemeForColor/statusTheme: theme classes keyed by the semantic COLOR token instead of the 7 legacy status tokens, which is what lets a custom status render at all. Class strings are spelled out so Tailwind's static scan keeps them. - issue_status:created / issue_status:updated realtime events invalidate the catalog and issue trees, and the catalog joins the reconnect bulk resync. Views: - StatusIcon accepts a catalog `detail` (or explicit icon/color) and resolves shape + color from it, falling back to the legacy token config. All existing call sites keep working unchanged. - New Settings → Statuses tab: statuses grouped by their immutable Category, create/rename/recolor/reorder-free editing, promote-to-default, and archive with a same-Category migration target. System statuses cannot be archived and Category is never an edit control, matching the API's immutability contract. - en / zh-Hans / ja / ko strings (locale parity test enforces all four). Follow-ups still open: catalog-driven status picker and filters, agent-facing catalog injection, Inbox/Squad/Activity status_detail, mobile. Co-authored-by: multica-agent --- packages/core/api/client.ts | 68 +++ packages/core/api/schemas.ts | 64 ++ packages/core/issue-statuses/index.ts | 6 + packages/core/issue-statuses/mutations.ts | 56 ++ packages/core/issue-statuses/queries.ts | 33 + packages/core/issues/config/index.ts | 3 +- packages/core/issues/config/status.ts | 53 +- packages/core/package.json | 3 + .../use-realtime-sync-ws-instance.test.tsx | 8 +- packages/core/realtime/use-realtime-sync.ts | 17 + packages/core/types/events.ts | 17 + packages/core/types/index.ts | 11 + packages/core/types/issue.ts | 82 +++ .../views/issues/components/status-icon.tsx | 34 +- packages/views/locales/en/settings.json | 63 +- packages/views/locales/ja/settings.json | 63 +- packages/views/locales/ko/settings.json | 63 +- packages/views/locales/zh-Hans/settings.json | 63 +- .../settings/components/settings-page.tsx | 8 +- .../settings/components/statuses-tab.tsx | 565 ++++++++++++++++++ 20 files changed, 1264 insertions(+), 16 deletions(-) create mode 100644 packages/core/issue-statuses/index.ts create mode 100644 packages/core/issue-statuses/mutations.ts create mode 100644 packages/core/issue-statuses/queries.ts create mode 100644 packages/views/settings/components/statuses-tab.tsx diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 0a036f6ff5d..198a13f2714 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -90,6 +90,10 @@ import type { IssueProperty, IssuePropertyValue, CreatePropertyRequest, + CreateIssueStatusRequest, + UpdateIssueStatusRequest, + IssueStatusDefinition, + IssueStatusCatalog, UpdatePropertyRequest, ListPropertiesResponse, IssuePropertiesResponse, @@ -250,9 +254,13 @@ import { LabelSchema, ListLabelsResponseSchema, IssuePropertySchema, + IssueStatusDefinitionSchema, + IssueStatusCatalogSchema, ListPropertiesResponseSchema, IssuePropertiesResponseSchema, EMPTY_ISSUE_PROPERTY, + EMPTY_ISSUE_STATUS_DEFINITION, + EMPTY_ISSUE_STATUS_CATALOG, EMPTY_LIST_PROPERTIES_RESPONSE, EMPTY_ISSUE_PROPERTIES_RESPONSE, ResourceLabelsResponseSchema, @@ -2294,6 +2302,66 @@ export class ApiClient { }); } + // ── Issue status catalog (MUL-4809 §5) ──────────────────────────────────── + + /** + * The workspace status catalog plus its alias table. Readable by any member; + * `include_archived` is admin-only and 403s otherwise. + * + * A backend predating custom statuses 404s here (installed desktop clients can + * outrun the server). That degrades to an empty catalog, which every surface + * reads as "not loaded" and falls back to the legacy 7 status tokens — so an + * old server never breaks the issue UI. + */ + async listIssueStatuses(includeArchived = false): Promise { + const suffix = includeArchived ? "?include_archived=true" : ""; + let raw: unknown; + try { + raw = await this.fetch(`/api/issue-statuses${suffix}`); + } catch (error) { + if (error instanceof Error && "status" in error && (error as { status?: number }).status === 404) { + return EMPTY_ISSUE_STATUS_CATALOG; + } + throw error; + } + return parseWithFallback(raw, IssueStatusCatalogSchema, EMPTY_ISSUE_STATUS_CATALOG, { + endpoint: "GET /api/issue-statuses", + }); + } + + async createIssueStatus(data: CreateIssueStatusRequest): Promise { + const raw = await this.fetch(`/api/issue-statuses`, { + method: "POST", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, IssueStatusDefinitionSchema, EMPTY_ISSUE_STATUS_DEFINITION, { + endpoint: "POST /api/issue-statuses", + }); + } + + async updateIssueStatus(id: string, data: UpdateIssueStatusRequest): Promise { + const raw = await this.fetch(`/api/issue-statuses/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, IssueStatusDefinitionSchema, EMPTY_ISSUE_STATUS_DEFINITION, { + endpoint: "PATCH /api/issue-statuses/{id}", + }); + } + + /** + * Archives (soft-deletes) a custom status. When the status still has issues, + * the server requires `migrateToStatusId` — a non-archived status in the SAME + * Category — and moves those issues over in the same transaction. System + * statuses cannot be archived. + */ + async archiveIssueStatus(id: string, migrateToStatusId?: string): Promise { + const suffix = migrateToStatusId + ? `?migrate_to_status_id=${encodeURIComponent(migrateToStatusId)}` + : ""; + await this.fetch(`/api/issue-statuses/${id}${suffix}`, { method: "DELETE" }); + } + async updateProperty(id: string, data: UpdatePropertyRequest): Promise { const raw = await this.fetch(`/api/properties/${id}`, { method: "PATCH", diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index be4d9d037db..3a3e1037084 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -23,6 +23,8 @@ import type { InboxWorkspaceUnread, Label, IssueProperty, + IssueStatusDefinition, + IssueStatusCatalog, ListPropertiesResponse, IssuePropertiesResponse, ListIssuesResponse, @@ -449,6 +451,68 @@ export const StatusDetailSchema = z.object({ color: z.string(), }).loose(); +/** + * A catalog entry (MUL-4809 §5). `category` is the strict 5-value enum for the + * same reason as StatusDetail: it is the only machine semantics and the union + * must stay honest. `icon` / `color` stay lenient strings — the server + * allowlists them, but a future token must not blank the whole row; every + * render path resolves an unknown value to a safe fallback. + */ +export const IssueStatusDefinitionSchema = z.object({ + id: z.string(), + workspace_id: z.string().optional().default(""), + name: z.string(), + description: z.string().optional().default(""), + icon: z.string().optional().default("todo"), + color: z.string().optional().default("muted-foreground"), + category: z.enum(["backlog", "todo", "in_progress", "done", "cancelled"]), + system_key: z.string().nullable().optional().default(null), + is_system: z.boolean().optional().default(false), + is_default: z.boolean().optional().default(false), + position: z.number().optional().default(0), + archived: z.boolean().optional().default(false), + archived_at: z.string().nullable().optional().default(null), + created_at: z.string().optional().default(""), + updated_at: z.string().optional().default(""), +}).loose(); + +export const EMPTY_ISSUE_STATUS_DEFINITION: IssueStatusDefinition = { + id: "", + workspace_id: "", + name: "", + description: "", + icon: "todo", + color: "muted-foreground", + category: "todo", + system_key: null, + is_system: false, + is_default: false, + position: 0, + archived: false, + archived_at: null, + created_at: "", + updated_at: "", +}; + +export const IssueStatusCatalogSchema = z.object({ + statuses: z.array(IssueStatusDefinitionSchema).default([]), + category_defaults: z.record(z.string(), z.string()).default({}), + aliases: z.record(z.string(), z.string()).default({}), + total: z.number().default(0), +}).loose(); + +/** + * Fallback for a malformed catalog response. Empty rather than the 7 built-ins: + * callers treat an empty catalog as "not loaded yet" and keep rendering issues + * off the legacy `status` token, which is always present. + */ +export const EMPTY_ISSUE_STATUS_CATALOG: IssueStatusCatalog = { + statuses: [], + category_defaults: {}, + aliases: {}, + total: 0, +}; + export const IssueSchema = z.object({ id: z.string(), workspace_id: z.string(), diff --git a/packages/core/issue-statuses/index.ts b/packages/core/issue-statuses/index.ts new file mode 100644 index 00000000000..837ff6ad48f --- /dev/null +++ b/packages/core/issue-statuses/index.ts @@ -0,0 +1,6 @@ +export { issueStatusKeys, issueStatusCatalogOptions, issueStatusListOptions } from "./queries"; +export { + useCreateIssueStatus, + useUpdateIssueStatus, + useArchiveIssueStatus, +} from "./mutations"; diff --git a/packages/core/issue-statuses/mutations.ts b/packages/core/issue-statuses/mutations.ts new file mode 100644 index 00000000000..2bba8ae48a0 --- /dev/null +++ b/packages/core/issue-statuses/mutations.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { issueStatusKeys } from "./queries"; +import { useWorkspaceId } from "../hooks"; +import { issueKeys } from "../issues/queries"; +import type { CreateIssueStatusRequest, UpdateIssueStatusRequest } from "../types"; + +/** + * Catalog mutations (MUL-4809 §5). None of these patch optimistically: edits + * happen in the settings tab where a round-trip is acceptable, and the server + * canonicalizes the parts that matter (position, the one-default-per-Category + * invariant, archive-with-migration). + * + * Every mutation invalidates the ISSUE caches too, because issue rows embed + * `status_detail` — a rename/recolor changes how existing rows render, and an + * archive can move issues to another status entirely. + */ +function useCatalogInvalidation() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return () => { + qc.invalidateQueries({ queryKey: issueStatusKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + }; +} + +export function useCreateIssueStatus() { + const invalidate = useCatalogInvalidation(); + return useMutation({ + mutationFn: (data: CreateIssueStatusRequest) => api.createIssueStatus(data), + onSettled: invalidate, + }); +} + +export function useUpdateIssueStatus() { + const invalidate = useCatalogInvalidation(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string } & UpdateIssueStatusRequest) => + api.updateIssueStatus(id, data), + onSettled: invalidate, + }); +} + +/** + * Archive a custom status. `migrateToStatusId` is required by the server when + * the status still has issues; it must name a non-archived status in the SAME + * Category, and the issues move over in the same transaction. + */ +export function useArchiveIssueStatus() { + const invalidate = useCatalogInvalidation(); + return useMutation({ + mutationFn: ({ id, migrateToStatusId }: { id: string; migrateToStatusId?: string }) => + api.archiveIssueStatus(id, migrateToStatusId), + onSettled: invalidate, + }); +} diff --git a/packages/core/issue-statuses/queries.ts b/packages/core/issue-statuses/queries.ts new file mode 100644 index 00000000000..b073dd244cf --- /dev/null +++ b/packages/core/issue-statuses/queries.ts @@ -0,0 +1,33 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; + +export const issueStatusKeys = { + /** PREFIX for invalidation — covers every catalog variant for the workspace. */ + all: (wsId: string) => ["issue-statuses", wsId] as const, + /** FULL KEY */ + catalog: (wsId: string, includeArchived = false) => + [...issueStatusKeys.all(wsId), "catalog", includeArchived] as const, +}; + +/** + * The workspace status catalog (MUL-4809). Server state only — never mirrored + * into Zustand, per the state rules: the catalog is workspace data that changes + * from the settings page and from other clients over websocket. + * + * `includeArchived` is admin-only server-side; leave it false for the pickers + * and filters, which must only ever offer active statuses. + */ +export function issueStatusCatalogOptions(wsId: string, includeArchived = false) { + return queryOptions({ + queryKey: issueStatusKeys.catalog(wsId, includeArchived), + queryFn: () => api.listIssueStatuses(includeArchived), + }); +} + +/** Just the ordered status list — the common case for pickers and filters. */ +export function issueStatusListOptions(wsId: string, includeArchived = false) { + return queryOptions({ + ...issueStatusCatalogOptions(wsId, includeArchived), + select: (data) => data.statuses, + }); +} diff --git a/packages/core/issues/config/index.ts b/packages/core/issues/config/index.ts index 60d97c53f51..d562d21109a 100644 --- a/packages/core/issues/config/index.ts +++ b/packages/core/issues/config/index.ts @@ -1,2 +1,3 @@ -export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status"; +export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG, STATUS_COLOR_CONFIG, statusThemeForColor, statusTheme } from "./status"; +export type { StatusTheme } from "./status"; export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority"; diff --git a/packages/core/issues/config/status.ts b/packages/core/issues/config/status.ts index 185b7980b23..d0e976ba8f9 100644 --- a/packages/core/issues/config/status.ts +++ b/packages/core/issues/config/status.ts @@ -1,4 +1,4 @@ -import type { IssueStatus } from "../../types"; +import type { IssueStatus, StatusColor, StatusDetail } from "../../types"; export const STATUS_ORDER: IssueStatus[] = [ "backlog", @@ -38,3 +38,54 @@ export const STATUS_CONFIG: Record< blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", columnBg: "bg-destructive/5" }, cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, }; + +export interface StatusTheme { + iconColor: string; + hoverBg: string; + dividerColor: string; + columnBg: string; +} + +/** + * Theme classes keyed by the semantic COLOR token a catalog status carries + * (MUL-4809). STATUS_CONFIG above is keyed by the 7 legacy status tokens, which + * cannot express a custom status; this map is the color-driven equivalent and is + * what every custom-status-aware surface should use. + * + * The class strings are written out in full on purpose: Tailwind scans source + * statically, so a template-built class name (`text-${color}`) would be purged. + */ +export const STATUS_COLOR_CONFIG: Record = { + "muted-foreground": { iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, + warning: { iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", columnBg: "bg-warning/5" }, + success: { iconColor: "text-success", hoverBg: "hover:bg-success/10", dividerColor: "bg-success", columnBg: "bg-success/5" }, + info: { iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", columnBg: "bg-info/5" }, + destructive: { iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", columnBg: "bg-destructive/5" }, +}; + +const FALLBACK_STATUS_THEME: StatusTheme = STATUS_COLOR_CONFIG["muted-foreground"]; + +/** + * Theme classes for a color token. An unknown token (a newer server shipping a + * color this client predates) degrades to the neutral theme rather than + * rendering unstyled — the same tolerance the schemas apply. + */ +export function statusThemeForColor(color: string | null | undefined): StatusTheme { + if (!color) return FALLBACK_STATUS_THEME; + return STATUS_COLOR_CONFIG[color as StatusColor] ?? FALLBACK_STATUS_THEME; +} + +/** + * Theme classes for an issue's status. Prefers the resolved catalog entry + * (`status_detail`, which is what a custom status arrives as) and falls back to + * the legacy token config when the issue has no `status_id` yet or the server + * predates the catalog. + */ +export function statusTheme( + statusDetail: StatusDetail | null | undefined, + fallbackStatus?: IssueStatus | string | null, +): StatusTheme { + if (statusDetail?.color) return statusThemeForColor(statusDetail.color); + const legacy = fallbackStatus ? STATUS_CONFIG[fallbackStatus as IssueStatus] : undefined; + return legacy ?? FALLBACK_STATUS_THEME; +} diff --git a/packages/core/package.json b/packages/core/package.json index 4d28e47227f..bb1247945a7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -78,6 +78,9 @@ "./properties": "./properties/index.ts", "./properties/queries": "./properties/queries.ts", "./properties/mutations": "./properties/mutations.ts", + "./issue-statuses": "./issue-statuses/index.ts", + "./issue-statuses/queries": "./issue-statuses/queries.ts", + "./issue-statuses/mutations": "./issue-statuses/mutations.ts", "./autopilots": "./autopilots/index.ts", "./autopilots/queries": "./autopilots/queries.ts", "./autopilots/mutations": "./autopilots/mutations.ts", diff --git a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx index a282a9d2e5e..f07e1395320 100644 --- a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx +++ b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx @@ -108,10 +108,10 @@ describe("useRealtimeSync — ws instance change", () => { rerender({ ws: ws2 }); // Should have called invalidateQueries for all workspace-scoped keys - // (16 workspace-scoped [incl. property definitions] + 6 per-issue - // prefixes + 5 per-chat prefixes + 1 workspaceKeys.list() + 1 - // cross-workspace inbox unread summary = 29 calls) - expect(invalidateSpy).toHaveBeenCalledTimes(29); + // (17 workspace-scoped [incl. property definitions and the issue-status + // catalog] + 6 per-issue prefixes + 5 per-chat prefixes + 1 + // workspaceKeys.list() + 1 cross-workspace inbox unread summary = 30 calls) + expect(invalidateSpy).toHaveBeenCalledTimes(30); }); it("does not re-invalidate when rerendered with the same ws instance", () => { diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 9c2fee09e4d..4ae935b70e5 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -16,6 +16,7 @@ import { autopilotKeys } from "../autopilots/queries"; import { runtimeKeys } from "../runtimes/queries"; import { labelKeys } from "../labels/queries"; import { propertyKeys } from "../properties/queries"; +import { issueStatusKeys } from "../issue-statuses/queries"; import { agentTaskSnapshotKeys, agentActivityKeys, @@ -499,6 +500,7 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void { qc.invalidateQueries({ queryKey: chatKeys.all(wsId) }); qc.invalidateQueries({ queryKey: labelKeys.all(wsId) }); qc.invalidateQueries({ queryKey: propertyKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueStatusKeys.all(wsId) }); } // Cross-workspace, so outside the wsId guard: a reconnect may have missed // inbox events from any workspace, so re-pull the switcher-dot summary. @@ -859,6 +861,20 @@ export function useRealtimeSync( }), ); + // Status-catalog changes (create / rename / recolor / default / archive). + // Issue caches embed status_detail, so a rename or recolor changes how + // existing rows render and an archive can move issues to another status — + // refetch both trees. + const unsubIssueStatusChanged = ["issue_status:created", "issue_status:updated"].map((event) => + ws.on(event as "issue_status:created" | "issue_status:updated", () => { + const wsId = getCurrentWsId(); + if (wsId) { + qc.invalidateQueries({ queryKey: issueStatusKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + } + }), + ); + const unsubInboxNew = ws.on("inbox:new", async (p) => { const { item } = p as InboxNewPayload; if (!item) return; @@ -1370,6 +1386,7 @@ export function useRealtimeSync( unsubIssueMetadataChanged(); unsubIssuePropertiesChanged(); unsubPropertyChanged.forEach((unsub) => unsub()); + unsubIssueStatusChanged.forEach((unsub) => unsub()); unsubInboxNew(); unsubCommentCreated(); unsubCommentUpdated(); diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index 059a4ae90b6..6cbe7ad8ced 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -1,5 +1,6 @@ import type { Issue, IssueMetadata, IssueReaction } from "./issue"; import type { IssueProperty, IssuePropertyValues } from "./property"; +import type { IssueStatusDefinition } from "./issue"; import type { Agent } from "./agent"; import type { InboxItem } from "./inbox"; import type { Comment, Reaction } from "./comment"; @@ -74,6 +75,8 @@ export type WSEventType = | "issue_properties:changed" | "property:created" | "property:updated" + | "issue_status:created" + | "issue_status:updated" | "pin:created" | "pin:deleted" | "pin:reordered" @@ -137,6 +140,18 @@ export interface PropertyChangedPayload { property: IssueProperty; } +/** + * Status-catalog change. `status` carries the full definition on create/update; + * an ARCHIVE arrives as an update carrying only `status_id` + `archived`, since + * statuses are never hard-deleted. Both shapes just invalidate the catalog, so + * every field is optional here. + */ +export interface IssueStatusChangedPayload { + status?: IssueStatusDefinition; + status_id?: string; + archived?: boolean; +} + export interface AgentStatusPayload { agent: Agent; } @@ -469,6 +484,8 @@ export interface WSEventPayloadMap { "issue_properties:changed": IssuePropertiesChangedPayload; "property:created": PropertyChangedPayload; "property:updated": PropertyChangedPayload; + "issue_status:created": IssueStatusChangedPayload; + "issue_status:updated": IssueStatusChangedPayload; "issue_reaction:added": IssueReactionAddedPayload; "issue_reaction:removed": IssueReactionRemovedPayload; "comment:created": CommentCreatedPayload; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 7516c0f714b..8ad149e0195 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -1,4 +1,15 @@ export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueMetadata, IssueMetadataValue, IssueReaction } from "./issue"; +export type { + StatusCategory, + StatusDetail, + StatusColor, + StatusIconKey, + IssueStatusDefinition, + IssueStatusCatalog, + CreateIssueStatusRequest, + UpdateIssueStatusRequest, +} from "./issue"; +export { STATUS_COLORS, STATUS_ICONS } from "./issue"; export type { Agent, AgentStatus, diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts index a1ed2e18c49..d918dd0370b 100644 --- a/packages/core/types/issue.ts +++ b/packages/core/types/issue.ts @@ -32,6 +32,88 @@ export interface StatusDetail { color: string; } +// The semantic color tokens a status may carry. Mirrors the server allowlist +// (validIssueStatusColors) so the settings picker can only offer values the API +// accepts, and so every surface can map a color to theme classes. +export const STATUS_COLORS = [ + "muted-foreground", + "warning", + "success", + "info", + "destructive", +] as const; +export type StatusColor = (typeof STATUS_COLORS)[number]; + +// The icon shapes a status may carry — the built-in status glyphs. Mirrors the +// server allowlist (validIssueStatusIcons). A custom status reuses whichever +// shape best fits its Category; icon is human-facing only. +export const STATUS_ICONS = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", + "cancelled", +] as const; +export type StatusIconKey = (typeof STATUS_ICONS)[number]; + +// IssueStatusDefinition is a catalog entry: the workspace-configurable +// definition behind a status (MUL-4809 §5). `category` and `system_key` are +// immutable after create; the 7 built-ins (is_system) can be renamed/recolored +// but never archived. +export interface IssueStatusDefinition { + id: string; + workspace_id: string; + name: string; + description: string; + icon: string; + color: string; + category: StatusCategory; + system_key: string | null; + is_system: boolean; + is_default: boolean; + position: number; + archived: boolean; + archived_at: string | null; + created_at: string; + updated_at: string; +} + +// IssueStatusCatalog is the workspace's status catalog plus the alias table. +// `category_defaults` maps each Category to its current default status id; +// `aliases` maps every alias token (5 Category + 2 legacy) to the status id it +// resolves to today, so a rename never leaves a caller guessing (§3.2). +export interface IssueStatusCatalog { + statuses: IssueStatusDefinition[]; + category_defaults: Record; + aliases: Record; + total: number; +} + +export interface CreateIssueStatusRequest { + name: string; + /** Immutable after create — pick a new status instead of moving Category. */ + category: StatusCategory; + description?: string; + icon: string; + color: string; + is_default?: boolean; +} + +/** + * Only the mutable fields. `category` / `system_key` are deliberately absent: + * the API rejects them with 400 immutable_field rather than ignoring them. + */ +export interface UpdateIssueStatusRequest { + name?: string; + description?: string; + icon?: string; + color?: string; + position?: number; + is_default?: boolean; +} + export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none"; export type IssueAssigneeType = "member" | "agent" | "squad"; diff --git a/packages/views/issues/components/status-icon.tsx b/packages/views/issues/components/status-icon.tsx index 81d6b8032cc..bb45c1db744 100644 --- a/packages/views/issues/components/status-icon.tsx +++ b/packages/views/issues/components/status-icon.tsx @@ -1,5 +1,5 @@ -import type { IssueStatus } from "@multica/core/types"; -import { STATUS_CONFIG } from "@multica/core/issues/config"; +import type { IssueStatus, StatusDetail } from "@multica/core/types"; +import { STATUS_CONFIG, statusThemeForColor } from "@multica/core/issues/config"; // --------------------------------------------------------------------------- // Geometry constants (viewBox 0 0 14 14, center 7,7) @@ -157,24 +157,48 @@ const STATUS_RENDERERS: Record React.ReactNode> = { // Public component // --------------------------------------------------------------------------- +/** + * Renders a status glyph. + * + * Legacy call sites pass only `status` (one of the 7 built-in tokens) and get the + * shape + color baked into STATUS_CONFIG. Catalog-aware call sites additionally + * pass `detail` (the issue's resolved `status_detail`) or an explicit + * `icon`/`color` pair, which is how a CUSTOM status renders with its configured + * shape and color (MUL-4809). Anything unknown degrades to the neutral Todo + * glyph rather than rendering nothing. + */ export function StatusIcon({ status, className = "h-4 w-4", inheritColor = false, + detail, + icon, + color, }: { status: IssueStatus | string; className?: string; inheritColor?: boolean; + detail?: StatusDetail | null; + icon?: string | null; + color?: string | null; }) { + const shapeKey = icon ?? detail?.icon ?? status; + const knownShape = shapeKey && shapeKey in STATUS_RENDERERS ? (shapeKey as IssueStatus) : null; + const Renderer = knownShape ? STATUS_RENDERERS[knownShape] : TodoIcon; + + const colorToken = color ?? detail?.color ?? null; const knownStatus = status in STATUS_RENDERERS ? (status as IssueStatus) : null; - const cfg = knownStatus ? STATUS_CONFIG[knownStatus] : null; - const Renderer = knownStatus ? STATUS_RENDERERS[knownStatus] : TodoIcon; + const iconColor = colorToken + ? statusThemeForColor(colorToken).iconColor + : knownStatus + ? STATUS_CONFIG[knownStatus].iconColor + : "text-muted-foreground"; return ( diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 2447dce9330..528e3f5c486 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -134,9 +134,70 @@ "labs": "Labs", "members": "Members", "labels": "Labels", - "properties": "Properties" + "properties": "Properties", + "statuses": "Statuses" } }, + "statuses": { + "title": "Issue statuses", + "description": "Customize the statuses in this workspace. Every status belongs to one of five categories — automation reads only the category, while the name, color and icon are for people.", + "loading": "Loading statuses…", + "add": "Add status", + "empty_category": "No statuses in this category yet.", + "categories": { + "backlog": { + "label": "Backlog", + "hint": "Not scheduled. Assigned agents are not started automatically." + }, + "todo": { + "label": "Todo", + "hint": "Queued for work. Assigned agents start from here." + }, + "in_progress": { + "label": "In Progress", + "hint": "Being worked on — review and blocked stages live here too." + }, + "done": { + "label": "Done", + "hint": "Completed." + }, + "cancelled": { + "label": "Cancelled", + "hint": "Dropped without completing." + } + }, + "actions_open": "Actions for {name}", + "badge_default": "Default", + "badge_system": "Built-in", + "edit": "Edit", + "make_default": "Make default for this category", + "default_hint": "Each category has exactly one default. Promoting this one replaces the current default.", + "archive": "Archive", + "create_title": "New status", + "edit_title": "Edit status", + "category_locked": "Category: {category} — fixed after creation. To change it, create a new status and move the issues over.", + "field_name": "Name", + "name_placeholder": "e.g. Needs QA", + "field_description": "Description", + "description_placeholder": "What this status means (optional)", + "field_color": "Color", + "field_icon": "Icon", + "cancel": "Cancel", + "save": "Save", + "create": "Create", + "created": "Status created", + "create_failed": "Couldn't create the status", + "updated": "Status updated", + "update_failed": "Couldn't update the status", + "default_set": "{name} is now the default", + "archived": "Status archived", + "archive_failed": "Couldn't archive the status", + "archive_title": "Archive “{name}”?", + "archive_description": "It stops being available for new issues. Built-in statuses can't be archived.", + "migrate_to": "Move existing issues to", + "migrate_placeholder": "Pick a status", + "migrate_hint": "Required if any issue still uses this status. Only statuses in the same category are allowed." + }, "properties": { "title": "Properties", "description": "Define typed custom properties for issues in this workspace. Values are set on each issue and are readable and writable by agents.", diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index 41c23dee77e..81b29a1484e 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -131,9 +131,70 @@ "labs": "ラボ", "members": "メンバー", "labels": "ラベル", - "properties": "プロパティ" + "properties": "プロパティ", + "statuses": "ステータス" } }, + "statuses": { + "title": "Issue ステータス", + "description": "このワークスペースのステータスをカスタマイズします。各ステータスは 5 つのカテゴリのいずれかに属し、自動化はカテゴリのみを参照します。名前・色・アイコンは人が読むためのものです。", + "loading": "ステータスを読み込み中…", + "add": "ステータスを追加", + "empty_category": "このカテゴリにはまだステータスがありません。", + "categories": { + "backlog": { + "label": "バックログ", + "hint": "未スケジュール。エージェントは自動起動しません。" + }, + "todo": { + "label": "未着手", + "hint": "実行待ち。担当エージェントはここから開始します。" + }, + "in_progress": { + "label": "進行中", + "hint": "作業中。レビュー中・ブロック中もここに含まれます。" + }, + "done": { + "label": "完了", + "hint": "完了しています。" + }, + "cancelled": { + "label": "キャンセル済み", + "hint": "完了せずに終了しました。" + } + }, + "actions_open": "{name} の操作", + "badge_default": "デフォルト", + "badge_system": "組み込み", + "edit": "編集", + "make_default": "このカテゴリのデフォルトにする", + "default_hint": "各カテゴリのデフォルトは 1 つだけです。設定すると現在のデフォルトが置き換わります。", + "archive": "アーカイブ", + "create_title": "新しいステータス", + "edit_title": "ステータスを編集", + "category_locked": "カテゴリ: {category} — 作成後は変更できません。変更するには新しいステータスを作成し、Issue を移動してください。", + "field_name": "名前", + "name_placeholder": "例: QA 待ち", + "field_description": "説明", + "description_placeholder": "このステータスの意味(任意)", + "field_color": "色", + "field_icon": "アイコン", + "cancel": "キャンセル", + "save": "保存", + "create": "作成", + "created": "ステータスを作成しました", + "create_failed": "ステータスを作成できませんでした", + "updated": "ステータスを更新しました", + "update_failed": "ステータスを更新できませんでした", + "default_set": "{name} をデフォルトにしました", + "archived": "ステータスをアーカイブしました", + "archive_failed": "ステータスをアーカイブできませんでした", + "archive_title": "「{name}」をアーカイブしますか?", + "archive_description": "新しい Issue で選択できなくなります。組み込みステータスはアーカイブできません。", + "migrate_to": "既存の Issue の移動先", + "migrate_placeholder": "ステータスを選択", + "migrate_hint": "このステータスを使用中の Issue がある場合は必須です。同じカテゴリのステータスのみ選択できます。" + }, "properties": { "title": "プロパティ", "description": "このワークスペースの Issue に型付きカスタムプロパティを定義します。値は各 Issue に設定され、エージェントが読み書きできます。", diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index 715f2877f09..d85cc1cf76c 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -131,9 +131,70 @@ "labs": "실험실", "members": "멤버", "labels": "레이블", - "properties": "속성" + "properties": "속성", + "statuses": "상태" } }, + "statuses": { + "title": "이슈 상태", + "description": "이 워크스페이스의 상태를 사용자 지정합니다. 각 상태는 다섯 개 카테고리 중 하나에 속하며, 자동화는 카테고리만 읽습니다. 이름·색상·아이콘은 사람을 위한 것입니다.", + "loading": "상태를 불러오는 중…", + "add": "상태 추가", + "empty_category": "이 카테고리에는 아직 상태가 없습니다.", + "categories": { + "backlog": { + "label": "백로그", + "hint": "일정 없음. 지정된 에이전트가 자동으로 시작되지 않습니다." + }, + "todo": { + "label": "할 일", + "hint": "작업 대기. 지정된 에이전트가 여기서 시작합니다." + }, + "in_progress": { + "label": "진행 중", + "hint": "작업 중 — 리뷰 중과 막힘도 여기에 포함됩니다." + }, + "done": { + "label": "완료", + "hint": "완료되었습니다." + }, + "cancelled": { + "label": "취소됨", + "hint": "완료하지 않고 중단했습니다." + } + }, + "actions_open": "{name} 작업", + "badge_default": "기본", + "badge_system": "기본 제공", + "edit": "편집", + "make_default": "이 카테고리의 기본값으로 설정", + "default_hint": "카테고리마다 기본값은 하나뿐입니다. 설정하면 현재 기본값이 대체됩니다.", + "archive": "보관", + "create_title": "새 상태", + "edit_title": "상태 편집", + "category_locked": "카테고리: {category} — 생성 후에는 변경할 수 없습니다. 변경하려면 새 상태를 만들고 이슈를 옮기세요.", + "field_name": "이름", + "name_placeholder": "예: QA 필요", + "field_description": "설명", + "description_placeholder": "이 상태의 의미 (선택)", + "field_color": "색상", + "field_icon": "아이콘", + "cancel": "취소", + "save": "저장", + "create": "생성", + "created": "상태를 만들었습니다", + "create_failed": "상태를 만들지 못했습니다", + "updated": "상태를 업데이트했습니다", + "update_failed": "상태를 업데이트하지 못했습니다", + "default_set": "{name}이(가) 기본값이 되었습니다", + "archived": "상태를 보관했습니다", + "archive_failed": "상태를 보관하지 못했습니다", + "archive_title": "“{name}”을(를) 보관할까요?", + "archive_description": "새 이슈에서 선택할 수 없게 됩니다. 기본 제공 상태는 보관할 수 없습니다.", + "migrate_to": "기존 이슈를 옮길 상태", + "migrate_placeholder": "상태 선택", + "migrate_hint": "이 상태를 사용하는 이슈가 있으면 필수입니다. 같은 카테고리의 상태만 선택할 수 있습니다." + }, "properties": { "title": "속성", "description": "이 워크스페이스의 Issue에 타입이 있는 사용자 지정 속성을 정의합니다. 값은 각 Issue에 설정되며 에이전트가 읽고 쓸 수 있습니다.", diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index e80a7cc1b8d..ddf0ac26bf1 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -134,9 +134,70 @@ "labs": "实验室", "members": "成员", "labels": "标签", - "properties": "属性" + "properties": "属性", + "statuses": "状态" } }, + "statuses": { + "title": "Issue 状态", + "description": "自定义此工作区的状态。每个状态都属于五个分类之一——自动化只读取分类,名称、颜色和图标仅面向人。", + "loading": "正在加载状态…", + "add": "新增状态", + "empty_category": "此分类下还没有状态。", + "categories": { + "backlog": { + "label": "待规划", + "hint": "尚未排期,不会自动启动 Agent。" + }, + "todo": { + "label": "待办", + "hint": "进入待执行范围,指派的 Agent 从这里开始。" + }, + "in_progress": { + "label": "进行中", + "hint": "正在处理,审核中与已阻塞也属于这一类。" + }, + "done": { + "label": "已完成", + "hint": "已经完成。" + }, + "cancelled": { + "label": "已取消", + "hint": "未完成即终止。" + } + }, + "actions_open": "{name} 的操作", + "badge_default": "默认", + "badge_system": "内置", + "edit": "编辑", + "make_default": "设为该分类的默认状态", + "default_hint": "每个分类有且只有一个默认状态,设为默认会替换当前的默认状态。", + "archive": "归档", + "create_title": "新建状态", + "edit_title": "编辑状态", + "category_locked": "分类:{category} —— 创建后不可修改。如需更换,请新建状态并把 Issue 迁移过去。", + "field_name": "名称", + "name_placeholder": "例如:待验收", + "field_description": "描述", + "description_placeholder": "这个状态代表什么(可选)", + "field_color": "颜色", + "field_icon": "图标", + "cancel": "取消", + "save": "保存", + "create": "创建", + "created": "状态已创建", + "create_failed": "创建状态失败", + "updated": "状态已更新", + "update_failed": "更新状态失败", + "default_set": "{name} 已设为默认", + "archived": "状态已归档", + "archive_failed": "归档状态失败", + "archive_title": "归档「{name}」?", + "archive_description": "归档后新的 Issue 将无法再选择该状态。内置状态不能归档。", + "migrate_to": "将现有 Issue 迁移到", + "migrate_placeholder": "选择一个状态", + "migrate_hint": "如果仍有 Issue 使用该状态,则必须选择;只能选择同一分类下的状态。" + }, "properties": { "title": "属性", "description": "为该工作区的 Issue 定义类型化的自定义属性。属性值设置在每个 Issue 上,agent 可读写。", diff --git a/packages/views/settings/components/settings-page.tsx b/packages/views/settings/components/settings-page.tsx index 6a4b7990b1b..0c8e41feb96 100644 --- a/packages/views/settings/components/settings-page.tsx +++ b/packages/views/settings/components/settings-page.tsx @@ -15,6 +15,7 @@ import { Tags, Keyboard, ListTodo, + CircleDashed, } from "lucide-react"; import { GitHubMark } from "./github-mark"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs"; @@ -35,6 +36,7 @@ import { LabsTab } from "./labs-tab"; import { NotificationsTab } from "./notifications-tab"; import { LabelsTab } from "./labels-tab"; import { PropertiesTab } from "./properties-tab"; +import { StatusesTab } from "./statuses-tab"; import { KeyboardShortcutsTab } from "./keyboard-shortcuts-tab"; import { useT } from "../../i18n"; @@ -58,6 +60,7 @@ const WORKSPACE_TAB_KEYS = [ "members", "labels", "properties", + "statuses", ] as const; const WORKSPACE_TAB_VALUES = { general: "workspace", @@ -68,6 +71,7 @@ const WORKSPACE_TAB_VALUES = { members: "members", labels: "labels", properties: "properties", + statuses: "statuses", } as const; const WORKSPACE_TAB_ICONS = { general: Settings, @@ -78,6 +82,7 @@ const WORKSPACE_TAB_ICONS = { members: Users, labels: Tags, properties: SlidersHorizontal, + statuses: CircleDashed, } as const; const DEFAULT_TAB = "profile"; @@ -207,7 +212,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) { {/* Right content */}
-
+
@@ -223,6 +228,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) { + {extraAccountTabs?.map((tab) => ( {tab.content} ))} diff --git a/packages/views/settings/components/statuses-tab.tsx b/packages/views/settings/components/statuses-tab.tsx new file mode 100644 index 00000000000..1e7e81080e0 --- /dev/null +++ b/packages/views/settings/components/statuses-tab.tsx @@ -0,0 +1,565 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Archive, Check, MoreHorizontal, Pencil, Plus } from "lucide-react"; +import { toast } from "sonner"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { + issueStatusCatalogOptions, + useArchiveIssueStatus, + useCreateIssueStatus, + useUpdateIssueStatus, +} from "@multica/core/issue-statuses"; +import { STATUS_COLORS, STATUS_ICONS } from "@multica/core/types"; +import type { IssueStatusDefinition, StatusCategory } from "@multica/core/types"; +import { statusThemeForColor } from "@multica/core/issues/config"; +import { Button } from "@multica/ui/components/ui/button"; +import { Input } from "@multica/ui/components/ui/input"; +import { Textarea } from "@multica/ui/components/ui/textarea"; +import { Label as FieldLabel } from "@multica/ui/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@multica/ui/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@multica/ui/components/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@multica/ui/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@multica/ui/components/ui/select"; +import { cn } from "@multica/ui/lib/utils"; +import { StatusIcon } from "../../issues/components/status-icon"; +import { useT } from "../../i18n"; +import { SettingsTab } from "./settings-layout"; + +/** + * Workspace status catalog management (MUL-4809 §7.1). + * + * Statuses are grouped by their Category, which is the ONLY machine semantics + * and is immutable after create — so Category is a create-time choice presented + * as a group, never an edit control. The 7 built-ins can be renamed/recolored + * but never archived, and each Category always keeps exactly one default. + */ + +// Fixed presentation order; matches the server's Category ordering. +const CATEGORY_ORDER: StatusCategory[] = [ + "backlog", + "todo", + "in_progress", + "done", + "cancelled", +]; + +interface StatusDraft { + name: string; + description: string; + category: StatusCategory; + icon: string; + color: string; + is_default: boolean; +} + +const EMPTY_DRAFT: StatusDraft = { + name: "", + description: "", + category: "todo", + icon: "todo", + color: "muted-foreground", + is_default: false, +}; + +function draftFromStatus(status: IssueStatusDefinition): StatusDraft { + return { + name: status.name, + description: status.description ?? "", + category: status.category, + icon: status.icon, + color: status.color, + is_default: status.is_default, + }; +} + +/** A color swatch row — the allowlist is small and fixed, so no color wheel. */ +function ColorChoices({ + value, + onChange, +}: { + value: string; + onChange: (color: string) => void; +}) { + return ( +
+ {STATUS_COLORS.map((color) => ( + + ))} +
+ ); +} + +/** Icon shape picker — the 7 built-in glyphs. */ +function IconChoices({ + value, + color, + onChange, +}: { + value: string; + color: string; + onChange: (icon: string) => void; +}) { + return ( +
+ {STATUS_ICONS.map((icon) => ( + + ))} +
+ ); +} + +export function StatusesTab() { + const { t } = useT("settings"); + const wsId = useWorkspaceId(); + + const { data: catalog, isLoading } = useQuery(issueStatusCatalogOptions(wsId)); + const statuses = useMemo(() => catalog?.statuses ?? [], [catalog]); + + const [createFor, setCreateFor] = useState(null); + const [editing, setEditing] = useState(null); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [pendingArchive, setPendingArchive] = useState(null); + const [migrateTo, setMigrateTo] = useState(""); + + const createStatus = useCreateIssueStatus(); + const updateStatus = useUpdateIssueStatus(); + const archiveStatus = useArchiveIssueStatus(); + + const byCategory = useMemo(() => { + const grouped = new Map(); + for (const category of CATEGORY_ORDER) grouped.set(category, []); + for (const status of statuses) { + if (status.archived) continue; + grouped.get(status.category)?.push(status); + } + for (const list of grouped.values()) list.sort((a, b) => a.position - b.position); + return grouped; + }, [statuses]); + + /** Same-Category, non-archived, excluding the one being archived. */ + const migrationTargets = useMemo(() => { + if (!pendingArchive) return []; + return statuses.filter( + (s) => !s.archived && s.category === pendingArchive.category && s.id !== pendingArchive.id, + ); + }, [statuses, pendingArchive]); + + const openCreate = (category: StatusCategory) => { + setDraft({ ...EMPTY_DRAFT, category, icon: category === "in_progress" ? "in_progress" : category }); + setCreateFor(category); + }; + + const openEdit = (status: IssueStatusDefinition) => { + setDraft(draftFromStatus(status)); + setEditing(status); + }; + + const closeDialogs = () => { + setCreateFor(null); + setEditing(null); + setDraft(EMPTY_DRAFT); + }; + + const handleCreate = async () => { + const name = draft.name.trim(); + if (!name || !createFor) return; + try { + await createStatus.mutateAsync({ + name, + category: createFor, + description: draft.description.trim(), + icon: draft.icon, + color: draft.color, + is_default: draft.is_default, + }); + toast.success(t(($) => $.statuses.created)); + closeDialogs(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t(($) => $.statuses.create_failed)); + } + }; + + const handleUpdate = async () => { + const name = draft.name.trim(); + if (!name || !editing) return; + try { + await updateStatus.mutateAsync({ + id: editing.id, + name, + description: draft.description.trim(), + icon: draft.icon, + color: draft.color, + // Only ever promote: the server keeps exactly one default per Category, + // so un-setting has no meaning — you promote a different status instead. + ...(draft.is_default && !editing.is_default ? { is_default: true } : {}), + }); + toast.success(t(($) => $.statuses.updated)); + closeDialogs(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t(($) => $.statuses.update_failed)); + } + }; + + const handleSetDefault = async (status: IssueStatusDefinition) => { + try { + await updateStatus.mutateAsync({ id: status.id, is_default: true }); + toast.success(t(($) => $.statuses.default_set, { name: status.name })); + } catch (error) { + toast.error(error instanceof Error ? error.message : t(($) => $.statuses.update_failed)); + } + }; + + const handleArchive = async () => { + if (!pendingArchive) return; + try { + await archiveStatus.mutateAsync({ + id: pendingArchive.id, + migrateToStatusId: migrateTo || undefined, + }); + toast.success(t(($) => $.statuses.archived)); + setPendingArchive(null); + setMigrateTo(""); + } catch (error) { + // The server 409s when the status still holds issues and no migration + // target was given; surface its message so the user knows to pick one. + toast.error(error instanceof Error ? error.message : t(($) => $.statuses.archive_failed)); + } + }; + + const dialogOpen = createFor !== null || editing !== null; + const isSaving = createStatus.isPending || updateStatus.isPending; + + return ( + $.statuses.title)} + description={t(($) => $.statuses.description)} + > + {isLoading ? ( +
+ {t(($) => $.statuses.loading)} +
+ ) : ( +
+ {CATEGORY_ORDER.map((category) => { + const rows = byCategory.get(category) ?? []; + return ( +
+
+
+

+ {t(($) => $.statuses.categories[category].label)} +

+

+ {t(($) => $.statuses.categories[category].hint)} +

+
+ +
+ +
+ {rows.length === 0 ? ( +

+ {t(($) => $.statuses.empty_category)} +

+ ) : ( +
+ {rows.map((status) => ( +
+ +
+
+ {status.name} + {status.is_default ? ( + + {t(($) => $.statuses.badge_default)} + + ) : null} + {status.is_system ? ( + + {t(($) => $.statuses.badge_system)} + + ) : null} +
+ {status.description ? ( +

+ {status.description} +

+ ) : null} +
+ + + $.statuses.actions_open, { name: status.name })} + > + + + } + /> + + openEdit(status)}> + + {t(($) => $.statuses.edit)} + + {!status.is_default ? ( + void handleSetDefault(status)}> + + {t(($) => $.statuses.make_default)} + + ) : null} + {/* System statuses are permanent (§5.4). */} + {!status.is_system ? ( + { + setMigrateTo(""); + setPendingArchive(status); + }} + > + + {t(($) => $.statuses.archive)} + + ) : null} + + +
+ ))} +
+ )} +
+
+ ); + })} +
+ )} + + {/* Create / edit */} + (open ? null : closeDialogs())}> + + + + {editing ? t(($) => $.statuses.edit_title) : t(($) => $.statuses.create_title)} + + + {t(($) => $.statuses.category_locked, { + category: t(($) => $.statuses.categories[(editing?.category ?? createFor ?? "todo")].label), + })} + + + +
+
+ {t(($) => $.statuses.field_name)} + setDraft((d) => ({ ...d, name: event.target.value }))} + placeholder={t(($) => $.statuses.name_placeholder)} + /> +
+ +
+ + {t(($) => $.statuses.field_description)} + +