From 65c1c218dd86911431b54b4f9ffaaeb6839213a0 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 19 Jul 2026 11:46:31 -0400 Subject: [PATCH] fix: reconcile actionable command center decisions (AXI-131) --- DEVLOG.md | 22 ++++ docs/concepts/primitives.md | 12 +- .../(app)/w/[slug]/command-center/page.tsx | 6 - .../command-center-action-requests.test.ts | 63 ++++++++++ src/server/routers/command-center.ts | 41 +------ .../services/__tests__/work-session.test.ts | 108 ++++++++++++++++++ src/server/services/work-session.ts | 95 ++++++++++++++- .../command-center-attention-actions.spec.ts | 31 ++++- 8 files changed, 329 insertions(+), 49 deletions(-) create mode 100644 src/server/routers/__tests__/command-center-action-requests.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 0dc013e2..430882b6 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,28 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-19 — AXI-131 actionable grouped decisions and conflict cleanup + +Corrected the Command Center failure exposed by AXI-129. Multiple open +ActionRequests for one issue are no longer collapsed behind an opaque count; +each decision remains independently readable and actionable. Legacy +`FREE_FORM` recovery requests without a registered reply target now provide a +safe Dismiss action alongside canonical issue navigation instead of leaving the +operator with no way to clear obsolete queue state. + +Extended work-session reconciliation across typed and historical delivery +conflicts. The worker now resolves a conflict after its never-started candidate +run becomes terminal, and session completion or abandonment clears every +conflict belonging to that session. Quiet MCP ownership remains deliberately +fail-safe: it clears only when that exact connection resumes or an explicit +delivery lifecycle transition releases the lease. + +Verification used local Postgres, Redis, MinIO, and the disposable +`forge_e2e` database. Focused real-database suites passed 38/38; lint and +typecheck passed (with only pre-existing lint warnings); the production build +and focused Command Center Playwright scenario passed. The full serial Vitest +suite passed 1,479 tests with 12 intentional skips. + ## 2026-07-18 — AXI-130 typed Attention Queue responses Replaced the Command Center's universal text/decline/status response pattern diff --git a/docs/concepts/primitives.md b/docs/concepts/primitives.md index e8ff6a6c..07033d59 100644 --- a/docs/concepts/primitives.md +++ b/docs/concepts/primitives.md @@ -365,11 +365,17 @@ MCP `actionRequests.list/create/transition`. ### Command Center -Read-only aggregator at `/w/{slug}/command-center` that unions +Operator aggregator at `/w/{slug}/command-center` that unions the operator's action requests, pending review gates, active and stalled agent runs, recent artifacts, issues due in the -next 7 days, and the currently-running timer. Every card links -to the canonical detail page — writes never happen here. +next 7 days, and the currently-running timer. Action requests +remain independently visible even when several target one issue; +each card exposes only server-authorized actions, with safe dismissal +and canonical issue navigation for legacy requests that have no reply +target. Recovery sweeps close delivery-conflict requests once their +blocked candidate run is terminal, while quiet MCP ownership remains +open until the exact connection resumes or the session is explicitly +handed off, completed, or abandoned. Surfaces: tRPC `commandCenter.summary`. diff --git a/src/app/(app)/w/[slug]/command-center/page.tsx b/src/app/(app)/w/[slug]/command-center/page.tsx index 1960349e..1e2f7df6 100644 --- a/src/app/(app)/w/[slug]/command-center/page.tsx +++ b/src/app/(app)/w/[slug]/command-center/page.tsx @@ -550,7 +550,6 @@ type CCActionRequest = { /** ActionRequestKind — FREE_FORM asks need an answer, not just Accept. */ kind: string; payload: unknown; - issueOpenCount?: number; presentation?: AttentionRequestPresentation | null; issue: { id: string; @@ -788,11 +787,6 @@ function ActionRequestDecisionCard({ {request.issue.workspace.key}-{request.issue.number} · {request.issue.title} - {(request.issueOpenCount ?? 1) > 1 ? ( - - {request.issueOpenCount} open decisions - - ) : null} ) : null} {showDecline ? ( diff --git a/src/server/routers/__tests__/command-center-action-requests.test.ts b/src/server/routers/__tests__/command-center-action-requests.test.ts new file mode 100644 index 00000000..5df250b8 --- /dev/null +++ b/src/server/routers/__tests__/command-center-action-requests.test.ts @@ -0,0 +1,63 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { ActionRequestKind, ActionRequestStatus, NotificationSeverity } from "@prisma/client"; +import { commandCenterRouter } from "@/server/routers/command-center"; +import { + buildContext, + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +describe("commandCenterRouter — action requests", () => { + it("returns every decision for an issue and gives reply-less legacy asks a safe dismissal", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "CA" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { title: "Multiple recovery decisions" }); + const rows = await Promise.all( + ["Legacy delivery conflict", "MCP status is unconfirmed"].map((title, index) => + prisma.actionRequest.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + assignedUserId: fixture.user.id, + title, + body: `${title} requires an explicit operator decision.`, + status: ActionRequestStatus.OPEN, + severity: index === 0 ? NotificationSeverity.WARNING : NotificationSeverity.INFO, + kind: ActionRequestKind.FREE_FORM, + sourceType: "work-session", + sourceId: `session-${issue.id}`, + dedupeKey: `fixture-${issue.id}-${index}`, + }, + }), + ), + ); + const caller = commandCenterRouter.createCaller(await buildContext(fixture)); + + const summary = await caller.summary({ dueWindowDays: 7, limit: 20 }); + const returned = summary.actionRequests.filter((request) => request.issueId === issue.id); + + expect(returned.map((request) => request.id).sort()).toEqual(rows.map((row) => row.id).sort()); + expect(summary.counts.actionRequests).toBe(2); + for (const request of returned) { + expect(request.presentation.protocol).toBe("GENERIC_FALLBACK"); + expect(request.presentation.actions.map((action) => action.id)).toEqual([ + "DISMISS", + "OPEN_ISSUE", + ]); + } + }); +}); diff --git a/src/server/routers/command-center.ts b/src/server/routers/command-center.ts index 4f6cc3a6..eb4538a4 100644 --- a/src/server/routers/command-center.ts +++ b/src/server/routers/command-center.ts @@ -28,32 +28,6 @@ function decisionAskWhere(workspaceId: string, userId: string) { }; } -/** - * Collapse rows to one per issue (keeping the first — i.e. the highest-priority - * by the caller's orderBy), tagging each survivor with `issueOpenCount` so the - * UI can show "+N more on this issue" instead of stacking near-duplicate cards. - * Null-issue (workspace-level) rows pass through individually. Applied to action - * requests only — runs are intentionally NOT grouped (distinct agents' concurrent - * runs on one issue are real, and same-agent stalled repeats already collapse via - * AgentRun.supersededByRunId). - */ -function collapsePerIssue( - rows: T[], -): (T & { issueOpenCount: number })[] { - const counts = new Map(); - for (const r of rows) if (r.issueId) counts.set(r.issueId, (counts.get(r.issueId) ?? 0) + 1); - const seen = new Set(); - const out: (T & { issueOpenCount: number })[] = []; - for (const r of rows) { - if (r.issueId) { - if (seen.has(r.issueId)) continue; - seen.add(r.issueId); - } - out.push({ ...r, issueOpenCount: r.issueId ? (counts.get(r.issueId) ?? 1) : 1 }); - } - return out; -} - type AttentionAction = { id: string; label: string; @@ -287,11 +261,12 @@ async function actionRequestPresentation( enabled: true, disabledReason: null, }, + dismiss, ...(openIssue ? [openIssue] : []), ] : openIssue - ? [openIssue] - : []; + ? [dismiss, openIssue] + : [dismiss]; return { ...base, category: "INFORMATION_REQUIRED", @@ -377,10 +352,7 @@ export const commandCenterRouter = router({ ? ctx.db.actionRequest.findMany({ where: decisionAskWhere(ctx.workspaceId, userId), orderBy: [{ severity: "desc" }, { createdAt: "desc" }], - // Over-fetch so the per-issue collapse + issueOpenCount below run - // over the full open set, not a pre-truncated page; sliced to - // input.limit after collapsing. - take: 200, + take: input.limit, include: { requestedByAgent: { select: { id: true, name: true, profileKey: true, avatar: true }, @@ -547,11 +519,8 @@ export const commandCenterRouter = router({ const runtimeApprovals = activeRuns.filter((run) => run.awaitingApprovalAt != null); const visibleActiveRuns = activeRuns.filter((run) => run.awaitingApprovalAt == null); - // Collapse near-duplicate action-request cards to one per issue, then - // slice to the requested limit (collapse runs over the full open set). - const groupedActionRequests = collapsePerIssue(actionRequests).slice(0, input.limit); const presentedActionRequests = await Promise.all( - groupedActionRequests.map(async (request) => ({ + actionRequests.map(async (request) => ({ ...request, presentation: await actionRequestPresentation( ctx.db, diff --git a/src/server/services/__tests__/work-session.test.ts b/src/server/services/__tests__/work-session.test.ts index ae08080a..d4456778 100644 --- a/src/server/services/__tests__/work-session.test.ts +++ b/src/server/services/__tests__/work-session.test.ts @@ -865,4 +865,112 @@ describe("work session coordination", () => { }), ).resolves.toMatchObject({ status: "RESOLVED", resolution: "Work session resumed." }); }); + + it("resolves legacy delivery conflicts after their candidate is terminal or session ends", async () => { + const { fixture, prisma, issue } = await setup(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `legacy-conflict-${Date.now()}`, + name: "Legacy conflict agent", + }, + }); + const [ownerConnection, candidateConnection] = await Promise.all([ + prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + instanceKey: `legacy-owner-${Date.now()}`, + }, + }), + prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MANAGED_RUNTIME", + livenessModel: "HEARTBEAT", + instanceKey: `legacy-candidate-${Date.now()}`, + }, + }), + ]); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/legacy-conflict", + source: WorkSessionSource.FORGE_AGENT, + actor: { + userId: fixture.user.id, + agentId: agent.id, + connectionId: ownerConnection.id, + }, + }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + connectionId: candidateConnection.id, + status: "WAITING", + currentStep: "blocked by another primary delivery connection", + }, + }); + const request = await prisma.actionRequest.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + assignedUserId: fixture.user.id, + title: "Issue already has a primary delivery connection", + status: "OPEN", + severity: "WARNING", + kind: "FREE_FORM", + sourceType: "work-session", + sourceId: session.id, + dedupeKey: `work-session-execution-conflict:${session.id}:${candidateConnection.id}`, + }, + }); + + await sweepStaleWorkSessions(prisma); + await expect( + prisma.actionRequest.findUniqueOrThrow({ where: { id: request.id } }), + ).resolves.toMatchObject({ status: "OPEN" }); + + await prisma.agentRun.update({ + where: { id: run.id }, + data: { status: "STALLED", finishedAt: new Date() }, + }); + await sweepStaleWorkSessions(prisma); + await expect( + prisma.actionRequest.findUniqueOrThrow({ where: { id: request.id } }), + ).resolves.toMatchObject({ + status: "RESOLVED", + resolution: "Blocked delivery attempt is no longer active.", + }); + + const endedSessionRequest = await prisma.actionRequest.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + assignedUserId: fixture.user.id, + title: "A later delivery conflict", + status: "OPEN", + severity: "WARNING", + kind: "FREE_FORM", + sourceType: "work-session", + sourceId: session.id, + dedupeKey: `work-session-execution-conflict:${session.id}:replacement-candidate`, + }, + }); + await advanceWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + actor: { userId: fixture.user.id }, + status: "ABANDONED", + }); + await expect( + prisma.actionRequest.findUniqueOrThrow({ where: { id: endedSessionRequest.id } }), + ).resolves.toMatchObject({ status: "RESOLVED", resolution: "Work session abandoned." }); + }); }); diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts index 3ccf3dd3..5974f73e 100644 --- a/src/server/services/work-session.ts +++ b/src/server/services/work-session.ts @@ -3,6 +3,7 @@ import "server-only"; import { ActionRequestKind, ActionRequestStatus, + AgentRunStatus, CommentKind, DeliveryTimelinePolicy, EventKind, @@ -65,9 +66,14 @@ async function resolveRecoveryRequests( await db.actionRequest.updateMany({ where: { workspaceId, - dedupeKey: { - in: [`work-session-stale:${sessionId}`, `work-session-mcp-quiet:${sessionId}`], - }, + OR: [ + { + dedupeKey: { + in: [`work-session-stale:${sessionId}`, `work-session-mcp-quiet:${sessionId}`], + }, + }, + { dedupeKey: { startsWith: `work-session-execution-conflict:${sessionId}:` } }, + ], status: ActionRequestStatus.OPEN, }, data: { @@ -78,6 +84,88 @@ async function resolveRecoveryRequests( }); } +/** + * Repair delivery-conflict requests whose blocked candidate is no longer + * waiting. Typed requests carry the run id; legacy FREE_FORM rows encode only + * the session and candidate connection in their dedupe key. In either case we + * resolve only when there is no still-actionable, never-started candidate run. + */ +async function resolveTerminalDeliveryConflictRequests(db: PrismaClient) { + const requests = await db.actionRequest.findMany({ + where: { + status: ActionRequestStatus.OPEN, + sourceType: "work-session", + OR: [ + { kind: ActionRequestKind.DELIVERY_CONNECTION_CONFLICT }, + { + kind: ActionRequestKind.FREE_FORM, + dedupeKey: { startsWith: "work-session-execution-conflict:" }, + }, + ], + }, + select: { + id: true, + workspaceId: true, + issueId: true, + kind: true, + payload: true, + sourceId: true, + dedupeKey: true, + }, + }); + + for (const request of requests) { + const raw = record(request.payload); + const typed = request.kind === ActionRequestKind.DELIVERY_CONNECTION_CONFLICT; + const queuedRunId = typed && typeof raw.queuedRunId === "string" ? raw.queuedRunId : null; + const candidateConnectionId = typed + ? typeof raw.candidateConnectionId === "string" + ? raw.candidateConnectionId + : null + : request.sourceId && + request.dedupeKey?.startsWith(`work-session-execution-conflict:${request.sourceId}:`) + ? request.dedupeKey.slice(`work-session-execution-conflict:${request.sourceId}:`.length) + : null; + + const [session, candidate] = await Promise.all([ + request.sourceId + ? db.workSession.findFirst({ + where: { id: request.sourceId, workspaceId: request.workspaceId }, + select: { endedAt: true }, + }) + : null, + request.issueId && (queuedRunId || candidateConnectionId) + ? db.agentRun.findFirst({ + where: { + workspaceId: request.workspaceId, + issueId: request.issueId, + ...(queuedRunId ? { id: queuedRunId } : { connectionId: candidateConnectionId! }), + externalRunId: null, + status: { in: [AgentRunStatus.WAITING, AgentRunStatus.ACTIVE] }, + }, + select: { id: true }, + }) + : null, + ]); + + if (session && !session.endedAt && candidate) continue; + await db.actionRequest.updateMany({ + where: { + id: request.id, + workspaceId: request.workspaceId, + status: ActionRequestStatus.OPEN, + }, + data: { + status: ActionRequestStatus.RESOLVED, + resolvedAt: new Date(), + resolution: session?.endedAt + ? "Delivery session ended." + : "Blocked delivery attempt is no longer active.", + }, + }); + } +} + /** * Any authenticated signal from an MCP connection renews its observation * lease. Clear delivery recovery asks for sessions that connection still owns, @@ -867,6 +955,7 @@ export async function advanceWorkSession( } export async function sweepStaleWorkSessions(db: PrismaClient): Promise { + await resolveTerminalDeliveryConflictRequests(db); // Repair requests created before merge-time cleanup was introduced. Query // from the small open-request set rather than scanning delivery history on // every worker tick. diff --git a/tests/e2e/command-center-attention-actions.spec.ts b/tests/e2e/command-center-attention-actions.spec.ts index 07854dc4..02f187c9 100644 --- a/tests/e2e/command-center-attention-actions.spec.ts +++ b/tests/e2e/command-center-attention-actions.spec.ts @@ -23,6 +23,7 @@ const FIXTURE = { candidateRunId: "e2e-axi130-candidate-run", actionableRequestId: "e2e-axi130-actionable-request", fallbackRequestId: "e2e-axi130-fallback-request", + secondFallbackRequestId: "e2e-axi131-second-fallback-request", } as const; const ACTIONABLE_TITLE = "Choose how the queued delivery connection should join"; @@ -51,7 +52,15 @@ const prisma = new PrismaClient({ async function clearFixtures() { await prisma.actionRequest.deleteMany({ - where: { id: { in: [FIXTURE.actionableRequestId, FIXTURE.fallbackRequestId] } }, + where: { + id: { + in: [ + FIXTURE.actionableRequestId, + FIXTURE.fallbackRequestId, + FIXTURE.secondFallbackRequestId, + ], + }, + }, }); await prisma.agentRun.deleteMany({ where: { id: FIXTURE.candidateRunId } }); await prisma.workSessionParticipant.deleteMany({ @@ -207,6 +216,19 @@ test.describe("Command Center attention actions", () => { sourceType: "e2e-fixture", sourceId: FIXTURE.fallbackRequestId, }, + { + id: FIXTURE.secondFallbackRequestId, + workspaceId: workspace.id, + issueId: FIXTURE.fallbackIssueId, + title: "A second decision for the same issue", + body: "This decision must remain independently visible and dismissible.", + status: ActionRequestStatus.OPEN, + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + assignedUserId: owner.id, + sourceType: "e2e-fixture", + sourceId: FIXTURE.secondFallbackRequestId, + }, ], }); }); @@ -252,8 +274,15 @@ test.describe("Command Center attention actions", () => { .getByRole("link", { name: FALLBACK_TITLE }) .locator("xpath=ancestor::div[contains(@class, 'bg-card/40')][1]"); await expect(fallbackCard.getByRole("link", { name: "Open issue" })).toBeVisible(); + await expect(fallbackCard.getByRole("button", { name: "Dismiss" })).toBeVisible(); await expect(fallbackCard.getByRole("button", { name: "Respond" })).toHaveCount(0); await expect(fallbackCard.getByRole("button", { name: "Accept" })).toHaveCount(0); await expect(fallbackCard.getByRole("button", { name: "Decline" })).toHaveCount(0); + + const secondFallbackCard = page + .getByRole("link", { name: "A second decision for the same issue" }) + .locator("xpath=ancestor::div[contains(@class, 'bg-card/40')][1]"); + await expect(secondFallbackCard).toBeVisible(); + await expect(secondFallbackCard.getByRole("button", { name: "Dismiss" })).toBeVisible(); }); });