Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions docs/concepts/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
6 changes: 0 additions & 6 deletions src/app/(app)/w/[slug]/command-center/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -788,11 +787,6 @@ function ActionRequestDecisionCard({
<span className="break-words">
{request.issue.workspace.key}-{request.issue.number} · {request.issue.title}
</span>
{(request.issueOpenCount ?? 1) > 1 ? (
<span className="rounded bg-subtle px-1.5 py-0.5 font-mono text-[0.625rem]">
{request.issueOpenCount} open decisions
</span>
) : null}
</div>
) : null}
{showDecline ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
]);
}
});
});
41 changes: 5 additions & 36 deletions src/server/routers/command-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { issueId: string | null }>(
rows: T[],
): (T & { issueOpenCount: number })[] {
const counts = new Map<string, number>();
for (const r of rows) if (r.issueId) counts.set(r.issueId, (counts.get(r.issueId) ?? 0) + 1);
const seen = new Set<string>();
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;
Expand Down Expand Up @@ -287,11 +261,12 @@ async function actionRequestPresentation(
enabled: true,
disabledReason: null,
},
dismiss,
...(openIssue ? [openIssue] : []),
]
: openIssue
? [openIssue]
: [];
? [dismiss, openIssue]
: [dismiss];
return {
...base,
category: "INFORMATION_REQUIRED",
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down
108 changes: 108 additions & 0 deletions src/server/services/__tests__/work-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." });
});
});
Loading
Loading