diff --git a/CHANGELOG.md b/CHANGELOG.md index a114f3d2..cbe8e9ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. ## [Unreleased] +## [2026-07-13] — v0.10.1 · Human-action visibility + +### Changed + +- **Runtime permission pauses are now first-class decisions.** Command Center surfaces them in its priority queue with the requested command and inline approve/reject controls, counts them in the Decisions badge, and no longer mislabels a patient approval wait as a stale run that should be abandoned. + +### Fixed + +- **Human-action stalls no longer disappear when an issue is unassigned.** Issue detail resolves the real active or waiting run independently of current assignment, prioritizes permission and waiting states, and shows the same actionable permission card in the main issue flow and persistent agent rail. +- **Approval state now refreshes every related surface reliably.** Connector approval capture and resolution write a deduplicated run event plus an audited activity event carrying the issue context, so Mission Control, Command Center, and issue detail converge over realtime updates. Open issue-bound asks created outside comments are also visible on the issue. + ## [2026-07-13] — v0.10.0 · Rich issue rendering & delivery-safe goals ### Added diff --git a/DEVLOG.md b/DEVLOG.md index d40e7539..84e0edb0 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,33 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-13 — AXI-102 human-action visibility + +Traced AXI-102 from production: its intentionally unassigned Backlog issue had +a valid Victor RESEARCH run in WAITING with a connector permission request, +but no ActionRequest, notification, or approval-specific activity event. The +floating agent overlay read the run directly; Command Center's priority queue +only understood ActionRequests/recovery/gates; and issue detail rejected the +run because it did not match `Issue.assignedAgentId`. + +Made runtime approvals a first-class Command Center decision with inline +approve/reject controls and badge counts. Issue detail now resolves live runs +from the issue relationship rather than mutable assignment, prioritizes +approval/waiting work, renders the approval in the main flow and rail, and +surfaces open issue-bound asks that were created outside a comment. Approval +waits are excluded from generic stale recovery instead of recommending an +incorrect abandon action. + +Added an atomic approval lifecycle boundary: poll and subscription producers +deduplicate capture, late subscription detail enriches poll-first records, +and capture writes both the run event and audited/realtime BLOCKED event with +issue context. Provider and operator resolution races similarly produce one +refresh event. + +Verification in progress for v0.10.1. Targeted approval, recovery, Command +Center, event-rollup, and unassigned-issue coverage is green (**23 passed**); +lint and typecheck are green with existing repository warnings only. + ## 2026-07-13 — Rich-rendering recovery + human delivery acceptance Recovered the six AXI-95–99 rich-rendering commits from the stale Codex bridge diff --git a/package.json b/package.json index f377cc0a..5c80ec06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.10.0", + "version": "0.10.1", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", diff --git a/src/app/(app)/w/[slug]/command-center/page.tsx b/src/app/(app)/w/[slug]/command-center/page.tsx index bbea5352..38d2c6e8 100644 --- a/src/app/(app)/w/[slug]/command-center/page.tsx +++ b/src/app/(app)/w/[slug]/command-center/page.tsx @@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button"; import { Picker } from "@/components/ui/modal"; import { EmptyState, SkeletonList } from "@/components/ui"; import { AgentAttentionPanel } from "@/components/agent-attention-panel"; +import { RunApprovalCard } from "@/components/agents/run-approval-card"; import { WorkspaceActivityTimeline } from "@/components/workspace-activity-timeline"; import { trpc } from "@/lib/trpc"; import { useWorkspace } from "@/hooks/use-workspace"; @@ -108,6 +109,16 @@ export default function CommandCenterPage() { counts: { ...prev.counts, reviewGates: Math.max(0, prev.counts.reviewGates - 1) }, }); }; + const dropRuntimeApproval = (id: string) => { + const prev = utils.commandCenter.summary.getData(summaryInput); + if (!prev) return; + const runtimeApprovals = prev.runtimeApprovals.filter((run) => run.id !== id); + utils.commandCenter.summary.setData(summaryInput, { + ...prev, + runtimeApprovals, + counts: { ...prev.counts, runtimeApprovals: runtimeApprovals.length }, + }); + }; const dropRunFailures = (ids: string[]) => { const prev = utils.commandCenter.summary.getData(summaryInput); if (!prev) return; @@ -142,10 +153,14 @@ export default function CommandCenterPage() { }, }); const attentionCount = data - ? data.actionRequests.length + data.reviewGates.length + data.stalledRuns.length + ? data.actionRequests.length + + data.runtimeApprovals.length + + data.reviewGates.length + + data.stalledRuns.length : 0; const activeAttentionGroups = data ? Number(data.actionRequests.length > 0) + + Number(data.runtimeApprovals.length > 0) + Number(data.stalledRuns.length > 0) + Number(data.reviewGates.length > 0) : 0; @@ -156,7 +171,7 @@ export default function CommandCenterPage() { title="Command Center" subtitle={ data - ? `Decisions & live agent ops · ${data.counts.actionRequests} asks · ${data.counts.reviewGates} gates · ${data.counts.activeRuns} active runs` + ? `Decisions & live agent ops · ${data.counts.actionRequests} asks · ${data.counts.runtimeApprovals} approvals · ${data.counts.reviewGates} gates · ${data.counts.activeRuns} active runs` : "Decisions & live agent ops" } /> @@ -208,6 +223,22 @@ export default function CommandCenterPage() { ))} ) : null} + {data.runtimeApprovals.length > 0 ? ( + + {data.runtimeApprovals.map((run) => ( + dropRuntimeApproval(run.id)} + /> + ))} + + ) : null} {data.stalledRuns.length > 0 ? (
- void; +}) { + const utils = trpc.useUtils(); + const href = `/w/${run.issue.workspace.slug || slug}/i/${run.issue.workspace.key}-${run.issue.number}`; + return ( +
+ +
+ + {run.issue.workspace.key}-{run.issue.number} · {run.issue.title} + + @{run.agent.profileKey} +
+ {run.currentStep ? ( +

+ {run.currentStep} +

+ ) : null} + + { + onResolved(); + void utils.commandCenter.summary.invalidate(); + void utils.commandCenter.decisionsCount.invalidate(); + void utils.issue.byId.invalidate({ id: run.issue.id }); + void utils.agentRun.activeForIssue.invalidate({ issueId: run.issue.id }); + }} + /> +
+ ); +} + function ActionRequestDecisionCard({ request, slug, diff --git a/src/components/action-requests/action-request-card.tsx b/src/components/action-requests/action-request-card.tsx index 4f9cb657..e2eb24e0 100644 --- a/src/components/action-requests/action-request-card.tsx +++ b/src/components/action-requests/action-request-card.tsx @@ -40,7 +40,8 @@ import { useWorkspace } from "@/hooks/use-workspace"; */ export type ActionRequestCardProps = | ActionRequestCardCommentProps - | ActionRequestCardPlanProps; + | ActionRequestCardPlanProps + | ActionRequestCardDirectProps; interface ActionRequestCardCommentProps { /** Comment row's id — the bound ActionRequest is fetched by this. */ @@ -75,13 +76,88 @@ interface ActionRequestCardPlanProps { onResolved?: () => void; } +interface ActionRequestCardDirectProps { + /** Render an issue-bound request that was not created from a comment. */ + requestId: string; + commentId?: never; + planId?: never; + issueId: string; + canResolve?: boolean; + onResolved?: () => void; +} + export function ActionRequestCard(props: ActionRequestCardProps) { + if ("requestId" in props && props.requestId) { + return ; + } if ("planId" in props && props.planId) { return ; } return ; } +function ActionRequestCardForRequest({ + requestId, + issueId, + canResolve, + onResolved, +}: ActionRequestCardDirectProps) { + const utils = trpc.useUtils(); + const ws = useWorkspace(); + const isAdmin = ws.role === ("OWNER" as Role) || ws.role === ("ADMIN" as Role); + const visibleCanResolve = canResolve || isAdmin; + const { data: request, isLoading } = trpc.actionRequest.get.useQuery( + { id: requestId }, + { staleTime: 30_000 }, + ); + const [showDeclineReason, setShowDeclineReason] = useState(false); + const [declineReason, setDeclineReason] = useState(""); + const settle = () => { + void utils.actionRequest.get.invalidate({ id: requestId }); + void utils.actionRequest.list.invalidate(); + void utils.issue.byId.invalidate({ id: issueId }); + void utils.commandCenter.summary.invalidate(); + void utils.commandCenter.decisionsCount.invalidate(); + onResolved?.(); + }; + const accept = trpc.actionRequest.accept.useMutation({ + onError: (error) => toast.error(error.message), + onSuccess: settle, + }); + const decline = trpc.actionRequest.decline.useMutation({ + onError: (error) => toast.error(error.message), + onSuccess: () => { + setShowDeclineReason(false); + setDeclineReason(""); + settle(); + }, + }); + + if (isLoading || !request) return null; + return ( + accept.mutate({ id: request.id })} + onDecline={() => { + if (!showDeclineReason) { + setShowDeclineReason(true); + return; + } + decline.mutate({ id: request.id, reason: declineReason || null }); + }} + onCancelDecline={() => { + setShowDeclineReason(false); + setDeclineReason(""); + }} + pending={accept.isPending || decline.isPending} + /> + ); +} + function ActionRequestCardForComment({ commentId, canResolve, diff --git a/src/components/agents/run-approval-card.tsx b/src/components/agents/run-approval-card.tsx index db6722e0..9226147b 100644 --- a/src/components/agents/run-approval-card.tsx +++ b/src/components/agents/run-approval-card.tsx @@ -1,13 +1,14 @@ "use client"; import { Check, ShieldAlert, X } from "lucide-react"; +import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; /** * Operator approval card for a connector-driven run paused awaiting * permission (Codex/Hermes flagged a command or file change). Shared by the - * Mission Control Live tab (`RunRow`) and the issue right-rail - * (`IssueAgentPanel`) so an approval is actionable wherever the operator is - * looking — not buried in one surface. + * Mission Control Live tab (`RunRow`), Command Center priority queue, and + * issue detail so an approval is actionable wherever the operator is looking + * — not buried in one surface. * * Approve grants **session** scope by default (`acceptForSession`) so a * research sweep doesn't re-prompt on every command; the per-command "once" @@ -42,10 +43,16 @@ export function RunApprovalCard({ const utils = trpc.useUtils(); const approval = readPendingApproval(pendingApproval); const respond = trpc.agentRun.respondApproval.useMutation({ + onSuccess: (_result, variables) => { + toast.success(variables.decision === "approve" ? "Permission granted" : "Run stopped"); + onResolved?.(); + }, + onError: (error) => toast.error(error.message), onSettled: () => { void utils.agentRun.activeAll.invalidate(); void utils.agentRun.activeForIssue.invalidate(); - onResolved?.(); + void utils.commandCenter.summary.invalidate(); + void utils.commandCenter.decisionsCount.invalidate(); }, }); diff --git a/src/components/issue-detail/agent-run-strip.tsx b/src/components/issue-detail/agent-run-strip.tsx index cb297772..d0f29d76 100644 --- a/src/components/issue-detail/agent-run-strip.tsx +++ b/src/components/issue-detail/agent-run-strip.tsx @@ -14,6 +14,7 @@ import { trpc } from "@/lib/trpc"; import { useRealtime } from "@/hooks/use-realtime"; import { relativeTime, cn } from "@/lib/utils"; import { RuntimePolicyBadges } from "@/components/runtime-tool-surface"; +import { RunApprovalCard } from "@/components/agents/run-approval-card"; import type { RuntimePolicySnapshot } from "@/lib/runtime-enforcement"; /** @@ -193,6 +194,19 @@ export function AgentRunStrip({ issueId }: { issueId: string }) { + {run.awaitingApprovalAt ? ( + { + void utils.agentRun.activeForIssue.invalidate({ issueId }); + void utils.issue.byId.invalidate({ id: issueId }); + void utils.commandCenter.summary.invalidate(); + void utils.commandCenter.decisionsCount.invalidate(); + }} + /> + ) : null} ); } diff --git a/src/components/issue-detail/issue-main.tsx b/src/components/issue-detail/issue-main.tsx index ee328191..9c67dd62 100644 --- a/src/components/issue-detail/issue-main.tsx +++ b/src/components/issue-detail/issue-main.tsx @@ -44,6 +44,7 @@ import { Kbd } from "@/components/ui/kbd"; import { Combobox } from "@/components/ui/combobox"; import { clearDraft, readDraft, saveDraft } from "@/components/ui/modal/draft"; import { useMaybeWorkspace } from "@/hooks/use-workspace"; +import { useRealtime } from "@/hooks/use-realtime"; // Live data for the slash-command VALUE autocomplete (projects / agents / // labels), shared by the description + comment composers so `/project`, @@ -341,6 +342,7 @@ export function IssueMain({ + @@ -350,6 +352,59 @@ export function IssueMain({ ); } +/** + * Surface every open issue-bound ask even when the agent/runtime created it + * outside the comment timeline. Comment-bound requests remain rendered beside + * their source comment, so this panel only fills the visibility gap for other + * sources (runs, automations, imports, and workspace decision flows). + */ +function IssueActionRequests({ issueId, canResolve }: { issueId: string; canResolve: boolean }) { + const ws = useWorkspace(); + const utils = trpc.useUtils(); + const { data } = trpc.actionRequest.list.useQuery( + { issueId, status: "OPEN", limit: 20 }, + { staleTime: 15_000 }, + ); + useRealtime((event) => { + if (event.subjectType !== "action-request") return; + void utils.actionRequest.list.invalidate(); + }); + const requests = (data?.items ?? []).filter((request) => request.sourceType !== "comment"); + if (requests.length === 0) return null; + return ( +
+
+ Needs your decision · {requests.length} +
+ {requests.map((request) => + request.kind === "FREE_FORM" ? ( +
+
{request.title}
+ {request.body ? ( +

+ {request.body} +

+ ) : null} + + Answer in Command Center + +
+ ) : ( + + ), + )} +
+ ); +} + /** * Backlink strip: goals spawned from this issue (via `/goal` or * goal.create with this issueId). Renders nothing when the issue has no diff --git a/src/server/routers/__tests__/command-center-runtime-approval.test.ts b/src/server/routers/__tests__/command-center-runtime-approval.test.ts new file mode 100644 index 00000000..db83f71b --- /dev/null +++ b/src/server/routers/__tests__/command-center-runtime-approval.test.ts @@ -0,0 +1,82 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { AgentRunStatus } 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 — runtime approvals", () => { + it("counts a paused runtime once as a decision and not as an ordinary active run", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "CC" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { title: "Permission-paused issue" }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Victor", + profileKey: `victor-${Date.now()}`, + }, + }); + const approvalRun = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + currentStep: "waiting for permission", + awaitingApprovalAt: new Date(), + pendingApproval: { + command: "git fetch origin", + description: "Network access is required.", + }, + }, + }); + const ordinaryRun = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + currentStep: "reading source", + }, + }); + const caller = commandCenterRouter.createCaller(await buildContext(fixture)); + + const summary = await caller.summary({ dueWindowDays: 7, limit: 20 }); + + expect(summary.runtimeApprovals.map((run) => run.id)).toEqual([approvalRun.id]); + expect(summary.runtimeApprovals[0]?.pendingApproval).toMatchObject({ + command: "git fetch origin", + description: "Network access is required.", + }); + expect(summary.activeRuns.map((run) => run.id)).toContain(ordinaryRun.id); + expect(summary.activeRuns.map((run) => run.id)).not.toContain(approvalRun.id); + expect(summary.counts.runtimeApprovals).toBe(1); + expect(summary.counts.activeRuns).toBe(1); + expect(summary.stalledRuns.map((run) => run.id)).not.toContain(approvalRun.id); + + const decisions = await caller.decisionsCount(); + expect(decisions).toMatchObject({ + actionRequests: 0, + reviewGates: 0, + runtimeApprovals: 1, + total: 1, + }); + }); +}); diff --git a/src/server/routers/__tests__/event.test.ts b/src/server/routers/__tests__/event.test.ts index 2b91c9aa..6a03eb7b 100644 --- a/src/server/routers/__tests__/event.test.ts +++ b/src/server/routers/__tests__/event.test.ts @@ -232,4 +232,36 @@ describe("eventRouter", () => { expect.arrayContaining(["question", "blocked"]), ); }); + + it("counts a runtime approval once instead of duplicating it as active work", async () => { + const { fixture, prisma, agent, caller } = await setup(); + const issue = await createIssue(fixture, { title: "Approve temporary runtime access" }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + currentStep: "needs permission to inspect deployment logs", + awaitingApprovalAt: new Date(), + pendingApproval: { + command: "docker compose logs forge", + description: "Temporary access requested.", + }, + }, + }); + + const result = await caller.agentAttention({ limit: 5, itemLimit: 5 }); + const row = result.agents.find((item) => item.agent.id === agent.id); + + expect(row?.counts).toMatchObject({ approvals: 1, activeRuns: 0, total: 1 }); + expect(row?.items).toHaveLength(1); + expect(row?.items[0]).toMatchObject({ + id: `approval:${run.id}`, + kind: "approval", + title: "Runtime approval needed", + }); + expect(row?.items[0]?.detail).toContain(fixture.workspace.key); + expect(row?.items[0]?.detail).toContain("needs permission to inspect deployment logs"); + }); }); diff --git a/src/server/routers/agent-run.ts b/src/server/routers/agent-run.ts index d31febcd..c615866f 100644 --- a/src/server/routers/agent-run.ts +++ b/src/server/routers/agent-run.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { AgentRunControlState, AgentRunStatus, EngagementMode, EventKind, Prisma } from "@prisma/client"; -import type { PrismaClient } from "@prisma/client"; +import { AgentRunControlState, AgentRunStatus, EngagementMode, EventKind } from "@prisma/client"; +import type { Prisma, PrismaClient } from "@prisma/client"; import { adminProcedure, router, workspaceProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; import { maybeAutoDispatch } from "@/server/services/dispatcher"; import { deliverWebhook } from "@/server/services/plugin-runtime"; import { STALE_RUN_MS } from "@/server/services/agent-presence"; import { appendRunEvent, finishRun } from "@/server/services/agent-run"; +import { resolveRunApproval } from "@/server/services/run-approval-lifecycle"; import { getRunsConnectorForAgent, resolveRunEngine } from "@/server/services/dispatch/registry"; import { FORGE_RUN_CONTRACT_VERSION } from "@/server/services/engagement-mode"; import { buildRuntimePolicySnapshot } from "@/lib/runtime-enforcement"; @@ -110,9 +111,9 @@ export const agentRunRouter = router({ workspaceId: ctx.workspaceId, deletedAt: null, }, - select: { assignedAgentId: true }, + select: { id: true }, }); - if (!issue?.assignedAgentId) return null; + if (!issue) return null; // Treat WAITING runs as "still in flight" for the live strip so a // patient agent doesn't disappear from the issue page just because // it self-blocked. The strip itself renders WAITING with a soft @@ -121,10 +122,19 @@ export const agentRunRouter = router({ where: { workspaceId: ctx.workspaceId, issueId: input.issueId, - agentId: issue.assignedAgentId, status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, }, - orderBy: { lastEventAt: "desc" }, + // The run is the source of truth for who is doing the work. Issue + // assignment may be cleared or changed while a connector run is + // still paused, so never hide operator approvals behind the current + // `assignedAgentId`. Prefer approvals, then explicit WAITING runs, + // then the freshest remaining run. + orderBy: [ + { awaitingApprovalAt: { sort: "desc", nulls: "last" } }, + { status: "desc" }, + { lastEventAt: "desc" }, + { startedAt: "desc" }, + ], include: { agent: { select: { @@ -1257,23 +1267,15 @@ export const agentRunRouter = router({ }); } await ctx.db.$transaction(async (tx) => { - await tx.agentRun.update({ - where: { id: run.id }, - data: { - awaitingApprovalAt: null, - pendingApproval: Prisma.DbNull, - lastEventAt: new Date(), - }, - }); - await appendRunEvent(tx, { + await resolveRunApproval(tx, { runId: run.id, workspaceId: ctx.workspaceId, issueId: run.issueId, agentId: run.agentId, - kind: "STEP", + source: "operator", + decision: input.scope, currentStep: input.scope === "session" ? "approved (session) · resuming" : "approved · resuming", - payload: { approval: input.scope }, }); }); return { ok: true as const, decision: "approve" as const }; @@ -1293,9 +1295,14 @@ export const agentRunRouter = router({ }); } await ctx.db.$transaction(async (tx) => { - await tx.agentRun.update({ - where: { id: run.id }, - data: { awaitingApprovalAt: null, pendingApproval: Prisma.DbNull }, + await resolveRunApproval(tx, { + runId: run.id, + workspaceId: ctx.workspaceId, + issueId: run.issueId, + agentId: run.agentId, + source: "operator", + decision: "reject", + currentStep: "approval rejected · stopping", }); await finishRun(tx, { runId: run.id, diff --git a/src/server/routers/command-center.ts b/src/server/routers/command-center.ts index b9466f07..1acdcf54 100644 --- a/src/server/routers/command-center.ts +++ b/src/server/routers/command-center.ts @@ -117,9 +117,12 @@ export const commandCenterRouter = router({ ctx.db.agentRun.findMany({ where: { workspaceId: ctx.workspaceId, - status: AgentRunStatus.ACTIVE, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, }, - orderBy: { lastEventAt: "desc" }, + orderBy: [ + { awaitingApprovalAt: { sort: "desc", nulls: "last" } }, + { lastEventAt: "desc" }, + ], take: input.limit, include: { agent: { select: { id: true, name: true, profileKey: true, avatar: true } }, @@ -229,6 +232,13 @@ export const commandCenterRouter = router({ diagnostics: item.diagnostics, })); + // Runtime permission pauses are durable human decisions, not merely + // live-run metadata. Keep them out of the generic active-run module and + // return them as a first-class Command Center attention group so the + // command/reason and approval choices can be rendered inline. + 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); @@ -236,7 +246,8 @@ export const commandCenterRouter = router({ return { actionRequests: groupedActionRequests, reviewGates, - activeRuns, + activeRuns: visibleActiveRuns, + runtimeApprovals, stalledRuns, runRecoveryCounts: runRecovery.counts, recentArtifacts, @@ -246,7 +257,8 @@ export const commandCenterRouter = router({ counts: { actionRequests: groupedActionRequests.length, reviewGates: reviewGates.length, - activeRuns: activeRuns.length, + activeRuns: visibleActiveRuns.length, + runtimeApprovals: runtimeApprovals.length, stalledRuns: runRecovery.counts.total, recentArtifacts: recentArtifacts.length, dueIssues: dueIssues.length, @@ -263,14 +275,26 @@ export const commandCenterRouter = router({ */ decisionsCount: workspaceProcedure.query(async ({ ctx }) => { const userId = ctx.session?.user?.id ?? null; - const [actionRequests, reviewGates] = await Promise.all([ + const [actionRequests, reviewGates, runtimeApprovals] = await Promise.all([ userId ? ctx.db.actionRequest.count({ where: decisionAskWhere(ctx.workspaceId, userId) }) : Promise.resolve(0), ctx.db.reviewGate.count({ where: { workspaceId: ctx.workspaceId, status: ReviewGateStatus.PENDING }, }), + ctx.db.agentRun.count({ + where: { + workspaceId: ctx.workspaceId, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + awaitingApprovalAt: { not: null }, + }, + }), ]); - return { actionRequests, reviewGates, total: actionRequests + reviewGates }; + return { + actionRequests, + reviewGates, + runtimeApprovals, + total: actionRequests + reviewGates + runtimeApprovals, + }; }), }); diff --git a/src/server/routers/event.ts b/src/server/routers/event.ts index 8a5babc8..0b23cbaa 100644 --- a/src/server/routers/event.ts +++ b/src/server/routers/event.ts @@ -465,6 +465,7 @@ export const eventRouter = router({ const agentRuns = runs.filter((r) => r.agentId === agent.id); const blockers = recovery.items.filter((r) => r.run.agentId === agent.id); const approvals = agentRuns.filter((r) => r.awaitingApprovalAt); + const ordinaryRuns = agentRuns.filter((r) => !r.awaitingApprovalAt); const items: AgentAttentionItem[] = []; for (const request of questions) { @@ -561,13 +562,13 @@ export const eventRouter = router({ reviewGates: gates.length, approvals: approvals.length, blocked: blockers.length, - activeRuns: agentRuns.length, + activeRuns: ordinaryRuns.length, total: questions.length + gates.length + approvals.length + blockers.length + - agentRuns.length, + ordinaryRuns.length, }, items: items.slice(0, input.itemLimit), }; diff --git a/src/server/routers/issue.ts b/src/server/routers/issue.ts index 8004b74f..5739da8d 100644 --- a/src/server/routers/issue.ts +++ b/src/server/routers/issue.ts @@ -787,18 +787,20 @@ export const issueRouter = router({ }, }); if (!issue) throw new TRPCError({ code: "NOT_FOUND" }); - const currentAgentRun = issue.assignedAgentId - ? await ctx.db.agentRun.findFirst({ - where: { - workspaceId: ctx.workspaceId, - issueId: issue.id, - agentId: issue.assignedAgentId, - status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, - }, - orderBy: [{ lastEventAt: "desc" }, { startedAt: "desc" }], - select: { id: true, status: true, lastEventAt: true }, - }) - : null; + const currentAgentRun = await ctx.db.agentRun.findFirst({ + where: { + workspaceId: ctx.workspaceId, + issueId: issue.id, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + }, + orderBy: [ + { awaitingApprovalAt: { sort: "desc", nulls: "last" } }, + { status: "desc" }, + { lastEventAt: "desc" }, + { startedAt: "desc" }, + ], + select: { id: true, status: true, lastEventAt: true }, + }); const runtimePreflight = runtimePreflightForIssue({ title: issue.title, description: issue.description, diff --git a/src/server/services/__tests__/agent-run-attention.test.ts b/src/server/services/__tests__/agent-run-attention.test.ts new file mode 100644 index 00000000..82e55482 --- /dev/null +++ b/src/server/services/__tests__/agent-run-attention.test.ts @@ -0,0 +1,106 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { AgentRunStatus } from "@prisma/client"; +import { agentRunRouter } from "@/server/routers/agent-run"; +import { listRunRecoveryItems } from "@/server/services/agent-run-recovery"; +import { + buildContext, + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function createAgent(workspaceId: string, suffix: string) { + return getPrisma().agent.create({ + data: { + workspaceId, + name: `Agent ${suffix}`, + profileKey: `attention-${suffix}-${Date.now()}`, + }, + }); +} + +describe("agent-run operator attention", () => { + it("does not misclassify an old approval pause as generic stale recovery", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RA" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { title: "Approval is the recovery action" }); + const agent = await createAgent(fixture.workspace.id, "approval"); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + awaitingApprovalAt: new Date(Date.now() - 30 * 60_000), + pendingApproval: { command: "pnpm install" }, + }, + }); + await prisma.$executeRawUnsafe( + `UPDATE "AgentRun" SET "lastEventAt" = $1 WHERE "id" = $2`, + new Date(Date.now() - 60 * 60_000), + run.id, + ); + + const recovery = await listRunRecoveryItems(prisma, { + workspaceId: fixture.workspace.id, + limit: 20, + }); + + expect(recovery.items.map((item) => item.id)).not.toContain(run.id); + expect(recovery.counts.activeStale).toBe(0); + }); + + it("returns an approval-first run on an unassigned issue", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RI" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { title: "Unassigned but blocked" }); + const approvalAgent = await createAgent(fixture.workspace.id, "blocked"); + const activeAgent = await createAgent(fixture.workspace.id, "active"); + const approvalRun = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: approvalAgent.id, + status: AgentRunStatus.ACTIVE, + currentStep: "needs temporary permission", + lastEventAt: new Date(Date.now() - 60_000), + awaitingApprovalAt: new Date(Date.now() - 60_000), + pendingApproval: { command: "git push" }, + }, + }); + await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: activeAgent.id, + status: AgentRunStatus.ACTIVE, + currentStep: "newer ordinary work", + lastEventAt: new Date(), + }, + }); + const caller = agentRunRouter.createCaller(await buildContext(fixture)); + + const visible = await caller.activeForIssue({ issueId: issue.id }); + + expect(visible).toMatchObject({ + id: approvalRun.id, + awaitingApprovalAt: expect.any(Date), + pendingApproval: { command: "git push" }, + agent: { id: approvalAgent.id }, + }); + }); +}); diff --git a/src/server/services/__tests__/agent-run.test.ts b/src/server/services/__tests__/agent-run.test.ts index a1dfeae8..cdd76113 100644 --- a/src/server/services/__tests__/agent-run.test.ts +++ b/src/server/services/__tests__/agent-run.test.ts @@ -16,6 +16,7 @@ import { type TestFixture, } from "@/server/routers/__tests__/helpers"; import { agentRunRouter } from "@/server/routers/agent-run"; +import { captureRunApproval, resolveRunApproval } from "@/server/services/run-approval-lifecycle"; const fixtures: TestFixture[] = []; @@ -222,6 +223,146 @@ describe("agent-run lifecycle", () => { expect(after.finishedAt).not.toBeNull(); }); + it("records one actionable approval lifecycle across competing producers", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "ARA" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const agent = await createAgent(fixture.workspace.id, "ara-a1"); + const issue = await createIssue(fixture); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + }, + }); + const approval = { + command: "rm -rf /tmp/checkout", + description: "delete in root path", + choices: ["once", "session", "deny"], + }; + + const captured = await prisma.$transaction((tx) => + captureRunApproval(tx, { + runId: run.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + approval, + source: "subscription", + }), + ); + const duplicateCapture = await prisma.$transaction((tx) => + captureRunApproval(tx, { + runId: run.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + approval: null, + source: "poll", + }), + ); + expect(captured).toBe(true); + expect(duplicateCapture).toBe(false); + + const blocked = await prisma.agentRunEvent.findMany({ + where: { runId: run.id, kind: "BLOCKED" }, + }); + expect(blocked).toHaveLength(1); + expect(blocked[0]?.payload).toMatchObject({ + issueId: issue.id, + reason: "runtime-approval-required", + approval, + }); + expect( + await prisma.activityEvent.count({ + where: { + workspaceId: fixture.workspace.id, + subjectId: run.id, + kind: EventKind.AGENT_RUN_BLOCKED, + }, + }), + ).toBe(1); + expect( + (await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } })).pendingApproval, + ).toMatchObject(approval); + + const pollFirstRun = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + }, + }); + await prisma.$transaction((tx) => + captureRunApproval(tx, { + runId: pollFirstRun.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + approval: null, + source: "poll", + }), + ); + await prisma.$transaction((tx) => + captureRunApproval(tx, { + runId: pollFirstRun.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + approval, + source: "subscription", + }), + ); + const pollFirstAfter = await prisma.agentRun.findUniqueOrThrow({ + where: { id: pollFirstRun.id }, + include: { events: true }, + }); + expect(pollFirstAfter.pendingApproval).toMatchObject(approval); + expect(pollFirstAfter.events.filter((event) => event.kind === "BLOCKED")).toHaveLength(1); + + const resolved = await prisma.$transaction((tx) => + resolveRunApproval(tx, { + runId: run.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + source: "operator", + decision: "session", + currentStep: "approved (session) · resuming", + }), + ); + const duplicateResolution = await prisma.$transaction((tx) => + resolveRunApproval(tx, { + runId: run.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + source: "subscription", + decision: "session", + currentStep: "approval resolved · resuming", + }), + ); + expect(resolved).toBe(true); + expect(duplicateResolution).toBe(false); + + const after = await prisma.agentRun.findUniqueOrThrow({ + where: { id: run.id }, + include: { events: { orderBy: { createdAt: "asc" } } }, + }); + expect(after.awaitingApprovalAt).toBeNull(); + expect(after.pendingApproval).toBeNull(); + expect(after.events.filter((event) => event.kind === "STEP")).toHaveLength(1); + expect(after.events.at(-1)?.payload).toMatchObject({ + issueId: issue.id, + approvalResolved: true, + source: "operator", + decision: "session", + }); + }); + it("rejects in-place engagement mode changes for active runs", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "ARL" }); fixtures.push(fixture); diff --git a/src/server/services/agent-run-recovery.ts b/src/server/services/agent-run-recovery.ts index 85420d6b..35082cca 100644 --- a/src/server/services/agent-run-recovery.ts +++ b/src/server/services/agent-run-recovery.ts @@ -180,7 +180,11 @@ export function classifyRunForRecovery( }; } - if ((run.status === "ACTIVE" || run.status === "WAITING") && idleMs >= STALE_RUN_MS) { + if ( + (run.status === "ACTIVE" || run.status === "WAITING") && + !run.awaitingApprovalAt && + idleMs >= STALE_RUN_MS + ) { const detail = warningDiagnostic?.description ?? (run.status === "WAITING" diff --git a/src/server/services/dispatch/run-dispatcher.ts b/src/server/services/dispatch/run-dispatcher.ts index e31fa260..183ba144 100644 --- a/src/server/services/dispatch/run-dispatcher.ts +++ b/src/server/services/dispatch/run-dispatcher.ts @@ -24,6 +24,11 @@ import { loadRunOrchestrationContext, } from "@/server/services/orchestration-context"; import { markExecutionStepRunning } from "@/server/services/execution-step-runtime"; +import { + captureRunApproval, + resolveRunApproval, + type PendingRunApproval, +} from "@/server/services/run-approval-lifecycle"; /** * Dispatch-via-runs ingestion (worker-hosted, poll-based). @@ -923,24 +928,21 @@ async function pollActiveRuns(): Promise { if (status.state === "waiting_for_approval") { // Transition into a blocked state (set the flag + a BLOCKED event) - // only once, so we don't spam the timeline while it waits. + // only once, so we don't spam the timeline while it waits. The + // conditional claim also arbitrates a race with the live subscription. if (!run.awaitingApprovalAt) { await db - .$transaction(async (tx) => { - await tx.agentRun.update({ - where: { id: run.id }, - data: { awaitingApprovalAt: new Date() }, - }); - await appendRunEvent(tx, { + .$transaction((tx) => + captureRunApproval(tx, { runId: run.id, workspaceId: run.workspaceId, issueId: run.issueId, agentId: run.agentId, - kind: "BLOCKED", - currentStep: "waiting for approval", - payload: { lastEvent: status.lastEvent ?? null }, - }); - }) + approval: null, + source: "poll", + lastEvent: status.lastEvent ?? null, + }), + ) .catch((err) => logger.warn({ err, runId: run.id }, "runs-dispatch: block update failed"), ); @@ -1177,31 +1179,38 @@ async function subscribeRun(run: { "thinking step", ); break; - case "approval_required": - // Capture the command + set the block (whichever of poll/sub - // is first wins via the awaitingApprovalAt guard). + // Capture the command + set the block (whichever of poll/sub is + // first wins via the conditional transaction claim). Persist detail + // and append BLOCKED together so all surfaces agree. + case "approval_required": { + const approval: PendingRunApproval = { + command: typeof e.raw.command === "string" ? e.raw.command : null, + description: typeof e.raw.description === "string" ? e.raw.description : null, + choices: e.choices, + }; fireDb( - db.agentRun.updateMany({ - where: { id: run.id, awaitingApprovalAt: null }, - data: { - awaitingApprovalAt: new Date(), - pendingApproval: { - command: typeof e.raw.command === "string" ? e.raw.command : null, - description: typeof e.raw.description === "string" ? e.raw.description : null, - choices: e.choices, - }, - }, - }), + db.$transaction((tx) => + captureRunApproval(tx, { + ...base, + approval, + source: "subscription", + }), + ), run.id, "approval capture", ); break; + } case "approval_resolved": fireDb( - db.agentRun.update({ - where: { id: run.id }, - data: { awaitingApprovalAt: null, pendingApproval: Prisma.DbNull }, - }), + db.$transaction((tx) => + resolveRunApproval(tx, { + ...base, + source: "subscription", + decision: e.choice ?? null, + currentStep: "approval resolved · resuming", + }), + ), run.id, "approval clear", ); diff --git a/src/server/services/run-approval-lifecycle.ts b/src/server/services/run-approval-lifecycle.ts new file mode 100644 index 00000000..f4c08085 --- /dev/null +++ b/src/server/services/run-approval-lifecycle.ts @@ -0,0 +1,131 @@ +import "server-only"; + +import { EventKind, Prisma } from "@prisma/client"; +import { recordChange } from "@/server/audit"; +import { appendRunEvent } from "@/server/services/agent-run"; + +type Tx = Prisma.TransactionClient; + +export type PendingRunApproval = { + command: string | null; + description: string | null; + choices: string[]; +}; + +/** Claim and persist a runtime approval exactly once. */ +export async function captureRunApproval( + tx: Tx, + params: { + runId: string; + workspaceId: string; + issueId: string; + agentId: string; + approval: PendingRunApproval | null; + source: "poll" | "subscription"; + lastEvent?: string | null; + }, +): Promise { + const claimed = await tx.agentRun.updateMany({ + where: { id: params.runId, awaitingApprovalAt: null }, + data: { + awaitingApprovalAt: new Date(), + ...(params.approval ? { pendingApproval: params.approval } : {}), + }, + }); + if (claimed.count === 0) { + // The poller may have won with only a generic "waiting" state before + // the subscription delivered the command. Enrich the canonical row but + // do not append a second BLOCKED event. + if (params.approval) { + await tx.agentRun.updateMany({ + where: { + id: params.runId, + awaitingApprovalAt: { not: null }, + pendingApproval: { equals: Prisma.DbNull }, + }, + data: { pendingApproval: params.approval }, + }); + } + return false; + } + + await appendRunEvent(tx, { + runId: params.runId, + workspaceId: params.workspaceId, + issueId: params.issueId, + agentId: params.agentId, + kind: "BLOCKED", + currentStep: "waiting for approval", + payload: { + issueId: params.issueId, + reason: "runtime-approval-required", + source: params.source, + approval: params.approval, + lastEvent: params.lastEvent ?? null, + }, + }); + await recordChange(tx, { + workspaceId: params.workspaceId, + actorId: null, + actorAgentId: null, + entity: "AgentRun", + entityId: params.runId, + action: "runtime-approval-required", + after: { + awaitingApproval: true, + pendingApproval: params.approval, + }, + eventKind: EventKind.AGENT_RUN_BLOCKED, + subjectType: "agent-run", + subjectId: params.runId, + payload: { + runId: params.runId, + issueId: params.issueId, + agentId: params.agentId, + reason: params.approval?.description ?? "Runtime approval required", + command: params.approval?.command ?? null, + choices: params.approval?.choices ?? [], + source: params.source, + }, + }); + return true; +} + +/** + * Clear a pending approval exactly once. Provider callbacks and the human + * response mutation can race; only the winner publishes the resolution. + */ +export async function resolveRunApproval( + tx: Tx, + params: { + runId: string; + workspaceId: string; + issueId: string; + agentId: string; + source: "subscription" | "operator"; + decision?: string | null; + currentStep: string; + }, +): Promise { + const claimed = await tx.agentRun.updateMany({ + where: { id: params.runId, awaitingApprovalAt: { not: null } }, + data: { awaitingApprovalAt: null, pendingApproval: Prisma.DbNull }, + }); + if (claimed.count === 0) return false; + + await appendRunEvent(tx, { + runId: params.runId, + workspaceId: params.workspaceId, + issueId: params.issueId, + agentId: params.agentId, + kind: "STEP", + currentStep: params.currentStep, + payload: { + issueId: params.issueId, + approvalResolved: true, + source: params.source, + decision: params.decision ?? null, + }, + }); + return true; +}