Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0c8479a
feat(issue-status): Phase 1 schema + built-in status seeding (MUL-4809)
Jul 16, 2026
aecfeaa
feat(issue-status): alias resolver for Category/legacy/name inputs (M…
Jul 16, 2026
68d3ff6
feat(issue-status): double-write status_id on issue write paths (MUL-…
Jul 16, 2026
4fabe59
feat(issue-status): admin CRUD API for the status catalog (MUL-4809)
Jul 16, 2026
4851848
fix(issue-status): close status-API review blockers — locks, presence…
Jul 16, 2026
111417a
fix(issue-status): renumber migrations 200-206 -> 202-208 (MUL-4809)
Jul 16, 2026
3b5f609
fix(issue-status): take workspace FOR KEY SHARE first to avoid create…
Jul 16, 2026
bad98ff
feat(issue-status): read side — status_id/status_detail + Category fi…
Jul 16, 2026
2f9d646
feat(issue-status): client parses optional status_id/status_detail (M…
Jul 16, 2026
6e20dba
Merge remote-tracking branch 'origin/main' into agent/j/3d71633c
Jul 16, 2026
42d90a6
fix(issue-status): read-side P1s — open_only filters + search/childre…
Jul 16, 2026
6abc803
fix(issue-status): read-side P1s — safe-degrade detail + honest categ…
Jul 16, 2026
7b77c9c
feat(autopilot): drive create_issue runs off task outcome, not issue …
Jul 17, 2026
845efb9
fix(autopilot): compare-and-set run state transitions (MUL-4809 §4.1 …
Jul 17, 2026
44d7850
fix(autopilot): harden CAS bind — exclusive/idempotent + authoritativ…
Jul 17, 2026
ea2511a
fix(autopilot): bind must check task ownership on terminal CAS miss (…
Jul 17, 2026
af489ea
fix(autopilot): precise crash-window bind repair for unbound create_i…
Jul 20, 2026
4fbee57
feat(autopilot): traceable cancelled reason + §9.2 acceptance tests (…
Jul 20, 2026
4b75d1c
refactor(issue-status): task_failed dismiss keys off terminal Categor…
Jul 20, 2026
bd3eb93
Merge origin/main into agent/j/acf19200 (resolve status catalog vs mu…
Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@ export class ApiClient {
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.q?.trim()) search.set("q", params.q.trim());
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?.statuses?.length) search.set("statuses", params.statuses.join(","));
if (params?.priority) search.set("priority", params.priority);
if (params?.priorities?.length) search.set("priorities", params.priorities.join(","));
Expand Down Expand Up @@ -627,6 +629,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);
Expand Down
116 changes: 116 additions & 0 deletions packages/core/api/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,122 @@ 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 a well-formed status_id / status_detail through", async () => {
stubFetchJson({
issues: [
{
...baseIssue,
status_id: "st-1",
status_detail: {
id: "st-1",
name: "In Review",
category: "in_progress",
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("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 null on explicit-null status_id / status_detail", 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_id).toBeNull();
expect(res.issues[0]?.status_detail).toBeNull();
});
});

describe("createIssue", () => {
// The create modal decides whether to run its label-attach fallback by
// reading `labels` off the parsed response, and treats a rejection as a
Expand Down
22 changes: 22 additions & 0 deletions packages/core/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,20 @@ 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). 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.enum(["backlog", "todo", "in_progress", "done", "cancelled"]),
icon: z.string(),
color: z.string(),
}).loose();

export const IssueSchema = z.object({
id: z.string(),
workspace_id: z.string(),
Expand Down Expand Up @@ -462,6 +476,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). 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();
Expand Down
10 changes: 9 additions & 1 deletion packages/core/types/api.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -83,6 +83,10 @@ export interface ListIssuesParams {
/** Flat-table quick search. Matches issue title words or an exact issue number. */
q?: 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;
/** Multi-value table facet. OR within the field. */
statuses?: IssueStatus[];
priority?: IssuePriority;
Expand Down Expand Up @@ -163,6 +167,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;
Expand Down
28 changes: 28 additions & 0 deletions packages/core/types/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions server/cmd/server/autopilot_dispatch_for_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 7 additions & 36 deletions server/cmd/server/autopilot_listeners.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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)
}
Loading
Loading