From 6f05bbac9ef16d75095bce60e45476d96750eb44 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 27 Jul 2026 20:23:08 -0400 Subject: [PATCH] feat: make delivery attention requests actionable --- DEVLOG.md | 20 +++ docs/engineering/work-management.md | 18 ++ .../migration.sql | 2 + prisma/schema.prisma | 54 +++--- .../(app)/w/[slug]/command-center/page.tsx | 44 ++++- .../attention-request-model.ts | 24 +++ src/components/issue-detail/issue-main.tsx | 142 +++++++++++++++- .../command-center-action-requests.test.ts | 114 ++++++++++++- src/server/routers/action-request.ts | 43 ++++- src/server/routers/command-center.ts | 143 +++++++++++++++- src/server/services/action-request-service.ts | 158 ++++++++++++++++++ src/server/services/work-session.ts | 2 + .../command-center-attention-actions.spec.ts | 39 ++++- tests/unit/attention-request-model.test.ts | 19 +++ 14 files changed, 778 insertions(+), 44 deletions(-) create mode 100644 prisma/migrations/20260727234500_work_session_operator_confirmation/migration.sql diff --git a/DEVLOG.md b/DEVLOG.md index 776aea3f..95046fc3 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,25 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-27 — Actionable Delivery recovery requests + +- Replaced the dead-end Dismiss/Open Issue treatment for quiet or stale + Delivery sessions with server-derived **Still working**, **Resume session**, + and **Abandon session** actions in both Command Center and issue detail. +- Added **Respond in issue** as the safe fallback for generic asks that have no + registered reply transport. The issue opens with a prepared comment while + preserving the operator's ability to edit before sending. +- Renamed the destructive-looking generic dismissal to **Dismiss card** and + made its scope explicit: it clears the card only and does not resolve the + underlying Delivery condition. +- Stored operator confirmation independently from lifecycle heartbeats. A + confirmation prevents immediate alert recreation without fabricating fresh + MCP or runtime evidence. +- Added schema, service, router, presentation, issue-local UI, unit/integration, + and focused Playwright coverage. The full local quality gate passed with + 1,523 tests passed and one skipped; the focused Chromium scenario also + passed against the isolated local service stack. + ## 2026-07-25 — v0.30.0 release preparation Prepared the serialized minor release after implementation PRs #80, #89, and @@ -118,6 +137,7 @@ with an explicit, keyboard-accessible Show more / Show fewer affordance. Asks no longer expands without limit, and Run recovery no longer hides additional cards in a clipped nested scroll area. Responsive Playwright coverage now guards both the dashboard rail fill and the attention overflow contract. + ## 2026-07-20 — AXI-138 faster and safer local iteration Scoped Turbopack to the Forge repository root so unrelated parent lockfiles no diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md index 40b4e887..c4d425bb 100644 --- a/docs/engineering/work-management.md +++ b/docs/engineering/work-management.md @@ -142,6 +142,24 @@ Claimed → In progress → PR open → In review → Ready to merge → Merged ownership lease. A stale session remains a lease and raises a shared action request; it must be resumed or abandoned before replacement work starts. +Delivery recovery requests expose lifecycle-aware operator actions wherever +the request appears: + +- **Still working** confirms that a quiet MCP-owned lease should remain + reserved. +- **Resume session** returns a stale lease to in progress. +- **Abandon session** explicitly releases ownership. +- **Respond in issue** opens the issue with a prepared comment when the request + needs human context instead of a structured transition. +- **Dismiss card** removes only the current Attention Queue card. It does not + change Delivery ownership or claim that the underlying condition was fixed. + +Operator confirmation is stored separately from the connection heartbeat. It +suppresses repeated recovery prompts for the normal stale interval without +claiming that MCP, a runtime, or any other client emitted fresh lifecycle +evidence. The owning connection must still heartbeat, complete, hand off, or +abandon the session explicitly. + For direct MCP work, clients open an explicit run before implementation. Opening an `EXECUTE` run moves a Backlog or Todo issue to the workspace's configured In Progress status; successfully completing it moves the issue to diff --git a/prisma/migrations/20260727234500_work_session_operator_confirmation/migration.sql b/prisma/migrations/20260727234500_work_session_operator_confirmation/migration.sql new file mode 100644 index 00000000..cad480c4 --- /dev/null +++ b/prisma/migrations/20260727234500_work_session_operator_confirmation/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "WorkSession" +ADD COLUMN "operatorConfirmedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c63ac59b..d8f3e71e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2719,34 +2719,38 @@ model AgentConnection { /// branch for the same issue. Terminal rows remain as the issue's delivery /// history; a partial unique index in the migration permits one active row. model WorkSession { - id String @id @default(cuid()) - workspaceId String - issueId String - ownerUserId String? - ownerAgentId String? + id String @id @default(cuid()) + workspaceId String + issueId String + ownerUserId String? + ownerAgentId String? /// Concrete endpoint that owns execution/heartbeat authority. The logical /// owner remains ownerAgentId; nullable preserves legacy/manual sessions. - ownerConnectionId String? - source WorkSessionSource - status WorkSessionStatus @default(CLAIMED) - repoFullName String - branch String - baseBranch String @default("main") + ownerConnectionId String? + source WorkSessionSource + status WorkSessionStatus @default(CLAIMED) + repoFullName String + branch String + baseBranch String @default("main") /// Local path is diagnostic only; it is never used as an execution target. - worktreePath String? - headSha String? - pullRequestId String? - releasedVersion String? - deployedSha String? - lastHeartbeatAt DateTime @default(now()) - staleAt DateTime? - mergedAt DateTime? - releasedAt DateTime? - deployedAt DateTime? - verifiedAt DateTime? - endedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + worktreePath String? + headSha String? + pullRequestId String? + releasedVersion String? + deployedSha String? + lastHeartbeatAt DateTime @default(now()) + /// Human confirmation that the delivery lease should remain reserved. + /// Kept separate from lifecycle heartbeats so operator intent never + /// fabricates MCP or runtime evidence. + operatorConfirmedAt DateTime? + staleAt DateTime? + mergedAt DateTime? + releasedAt DateTime? + deployedAt DateTime? + verifiedAt DateTime? + endedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) diff --git a/src/app/(app)/w/[slug]/command-center/page.tsx b/src/app/(app)/w/[slug]/command-center/page.tsx index eb1aa361..5ff1c297 100644 --- a/src/app/(app)/w/[slug]/command-center/page.tsx +++ b/src/app/(app)/w/[slug]/command-center/page.tsx @@ -36,10 +36,12 @@ import { RunApprovalCard } from "@/components/agents/run-approval-card"; import { ActionRequestCard } from "@/components/action-requests/action-request-card"; import { isDeliveryConflictDecision, + isWorkSessionAttentionDecision, safeAttentionActions, type AttentionAction, type AttentionRequestPresentation, } from "@/components/action-requests/attention-request-model"; +import { readDraft, saveDraft } from "@/components/ui/modal/draft"; import { MarkdownWithAttachments } from "@/components/markdown/attachment-renderer"; import { WorkspaceActivityTimeline } from "@/components/workspace-activity-timeline"; import { IssueReference } from "@/components/issue-reference"; @@ -649,6 +651,7 @@ function ActionRequestDecisionCard({ const presentation = request.presentation ?? null; const replyTarget = presentation?.replyTarget ?? null; const isDeliveryConflict = presentation?.protocol === "DELIVERY_CONNECTION_CONFLICT"; + const isWorkSessionRecovery = presentation?.protocol === "WORK_SESSION_RECOVERY"; const issueHref = request.issue ? `/w/${request.issue.workspace.slug || slug}/i/${request.issue.workspace.key}-${request.issue.number}` : null; @@ -698,9 +701,29 @@ function ActionRequestDecisionCard({ onSuccess: () => toast.success("Delivery decision applied."), onSettled: settle, }); + const resolveWorkSessionAttention = trpc.actionRequest.resolveWorkSessionAttention.useMutation({ + onMutate: () => onResolved(), + onError: (e) => { + toast.error(e.message); + void utils.commandCenter.summary.invalidate(); + }, + onSuccess: (result) => + toast.success( + result.decision === "ABANDON_SESSION" + ? "Delivery session abandoned." + : result.decision === "RESUME_SESSION" + ? "Delivery session resumed." + : "Operator confirmation recorded. MCP evidence remains unchanged.", + ), + onSettled: settle, + }); const pending = - accept.isPending || decline.isPending || dismiss.isPending || resolveDeliveryConflict.isPending; + accept.isPending || + decline.isPending || + dismiss.isPending || + resolveDeliveryConflict.isPending || + resolveWorkSessionAttention.isPending; const runAction = async (action: AttentionAction) => { if (!action.enabled) return; @@ -720,6 +743,25 @@ function ActionRequestDecisionCard({ return; } + if (action.id === "RESPOND_IN_ISSUE") { + if (!issueHref || !request.issue) return; + const draftKey = `comment:${request.issue.id}`; + const existing = readDraft(draftKey); + const context = `Regarding "${request.title}":\n\n`; + if (!existing?.trim()) saveDraft(draftKey, context); + window.location.assign(`${issueHref}#issue-comments-heading`); + return; + } + + if (isWorkSessionRecovery) { + if (!isWorkSessionAttentionDecision(action.id)) { + toast.error("This Delivery action is not supported. Open the issue to continue safely."); + return; + } + resolveWorkSessionAttention.mutate({ id: request.id, decision: action.id }); + return; + } + if (isDeliveryConflict) { if (!isDeliveryConflictDecision(action.id)) { toast.error("This delivery action is not supported. Open the issue to continue safely."); diff --git a/src/components/action-requests/attention-request-model.ts b/src/components/action-requests/attention-request-model.ts index 397a09d4..ec17a7bb 100644 --- a/src/components/action-requests/attention-request-model.ts +++ b/src/components/action-requests/attention-request-model.ts @@ -29,11 +29,20 @@ export const DELIVERY_CONFLICT_DECISIONS = [ export type DeliveryConflictDecision = (typeof DELIVERY_CONFLICT_DECISIONS)[number]; +export const WORK_SESSION_ATTENTION_DECISIONS = [ + "CONFIRM_ACTIVE", + "RESUME_SESSION", + "ABANDON_SESSION", +] as const; + +export type WorkSessionAttentionDecision = (typeof WORK_SESSION_ATTENTION_DECISIONS)[number]; + const GENERIC_ATTENTION_ACTIONS = [ "ACCEPT", "DECLINE", "DISMISS", "RESPOND", + "RESPOND_IN_ISSUE", "OPEN_ISSUE", ] as const; @@ -41,6 +50,12 @@ export function isDeliveryConflictDecision(value: string): value is DeliveryConf return DELIVERY_CONFLICT_DECISIONS.some((decision) => decision === value); } +export function isWorkSessionAttentionDecision( + value: string, +): value is WorkSessionAttentionDecision { + return WORK_SESSION_ATTENTION_DECISIONS.some((decision) => decision === value); +} + /** * The server owns labels and availability, but the browser still keeps a * narrow execution allowlist. Unknown descriptors remain visible through the @@ -58,6 +73,15 @@ export function safeAttentionActions( action.id === "DISMISS", ); } + if (presentation.protocol === "WORK_SESSION_RECOVERY") { + return presentation.actions.filter( + (action) => + isWorkSessionAttentionDecision(action.id) || + action.id === "RESPOND_IN_ISSUE" || + action.id === "OPEN_ISSUE" || + action.id === "DISMISS", + ); + } return presentation.actions.filter((action) => GENERIC_ATTENTION_ACTIONS.some((allowed) => allowed === action.id), ); diff --git a/src/components/issue-detail/issue-main.tsx b/src/components/issue-detail/issue-main.tsx index b6591dc5..b7495f89 100644 --- a/src/components/issue-detail/issue-main.tsx +++ b/src/components/issue-detail/issue-main.tsx @@ -29,6 +29,11 @@ import { } from "@/components/issue-detail/plan-step-context-card"; import { SubIssuesPanel, ParentIssueBacklink } from "@/components/issue-detail/sub-issues-panel"; import { ActionRequestCard } from "@/components/action-requests/action-request-card"; +import { + isWorkSessionAttentionDecision, + safeAttentionActions, + type AttentionRequestPresentation, +} from "@/components/action-requests/attention-request-model"; import { CommentToolCallCard, splitToolDirectives, @@ -51,6 +56,7 @@ import { MentionInput } from "@/components/inputs/mention-input"; import { Kbd } from "@/components/ui/kbd"; import { Combobox } from "@/components/ui/combobox"; import { clearDraft, readDraft, saveDraft } from "@/components/ui/modal/draft"; +import { useConfirm } from "@/components/ui/modal"; import { useMaybeWorkspace } from "@/hooks/use-workspace"; import { useRealtime } from "@/hooks/use-realtime"; @@ -375,7 +381,14 @@ function IssueActionRequests({ issueId, canResolve }: { issueId: string; canReso Needs your decision · {requests.length} {requests.map((request) => - request.kind === "FREE_FORM" ? ( + request.presentation?.protocol === "WORK_SESSION_RECOVERY" ? ( + + ) : request.kind === "FREE_FORM" ? ( { + void utils.actionRequest.list.invalidate(); + void utils.workSession.listForIssue.invalidate({ issueId }); + void utils.issue.activity.invalidate({ issueId }); + void utils.commandCenter.summary.invalidate(); + void utils.commandCenter.decisionsCount.invalidate(); + }; + const resolve = trpc.actionRequest.resolveWorkSessionAttention.useMutation({ + onSuccess: (result) => { + settle(); + toast.success( + result.decision === "ABANDON_SESSION" + ? "Delivery session abandoned." + : result.decision === "RESUME_SESSION" + ? "Delivery session resumed." + : "Operator confirmation recorded. MCP evidence remains unchanged.", + ); + }, + onError: (error) => toast.error(error.message), + }); + const dismiss = trpc.actionRequest.dismiss.useMutation({ + onSuccess: () => { + settle(); + toast.success("Card dismissed; Delivery state was not changed."); + }, + onError: (error) => toast.error(error.message), + }); + const actions = safeAttentionActions(request.presentation).filter( + (action) => action.id !== "OPEN_ISSUE", + ); + const runAction = async (action: (typeof actions)[number]) => { + if (!action.enabled) return; + if (action.id === "RESPOND_IN_ISSUE") { + dispatchQuickReply(issueId, `Regarding "${request.title}":`); + return; + } + if (action.id === "DISMISS") { + dismiss.mutate({ id: request.id }); + return; + } + if (!isWorkSessionAttentionDecision(action.id)) return; + if (action.requiresConfirmation || action.tone === "DANGER") { + const approved = await confirm({ + title: action.label, + description: action.description, + primaryLabel: action.label, + secondaryLabel: "Keep reviewing", + variant: action.tone === "DANGER" ? "destructive" : "default", + }); + if (!approved) return; + } + resolve.mutate({ id: request.id, decision: action.id }); + }; + return ( +
+
+ {request.presentation.category.replace(/_/g, " ").toLowerCase()} +
+
{request.title}
+ {request.body ? ( +

{request.body}

+ ) : null} + {request.presentation.details.length > 0 ? ( +
+ {request.presentation.details.map((detail) => ( +
+
{detail.label}
+
{detail.value}
+
+ ))} +
+ ) : null} + {visibleCanResolve ? ( +
+ {actions.map((action) => ( + + ))} +
+ ) : ( +

+ The Delivery owner or a workspace admin can resolve this request. +

+ )} + {confirmElement} +
+ ); +} + function IssueFreeFormRequest({ request, issueId, diff --git a/src/server/routers/__tests__/command-center-action-requests.test.ts b/src/server/routers/__tests__/command-center-action-requests.test.ts index 5df250b8..b1b9a544 100644 --- a/src/server/routers/__tests__/command-center-action-requests.test.ts +++ b/src/server/routers/__tests__/command-center-action-requests.test.ts @@ -1,6 +1,8 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; import { ActionRequestKind, ActionRequestStatus, NotificationSeverity } from "@prisma/client"; import { commandCenterRouter } from "@/server/routers/command-center"; +import { actionRequestRouter } from "@/server/routers/action-request"; +import { sweepStaleWorkSessions } from "@/server/services/work-session"; import { buildContext, createIssue, @@ -21,17 +23,36 @@ afterAll(async () => { }); describe("commandCenterRouter — action requests", () => { - it("returns every decision for an issue and gives reply-less legacy asks a safe dismissal", async () => { + it("returns work-session recovery actions instead of a navigation-only fallback", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "CA" }); fixtures.push(fixture); const prisma = getPrisma(); - const issue = await createIssue(fixture, { title: "Multiple recovery decisions" }); + const issues = await Promise.all([ + createIssue(fixture, { title: "Quiet delivery" }), + createIssue(fixture, { title: "Stale delivery" }), + ]); + const sessions = await Promise.all( + ["quiet", "stale"].map((suffix, index) => + prisma.workSession.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issues[index].id, + ownerUserId: fixture.user.id, + source: "MCP", + status: index === 0 ? "IN_PROGRESS" : "STALE", + repoFullName: "Codename-11/Forge", + branch: `codex/test-${suffix}-${issues[index].id}`, + baseBranch: "main", + }, + }), + ), + ); const rows = await Promise.all( - ["Legacy delivery conflict", "MCP status is unconfirmed"].map((title, index) => + ["MCP status is unconfirmed", "Work session went quiet"].map((title, index) => prisma.actionRequest.create({ data: { workspaceId: fixture.workspace.id, - issueId: issue.id, + issueId: issues[index].id, assignedUserId: fixture.user.id, title, body: `${title} requires an explicit operator decision.`, @@ -39,8 +60,11 @@ describe("commandCenterRouter — action requests", () => { severity: index === 0 ? NotificationSeverity.WARNING : NotificationSeverity.INFO, kind: ActionRequestKind.FREE_FORM, sourceType: "work-session", - sourceId: `session-${issue.id}`, - dedupeKey: `fixture-${issue.id}-${index}`, + sourceId: sessions[index].id, + dedupeKey: + index === 0 + ? `work-session-mcp-quiet:${sessions[index].id}` + : `work-session-stale:${sessions[index].id}`, }, }), ), @@ -48,16 +72,90 @@ describe("commandCenterRouter — action requests", () => { 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); + const returned = summary.actionRequests.filter((request) => + rows.some((row) => row.id === request.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.protocol).toBe("WORK_SESSION_RECOVERY"); expect(request.presentation.actions.map((action) => action.id)).toEqual([ + request.dedupeKey?.startsWith("work-session-stale:") ? "RESUME_SESSION" : "CONFIRM_ACTIVE", + "ABANDON_SESSION", + "RESPOND_IN_ISSUE", "DISMISS", "OPEN_ISSUE", ]); } }); + + it("records operator confirmation without changing MCP lifecycle evidence", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "CB" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { title: "Quiet delivery" }); + const session = await prisma.workSession.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + ownerUserId: fixture.user.id, + source: "MCP", + status: "IN_PROGRESS", + repoFullName: "Codename-11/Forge", + branch: `codex/quiet-${issue.id}`, + baseBranch: "main", + lastHeartbeatAt: new Date(Date.now() - 3 * 60 * 60_000), + }, + }); + const request = await prisma.actionRequest.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + assignedUserId: fixture.user.id, + title: "MCP status is unconfirmed", + status: ActionRequestStatus.OPEN, + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + sourceType: "work-session", + sourceId: session.id, + dedupeKey: `work-session-mcp-quiet:${session.id}`, + }, + }); + const beforeHeartbeat = session.lastHeartbeatAt; + const caller = actionRequestRouter.createCaller(await buildContext(fixture)); + + await caller.resolveWorkSessionAttention({ + id: request.id, + decision: "CONFIRM_ACTIVE", + }); + + await expect( + prisma.workSession.findUniqueOrThrow({ where: { id: session.id } }), + ).resolves.toMatchObject({ + status: "IN_PROGRESS", + lastHeartbeatAt: beforeHeartbeat, + operatorConfirmedAt: expect.any(Date), + }); + await expect( + prisma.actionRequest.findUniqueOrThrow({ where: { id: request.id } }), + ).resolves.toMatchObject({ + status: ActionRequestStatus.RESOLVED, + resolvedByUserId: fixture.user.id, + }); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { workSessionStaleMinutes: 1 }, + }); + await sweepStaleWorkSessions(prisma); + await expect( + prisma.actionRequest.count({ + where: { + workspaceId: fixture.workspace.id, + status: ActionRequestStatus.OPEN, + sourceId: session.id, + }, + }), + ).resolves.toBe(0); + }); }); diff --git a/src/server/routers/action-request.ts b/src/server/routers/action-request.ts index df828bfa..e23abc35 100644 --- a/src/server/routers/action-request.ts +++ b/src/server/routers/action-request.ts @@ -11,11 +11,14 @@ import { declineActionRequest, getActionRequestResults, resolveDeliveryConnectionConflict, + resolveWorkSessionAttention, transitionActionRequest, voteOnActionRequest, + workSessionAttentionDecisionSchema, } from "@/server/services/action-request-service"; import { enqueueGitHubResourceReconciliation } from "@/server/services/github/reconciliation-queue"; import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types"; +import { actionRequestPresentation } from "@/server/routers/command-center"; /** * Zod-side mirror of `ActionRequestKind` so the router can accept the @@ -61,8 +64,26 @@ export const actionRequestRouter = router({ }, orderBy: { createdAt: "desc" }, take: input.limit, + include: { + requestedByAgent: { + select: { id: true, name: true, profileKey: true, avatar: true }, + }, + }, }); - return { items: rows }; + const userId = ctx.session?.user?.id ?? null; + return { + items: await Promise.all( + rows.map(async (request) => ({ + ...request, + presentation: await actionRequestPresentation( + ctx.db, + ctx.workspaceId, + { userId, role: ctx.membership.role }, + request, + ), + })), + ), + }; }), /** Open requests assigned to the calling user. Powers the inbox surface. */ @@ -366,6 +387,26 @@ export const actionRequestRouter = router({ }); }), + resolveWorkSessionAttention: workspaceProcedure + .input( + z.object({ + id: z.string().cuid(), + decision: workSessionAttentionDecisionSchema, + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session?.user?.id; + if (!userId) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "No session user." }); + } + return resolveWorkSessionAttention(ctx.db, { + workspaceId: ctx.workspaceId, + actorId: userId, + requestId: input.id, + decision: input.decision, + }); + }), + /** * Decline the request: flip status=REJECTED with the operator's * reason. Never dispatches the bound action. Same permission gate diff --git a/src/server/routers/command-center.ts b/src/server/routers/command-center.ts index eb4538a4..5f278bda 100644 --- a/src/server/routers/command-center.ts +++ b/src/server/routers/command-center.ts @@ -38,7 +38,7 @@ type AttentionAction = { disabledReason: string | null; }; -async function actionRequestPresentation( +export async function actionRequestPresentation( db: PrismaClient, workspaceId: string, viewer: { userId: string | null; role: string }, @@ -51,6 +51,7 @@ async function actionRequestPresentation( issueId: string | null; sourceType: string | null; sourceId: string | null; + dedupeKey: string | null; requestedByAgent: { id: string; name: string; @@ -82,14 +83,135 @@ async function actionRequestPresentation( }; const dismiss: AttentionAction = { id: "DISMISS", - label: "Dismiss", - description: "Remove this stale request from the attention queue.", + label: "Dismiss card", + description: + "Remove this card without resolving the underlying issue or Delivery state. It may return if the condition persists.", tone: "NEUTRAL", requiresConfirmation: false, enabled: true, disabledReason: null, }; + const isMcpQuiet = + request.sourceType === "work-session" && + request.dedupeKey?.startsWith("work-session-mcp-quiet:") === true; + const isStaleSession = + request.sourceType === "work-session" && + request.dedupeKey?.startsWith("work-session-stale:") === true; + if (request.kind === ActionRequestKind.FREE_FORM && (isMcpQuiet || isStaleSession)) { + const session = request.sourceId + ? await db.workSession.findFirst({ + where: { id: request.sourceId, workspaceId }, + select: { + id: true, + status: true, + endedAt: true, + repoFullName: true, + branch: true, + ownerUserId: true, + operatorConfirmedAt: true, + ownerConnection: { + select: { status: true, confidence: true, displayName: true, lastSeenAt: true }, + }, + }, + }) + : null; + const isAdmin = viewer.role === "OWNER" || viewer.role === "ADMIN"; + const canManage = + Boolean(session && !session.endedAt) && + (isAdmin || (viewer.userId !== null && session?.ownerUserId === viewer.userId)); + const unavailableReason = !session + ? "The delivery session no longer exists." + : session.endedAt + ? "The delivery session has already ended." + : canManage + ? null + : "Only the work-session owner or a workspace admin can manage this session."; + const primary: AttentionAction = { + id: isStaleSession ? "RESUME_SESSION" : "CONFIRM_ACTIVE", + label: isStaleSession ? "Resume session" : "Still working", + description: isStaleSession + ? "Resume this existing Delivery session and keep its ownership reserved." + : "Record an audited operator confirmation and keep Delivery reserved without claiming the MCP client checked in.", + tone: "PRIMARY", + requiresConfirmation: false, + enabled: unavailableReason === null, + disabledReason: unavailableReason, + }; + const abandon: AttentionAction = { + id: "ABANDON_SESSION", + label: "Abandon session", + description: + "End this Delivery session and release its ownership so replacement work can start.", + tone: "DANGER", + requiresConfirmation: true, + enabled: unavailableReason === null, + disabledReason: unavailableReason, + }; + const respondInIssue: AttentionAction | null = request.issueId + ? { + id: "RESPOND_IN_ISSUE", + label: "Respond in issue", + description: + "Open the issue composer with this request’s context so you can explain the intended next step.", + tone: "NEUTRAL", + requiresConfirmation: false, + enabled: true, + disabledReason: null, + } + : null; + return { + ...base, + category: isMcpQuiet ? "DELIVERY_STATUS_REQUIRED" : "DELIVERY_RECOVERY", + protocol: "WORK_SESSION_RECOVERY", + replyTarget: null, + actions: [ + primary, + abandon, + ...(respondInIssue ? [respondInIssue] : []), + dismiss, + ...(openIssue ? [openIssue] : []), + ], + details: [ + { label: "Delivery state", value: session?.status ?? "Unavailable" }, + ...(session + ? [{ label: "Branch", value: `${session.repoFullName}:${session.branch}` }] + : []), + ...(session?.ownerConnection?.displayName + ? [{ label: "Owning connection", value: session.ownerConnection.displayName }] + : []), + ...(session?.ownerConnection + ? [ + { + label: "Connection evidence", + value: `${session.ownerConnection.status.toLowerCase()} · ${session.ownerConnection.confidence.toLowerCase()}`, + }, + ] + : []), + ...(session?.operatorConfirmedAt + ? [ + { + label: "Last operator confirmation", + value: session.operatorConfirmedAt.toISOString(), + }, + ] + : []), + ], + technicalDetails: [ + ...base.technicalDetails, + ...(session ? [{ label: "Work session", value: session.id }] : []), + ...(session?.ownerConnection?.lastSeenAt + ? [ + { + label: "Connection last seen", + value: session.ownerConnection.lastSeenAt.toISOString(), + }, + ] + : []), + ], + }; + } + if (request.kind === ActionRequestKind.DELIVERY_CONNECTION_CONFLICT) { const raw = request.payload && typeof request.payload === "object" && !Array.isArray(request.payload) @@ -265,7 +387,20 @@ async function actionRequestPresentation( ...(openIssue ? [openIssue] : []), ] : openIssue - ? [dismiss, openIssue] + ? [ + { + id: "RESPOND_IN_ISSUE", + label: "Respond in issue", + description: + "Open the issue composer with this request’s context when no structured operation is available.", + tone: "PRIMARY", + requiresConfirmation: false, + enabled: true, + disabledReason: null, + }, + dismiss, + openIssue, + ] : [dismiss]; return { ...base, diff --git a/src/server/services/action-request-service.ts b/src/server/services/action-request-service.ts index 7386892e..c4229fd0 100644 --- a/src/server/services/action-request-service.ts +++ b/src/server/services/action-request-service.ts @@ -1427,6 +1427,164 @@ export const deliveryConflictDecisionSchema = z.enum([ ]); export type DeliveryConflictDecision = z.infer; +export const workSessionAttentionDecisionSchema = z.enum([ + "CONFIRM_ACTIVE", + "RESUME_SESSION", + "ABANDON_SESSION", +]); +export type WorkSessionAttentionDecision = z.infer; + +/** + * Resolve stale/MCP-quiet Delivery attention with an actual session operation. + * Operator confirmation is intentionally distinct from lifecycle heartbeat + * evidence, so confirming intent never makes a quiet MCP client look healthy. + */ +export async function resolveWorkSessionAttention( + db: PrismaClient, + params: { + workspaceId: string; + actorId: string; + requestId: string; + decision: WorkSessionAttentionDecision; + }, +): Promise<{ id: string; decision: WorkSessionAttentionDecision }> { + const request = await db.actionRequest.findFirst({ + where: { id: params.requestId, workspaceId: params.workspaceId }, + select: { + id: true, + status: true, + kind: true, + assignedUserId: true, + issueId: true, + sourceType: true, + sourceId: true, + dedupeKey: true, + }, + }); + if (!request) { + throw new TRPCError({ code: "NOT_FOUND", message: "Action request not found." }); + } + if (request.status !== ActionRequestStatus.OPEN) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Action request is already ${request.status.toLowerCase()}.`, + }); + } + const isMcpQuiet = request.dedupeKey?.startsWith("work-session-mcp-quiet:") === true; + const isStale = request.dedupeKey?.startsWith("work-session-stale:") === true; + if ( + request.kind !== ActionRequestKind.FREE_FORM || + request.sourceType !== "work-session" || + !request.sourceId || + (!isMcpQuiet && !isStale) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This request is not a supported work-session recovery decision.", + }); + } + await assertCanResolveActionRequest(db, { + workspaceId: params.workspaceId, + userId: params.actorId, + request, + }); + const [session, membership] = await Promise.all([ + db.workSession.findFirst({ + where: { id: request.sourceId, workspaceId: params.workspaceId }, + select: { id: true, status: true, endedAt: true, ownerUserId: true }, + }), + db.membership.findUnique({ + where: { + userId_workspaceId: { + userId: params.actorId, + workspaceId: params.workspaceId, + }, + }, + select: { role: true }, + }), + ]); + if (!session) { + throw new TRPCError({ code: "NOT_FOUND", message: "Work session not found." }); + } + if (session.endedAt) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Work session has already ended." }); + } + const isAdmin = membership?.role === Role.OWNER || membership?.role === Role.ADMIN; + if (!isAdmin && session.ownerUserId !== params.actorId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the work-session owner or a workspace admin can resolve this request.", + }); + } + + if (params.decision === "ABANDON_SESSION") { + const { advanceWorkSession } = await import("@/server/services/work-session"); + await advanceWorkSession(db, { + workspaceId: params.workspaceId, + sessionId: session.id, + actor: { userId: params.actorId }, + status: "ABANDONED", + }); + await db.actionRequest.update({ + where: { id: request.id }, + data: { + resolvedByUserId: params.actorId, + resolution: "Operator abandoned the delivery session.", + }, + }); + return { id: request.id, decision: params.decision }; + } + + const now = new Date(); + const resolution = + params.decision === "RESUME_SESSION" + ? "Operator resumed the existing delivery session." + : "Operator confirmed the delivery session is still active."; + await db.$transaction(async (tx) => { + const updated = await tx.workSession.update({ + where: { id: session.id }, + data: { + operatorConfirmedAt: now, + staleAt: null, + ...(params.decision === "RESUME_SESSION" && session.status === "STALE" + ? { status: "IN_PROGRESS" } + : {}), + }, + }); + await tx.actionRequest.update({ + where: { id: request.id }, + data: { + status: ActionRequestStatus.RESOLVED, + resolvedAt: now, + resolvedByUserId: params.actorId, + resolution, + }, + }); + await recordChange(tx, { + workspaceId: params.workspaceId, + actorId: params.actorId, + entity: "WorkSession", + entityId: session.id, + action: params.decision === "RESUME_SESSION" ? "operator-resume" : "operator-confirm-active", + before: { status: session.status, operatorConfirmedAt: null }, + after: { + status: updated.status, + operatorConfirmedAt: updated.operatorConfirmedAt?.toISOString() ?? null, + }, + eventKind: EventKind.ISSUE_UPDATED, + subjectType: "work-session", + subjectId: session.id, + payload: { + issueId: request.issueId, + actionRequestId: request.id, + decision: params.decision, + lifecycleConfidenceChanged: false, + }, + }); + }); + return { id: request.id, decision: params.decision }; +} + /** * Resolve a blocked managed-runtime delivery attempt. The request, lease, and * queued (never-started) run are locked and revalidated in one transaction so diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts index fd5e6284..99ab9d51 100644 --- a/src/server/services/work-session.ts +++ b/src/server/services/work-session.ts @@ -1020,6 +1020,7 @@ export async function sweepStaleWorkSessions(db: PrismaClient): Promise workspaceId: workspace.id, status: { in: [...STALEABLE_WORK_SESSION_STATUSES] }, lastHeartbeatAt: { lt: cutoff }, + OR: [{ operatorConfirmedAt: null }, { operatorConfirmedAt: { lt: cutoff } }], }, include: { ownerConnection: { select: { id: true, kind: true, status: true } }, @@ -1089,6 +1090,7 @@ export async function sweepStaleWorkSessions(db: PrismaClient): Promise id: session.id, status: { in: [...STALEABLE_WORK_SESSION_STATUSES] }, lastHeartbeatAt: { lt: cutoff }, + OR: [{ operatorConfirmedAt: null }, { operatorConfirmedAt: { lt: cutoff } }], }, data: { status: WorkSessionStatus.STALE, staleAt: new Date() }, }); diff --git a/tests/e2e/command-center-attention-actions.spec.ts b/tests/e2e/command-center-attention-actions.spec.ts index c426fdbf..18553057 100644 --- a/tests/e2e/command-center-attention-actions.spec.ts +++ b/tests/e2e/command-center-attention-actions.spec.ts @@ -22,12 +22,14 @@ const FIXTURE = { workSessionId: "e2e-axi130-work-session", candidateRunId: "e2e-axi130-candidate-run", actionableRequestId: "e2e-axi130-actionable-request", + recoveryRequestId: "e2e-axi164-recovery-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"; const FALLBACK_TITLE = "Review an ask without a registered reply target"; +const RECOVERY_TITLE = "MCP status for AXI-130001 is unconfirmed"; function e2eDatabaseUrl() { if (process.env.E2E_MANAGE_STACK === "0") { @@ -56,6 +58,7 @@ async function clearFixtures() { id: { in: [ FIXTURE.actionableRequestId, + FIXTURE.recoveryRequestId, FIXTURE.fallbackRequestId, FIXTURE.secondFallbackRequestId, ], @@ -229,6 +232,20 @@ test.describe("Command Center attention actions", () => { sourceType: "e2e-fixture", sourceId: FIXTURE.secondFallbackRequestId, }, + { + id: FIXTURE.recoveryRequestId, + workspaceId: workspace.id, + issueId: FIXTURE.actionableIssueId, + title: RECOVERY_TITLE, + body: "The owning MCP connection has not sent a lifecycle signal. Delivery remains reserved until the operator confirms or abandons it.", + status: ActionRequestStatus.OPEN, + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + assignedUserId: owner.id, + sourceType: "work-session", + sourceId: FIXTURE.workSessionId, + dedupeKey: `work-session-mcp-quiet:${FIXTURE.workSessionId}`, + }, ], }); }); @@ -281,16 +298,30 @@ test.describe("Command Center attention actions", () => { const fallbackCard = page .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("link", { name: "Open issue" })).toHaveCount(0); + await expect(fallbackCard.getByRole("button", { name: "Respond in issue" })).toBeVisible(); + await expect(fallbackCard.getByRole("button", { name: "Dismiss card" })).toBeVisible(); + await expect(fallbackCard.getByRole("button", { name: "Respond", exact: true })).toHaveCount(0); await expect(fallbackCard.getByRole("button", { name: "Accept" })).toHaveCount(0); await expect(fallbackCard.getByRole("button", { name: "Decline" })).toHaveCount(0); + await fallbackCard.getByRole("button", { name: "More responses" }).click(); + await expect(page.getByRole("menuitem", { name: /Open issue/ })).toBeVisible(); + await page.keyboard.press("Escape"); 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(); + await expect(secondFallbackCard.getByRole("button", { name: "Dismiss card" })).toBeVisible(); + + const recoveryCard = page + .getByRole("link", { name: RECOVERY_TITLE }) + .locator("xpath=ancestor::div[contains(@class, 'bg-card/40')][1]"); + await expect(recoveryCard.getByText("delivery status required", { exact: true })).toBeVisible(); + await expect(recoveryCard.getByRole("button", { name: "Still working" })).toBeVisible(); + await expect(recoveryCard.getByRole("button", { name: "Abandon session" })).toBeVisible(); + await expect(recoveryCard.getByRole("button", { name: "Dismiss card" })).toHaveCount(0); + await recoveryCard.getByRole("button", { name: "More responses" }).click(); + await expect(page.getByRole("menuitem", { name: /Dismiss card/ })).toBeVisible(); }); }); diff --git a/tests/unit/attention-request-model.test.ts b/tests/unit/attention-request-model.test.ts index 96aa062c..704cbc0b 100644 --- a/tests/unit/attention-request-model.test.ts +++ b/tests/unit/attention-request-model.test.ts @@ -63,6 +63,25 @@ describe("safeAttentionActions", () => { expect(result.map((item) => item.id)).toEqual(["ACCEPT", "DECLINE", "DISMISS", "RESPOND"]); }); + it("allows only registered work-session recovery decisions and safe fallbacks", () => { + const result = safeAttentionActions( + presentation("WORK_SESSION_RECOVERY", [ + action("CONFIRM_ACTIVE"), + action("ABANDON_SESSION"), + action("RESPOND_IN_ISSUE"), + action("DISMISS"), + action("DELETE_REPOSITORY"), + ]), + ); + + expect(result.map((item) => item.id)).toEqual([ + "CONFIRM_ACTIVE", + "ABANDON_SESSION", + "RESPOND_IN_ISSUE", + "DISMISS", + ]); + }); + it("falls back safely when no presentation is registered", () => { expect(safeAttentionActions(null)).toEqual([]); expect(safeAttentionActions(undefined)).toEqual([]);