From 1e2228c9b349373ed2ef3528e64bca86f05c7862 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Wed, 22 Jul 2026 12:41:39 -0400 Subject: [PATCH] fix: reconcile agent lifecycle evidence (AXI-141) --- DEVLOG.md | 28 +++++ docs/agents/engagement-modes.md | 13 +- docs/engineering/work-management.md | 12 ++ .../migration.sql | 1 + prisma/schema.prisma | 5 + scripts/seed-lifecycle-lab.ts | 12 ++ .../mission-control/run-timeline.tsx | 19 ++- .../services/__tests__/agent-run.test.ts | 36 ++++++ .../github-relation-persistence.test.ts | 2 +- src/server/services/__tests__/mcp.test.ts | 99 +++++++++++++++ src/server/services/agent-run.ts | 33 ++++- .../services/dispatch/run-dispatcher.ts | 25 +++- src/server/services/github/resource-sync.ts | 9 +- src/server/services/mcp.ts | 116 +++++++++++++++--- .../services/run-execution-capability.ts | 20 +++ tests/lifecycle/lifecycle-journey.spec.ts | 18 ++- 16 files changed, 417 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/20260722160000_run_execution_capability/migration.sql create mode 100644 src/server/services/run-execution-capability.ts diff --git a/DEVLOG.md b/DEVLOG.md index 97a5b118..e50c8d92 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -14234,3 +14234,31 @@ named-scenario skip. On Windows, `pnpm ci:local` completed through Vitest but could not invoke Unix `flock` through `cmd.exe`; its exact locked payload was therefore run directly through Git-for-Windows Bash after the repository's port ownership guard, matching the documented Windows validation path. + +## 2026-07-22 — Truthful runtime replies, waits, and Delivery evidence (AXI-141) + +Reproduced AXI-129's contradictory state against production data: the Hermes +Relay implementation issue was Done and PR #238 was merged, but an inferred +GitHub webhook replay had downgraded Victor's explicit `IMPLEMENTS` relation to +`RELATES_TO`. The detached Delivery session then remained in progress. A new +managed-runtime discussion turn produced an answer through Forge MCP, but +`runs.complete` rejected the MCP transport as a different execution connection; +the fallback wait created activity without an Action Request or durable reply, +and a buffered progress event overwrote the useful waiting reason. + +Managed runtime dispatch now mints a random one-run completion capability, +stores only its SHA-256 digest, and includes the raw capability only in the +dispatched turn. `runs.complete` accepts a different MCP transport connection +only when that capability verifies against the owning managed-runtime run, then +clears it. Generic GitHub inference can no longer downgrade an established +semantic relation. Blocking waits now create one deduplicated issue-scoped +Action Request and automatically resolve it on resume or terminal completion. +Buffered trace events remain visible without replacing a parked run's reason. + +Live Trace keeps its compact default but stores bounded provider progress +summaries up to 4,000 characters and makes long entries individually +expandable. The UI labels these as progress detail rather than encouraging raw +reasoning output. Added integration coverage for delegated runtime completion, +durable final comments, wait-request deduplication/resolution, waiting-reason +preservation, and webhook relation precedence, plus lifecycle Playwright +coverage for collapsed/expanded progress detail. diff --git a/docs/agents/engagement-modes.md b/docs/agents/engagement-modes.md index 58bea939..bd9c8568 100644 --- a/docs/agents/engagement-modes.md +++ b/docs/agents/engagement-modes.md @@ -122,12 +122,23 @@ output started when real work begins, use rolling status comments only for meaningful operator-facing checkpoints, call `runs.setWaiting` when blocked, and finish with `runs.complete`. MCP comments and status metadata do not create an execution record implicitly; `comments.upsertStatus` requires an active run. +For managed runtimes, the dispatch also supplies a one-run completion +capability; pass it only to `runs.complete` so Forge can correlate the runtime's +MCP reporting transport without treating it as a separate execution owner. + +`runs.setWaiting({ blocking: true })` means a human can take a concrete action +that unblocks the run. Forge creates one deduplicated Action Request for that +run and resolves it automatically when work resumes or terminates. Do not park +infrastructure, connector, or bookkeeping failures as operator decisions; +retry or surface those as recovery failures instead. The run trace and the rolling status serve different audiences: - **Mechanical trace** — runtime lifecycle, tool starts/completions, tests, and other adapter events are appended automatically as `AgentRunEvent` rows. - Agents should not duplicate this telemetry in comments. + Agents should not duplicate this telemetry in comments. Bounded, + provider-supplied progress summaries can be expanded in Live Trace; raw + private reasoning is neither requested nor displayed. - **Semantic status** — `comments.upsertStatus` is a compact operator update at a phase change (for example Inspecting → Implementing → Verifying), after a material finding changes the plan, or before continuing a long phase. It diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md index cd523039..7562adeb 100644 --- a/docs/engineering/work-management.md +++ b/docs/engineering/work-management.md @@ -29,6 +29,14 @@ connection is primary; additional connections must explicitly join as a contributor or reviewer, or receive an audited handoff before changing the branch or advancing delivery state. +A managed runtime can use MCP as its reporting transport without becoming a +second delivery owner. Forge issues a random, run-scoped execution capability +to the dispatched turn and stores only its SHA-256 digest. The runtime passes +that capability to `runs.complete`; Forge accepts it only for the matching +managed-runtime run and clears it at terminal completion. A shared Agent profile +or API key is not sufficient proof, and the capability must never be copied to +comments, logs, or another run. + Agent connection ids are opaque stable identifiers. Callers and typed action payloads must not assume they are Prisma CUIDs: historical managed-runtime and webhook connections used deterministic `ac_*` ids. Data migrations may @@ -80,6 +88,10 @@ integration, scheduled stabilization, or an upstream convention. for delivered behavior or `FIXES` for closing/fixing semantics. Bare issue references are `RELATES_TO`; release assembly PRs use `RELEASES` to mean the release contains the implementation. Never use a generic link attachment. +- Provider inference may establish a generic `RELATES_TO` link, but it never + downgrades an explicit `IMPLEMENTS`, `FIXES`, `RELEASES`, `REVIEWS`, or + `SOURCE` relation. Reclassifying between semantic kinds remains an explicit + operator/agent action. - GitHub owns PR state, reviews, checks, mergeability, and merge state. Forge mirrors those facts into the delivery lifecycle. - Check aggregation counts suites that contain executable check runs. GitHub diff --git a/prisma/migrations/20260722160000_run_execution_capability/migration.sql b/prisma/migrations/20260722160000_run_execution_capability/migration.sql new file mode 100644 index 00000000..7fbee0d1 --- /dev/null +++ b/prisma/migrations/20260722160000_run_execution_capability/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "AgentRun" ADD COLUMN "executionCapabilityHash" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 89e9f27d..c63ac59b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3093,6 +3093,11 @@ model AgentRun { /// approve / stop the live run and correlate streamed `/events`. /// Null for webhook/completions-driven runs. externalRunId String? + /// SHA-256 digest of the one-run capability issued to a managed runtime. + /// The raw capability is injected only into that dispatched turn and lets + /// its MCP transport report completion without being mistaken for an + /// unrelated client that happens to use the same Agent profile/key. + executionCapabilityHash String? /// Effective runtime/tool policy snapshot captured at dispatch. Stored /// on the run so old cards remain truthful after Runtime.config changes. runtimePolicy Json? diff --git a/scripts/seed-lifecycle-lab.ts b/scripts/seed-lifecycle-lab.ts index 6ccf1b6f..d1a69d06 100644 --- a/scripts/seed-lifecycle-lab.ts +++ b/scripts/seed-lifecycle-lab.ts @@ -301,6 +301,18 @@ async function main() { outputStartedAt: ago(61), }, }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: workspace.id, + runId: waitingRun.id, + kind: "STEP", + payload: { + thinking: + "Forge confirmed the implementation evidence and completed checks. The remaining decision is about presentation: optimize the summary for quick Command Center scanning, preserve the full issue-history context, or show both through progressive disclosure. Full progress detail reached its verified end marker.", + }, + createdAt: ago(9), + }, + }); await prisma.agentRunEvent.create({ data: { workspaceId: workspace.id, diff --git a/src/components/mission-control/run-timeline.tsx b/src/components/mission-control/run-timeline.tsx index 507e0877..66c2d83c 100644 --- a/src/components/mission-control/run-timeline.tsx +++ b/src/components/mission-control/run-timeline.tsx @@ -10,6 +10,7 @@ import { X as XIcon, Hourglass, Activity, + ChevronDown, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -155,7 +156,7 @@ function eventTitle(evt: RunTimelineEvent): string { } return "tool started"; } - if (evt.kind === "STEP" && typeof payload?.thinking === "string") return "thinking"; + if (evt.kind === "STEP" && typeof payload?.thinking === "string") return "progress detail"; if (evt.kind === "STEP" && typeof payload?.currentStep === "string") { return humanizeEventName(payload.currentStep); } @@ -213,7 +214,21 @@ export function RunTimeline({ {relativeTime(evt.createdAt)} - {preview &&
{preview}
} + {preview && preview.length > 120 ? ( +
+ + + {preview} + + + +
+ {preview} +
+
+ ) : preview ? ( +
{preview}
+ ) : null} ); diff --git a/src/server/services/__tests__/agent-run.test.ts b/src/server/services/__tests__/agent-run.test.ts index a9e7d713..34962f71 100644 --- a/src/server/services/__tests__/agent-run.test.ts +++ b/src/server/services/__tests__/agent-run.test.ts @@ -407,6 +407,42 @@ describe("agent-run lifecycle", () => { expect(await prisma.agentRunEvent.count({ where: { runId: run.id } })).toBe(eventCount); }); + it("keeps the operator-facing waiting reason when buffered progress arrives", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "ARP" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const agent = await createAgent(fixture.workspace.id, "arp-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.WAITING, + currentStep: "Waiting for the operator to choose a release window", + }, + }); + + await prisma.$transaction((tx) => + appendRunEvent(tx, { + runId: run.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + kind: "STEP", + currentStep: "thinking", + payload: { thinking: "Buffered progress detail after the run parked." }, + }), + ); + + await expect( + prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + ).resolves.toMatchObject({ + currentStep: "Waiting for the operator to choose a release window", + }); + await expect(prisma.agentRunEvent.count({ where: { runId: run.id } })).resolves.toBe(1); + }); + it("records one actionable approval lifecycle across competing producers", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "ARA" }); fixtures.push(fixture); diff --git a/src/server/services/__tests__/github-relation-persistence.test.ts b/src/server/services/__tests__/github-relation-persistence.test.ts index a62c35ee..86ba03b9 100644 --- a/src/server/services/__tests__/github-relation-persistence.test.ts +++ b/src/server/services/__tests__/github-relation-persistence.test.ts @@ -169,7 +169,7 @@ describe("native GitHub relation persistence", () => { }, }, }), - ).resolves.toMatchObject({ kind: "RELATES_TO" }); + ).resolves.toMatchObject({ kind: "FIXES" }); }); it("preserves imported SOURCE identity when derived relations are replayed", async () => { diff --git a/src/server/services/__tests__/mcp.test.ts b/src/server/services/__tests__/mcp.test.ts index 3b147964..d520274a 100644 --- a/src/server/services/__tests__/mcp.test.ts +++ b/src/server/services/__tests__/mcp.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, afterAll, afterEach } from "vitest"; import { AgentRunStatus, + AgentConnectionKind, + AgentConnectionLiveness, AgentProvider, ChatContextMode, CompletionAutomation, @@ -11,6 +13,10 @@ import { } from "@prisma/client"; import { mcpTools, type McpContext } from "@/server/services/mcp"; import { EXPLICIT_MCP_TOOL_POLICIES, mcpToolPolicy } from "@/server/services/mcp-policy"; +import { + hashRunExecutionCapability, + issueRunExecutionCapability, +} from "@/server/services/run-execution-capability"; import type { ApiKeyContext } from "@/server/services/api-key-auth"; import { createOrchestrationContextSnapshot, @@ -3989,6 +3995,78 @@ describe("mcp artifacts.*", () => { }); describe("mcp runs.complete + completion contract", () => { + it("accepts a managed run's one-run capability from its MCP transport", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "CCR" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const { ctx } = buildMcpCtx(fixture); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `ccr-${Date.now()}`, + name: "Runtime reporter", + }, + }); + const runtimeConnection = await prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: AgentConnectionKind.MANAGED_RUNTIME, + livenessModel: AgentConnectionLiveness.HEARTBEAT, + instanceKey: `runtime-${Date.now()}`, + }, + }); + const mcpConnection = await prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: AgentConnectionKind.MCP_CLIENT, + livenessModel: AgentConnectionLiveness.LEASE, + instanceKey: `mcp-${Date.now()}`, + }, + }); + const issue = await createIssue(fixture); + const capability = issueRunExecutionCapability(); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + connectionId: runtimeConnection.id, + executionCapabilityHash: capability.hash, + externalRunId: "provider-run-1", + status: AgentRunStatus.ACTIVE, + engagementMode: EngagementMode.DISCUSS, + }, + }); + const scopedCtx = { + ...ctx, + connectionId: mcpConnection.id, + apiKey: { ...ctx.apiKey!, linkedAgentId: agent.id }, + }; + + await expect( + call("runs.complete", { runId: run.id, summary: "The merge is healthy." }, scopedCtx), + ).rejects.toThrow(/different execution connection/); + await call( + "runs.complete", + { + runId: run.id, + summary: "The merge is healthy.", + executionCapability: capability.raw, + }, + scopedCtx, + ); + + const completed = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }); + expect(completed.status).toBe(AgentRunStatus.COMPLETED); + expect(completed.executionCapabilityHash).toBeNull(); + expect(hashRunExecutionCapability(capability.raw)).toBe(capability.hash); + await expect( + prisma.comment.count({ where: { issueId: issue.id, authoringAgentId: agent.id } }), + ).resolves.toBe(1); + }); + it("blocks issue-state mutations from non-EXECUTE active runs but allows comments", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "CC0" }); fixtures.push(fixture); @@ -4776,6 +4854,27 @@ describe("mcp runs.setWaiting + runs.resumeWork", () => { }, }), ).toBe(0); + const request = await prisma.actionRequest.findFirstOrThrow({ + where: { dedupeKey: `agent-run-waiting:${run.id}`, status: "OPEN" }, + }); + expect(request.issueId).toBe(issue.id); + expect(request.assignedUserId).toBe(fixture.user.id); + + await call( + "runs.setWaiting", + { runId: run.id, reason: "review needed", blocking: true }, + scopedCtx, + ); + await expect( + prisma.actionRequest.count({ + where: { dedupeKey: `agent-run-waiting:${run.id}`, status: "OPEN" }, + }), + ).resolves.toBe(1); + + await call("runs.resumeWork", { runId: run.id, currentStep: "review received" }, scopedCtx); + await expect( + prisma.actionRequest.findUniqueOrThrow({ where: { id: request.id } }), + ).resolves.toMatchObject({ status: "RESOLVED", resolution: "Run resumed." }); }); it("setWaiting without linkedAgentId is rejected", async () => { diff --git a/src/server/services/agent-run.ts b/src/server/services/agent-run.ts index fbef7d34..eee362f6 100644 --- a/src/server/services/agent-run.ts +++ b/src/server/services/agent-run.ts @@ -6,7 +6,7 @@ import type { RunEngine, LivenessConfidence, } from "@prisma/client"; -import { AgentRunStatus, EventKind, Prisma } from "@prisma/client"; +import { ActionRequestStatus, AgentRunStatus, EventKind, Prisma } from "@prisma/client"; import { recordChange } from "@/server/audit"; import { publish } from "@/server/realtime"; import { engagementSourceToEnum, type EngagementSource } from "@/server/services/engagement-mode"; @@ -455,11 +455,21 @@ export async function appendRunEvent( }, data: { lastEventAt: now, - ...(params.currentStep !== undefined ? { currentStep: params.currentStep } : {}), }, }); if (live.count === 0 && !params.allowTerminal) return; + // Once a run is deliberately parked, its reason is the operator-facing + // truth. Buffered provider progress may still arrive and belongs in the + // trace, but it must not replace that reason with a generic "thinking" or + // tool label. + if (live.count > 0 && params.currentStep !== undefined) { + await tx.agentRun.updateMany({ + where: { id: params.runId, status: AgentRunStatus.ACTIVE }, + data: { currentStep: params.currentStep }, + }); + } + if (live.count === 0) { const terminal = await tx.agentRun.findFirst({ where: { id: params.runId, workspaceId: params.workspaceId }, @@ -567,6 +577,7 @@ export async function finishRun( controlState: "NONE", controlRequestedAt: null, controlRequestedById: null, + executionCapabilityHash: null, }, }); if (claimed.count !== 1) { @@ -620,6 +631,24 @@ export async function finishRun( data: { completionMeta: completionMeta as Prisma.InputJsonValue }, }); + await tx.actionRequest.updateMany({ + where: { + workspaceId: params.workspaceId, + status: ActionRequestStatus.OPEN, + dedupeKey: `agent-run-waiting:${params.runId}`, + }, + data: { + status: ActionRequestStatus.RESOLVED, + resolvedAt: now, + resolution: + params.status === "COMPLETED" + ? "Run completed." + : params.status === "ABANDONED" + ? "Run abandoned." + : "Run stalled and moved to recovery.", + }, + }); + await tx.agentRunEvent.create({ data: { workspaceId: params.workspaceId, diff --git a/src/server/services/dispatch/run-dispatcher.ts b/src/server/services/dispatch/run-dispatcher.ts index f2d15580..02c82566 100644 --- a/src/server/services/dispatch/run-dispatcher.ts +++ b/src/server/services/dispatch/run-dispatcher.ts @@ -30,6 +30,7 @@ import { import { markExecutionStepRunning } from "@/server/services/execution-step-runtime"; import { upsertAgentConnection } from "@/server/services/agent-connection"; import { createActionRequest } from "@/server/services/action-request-service"; +import { issueRunExecutionCapability } from "@/server/services/run-execution-capability"; import { captureRunApproval, resolveRunApproval, @@ -101,6 +102,7 @@ function issueMessage( }, engagementInstruction?: string | null, runId?: string | null, + executionCapability?: string | null, ): string { const body = issue.description?.trim(); // Per-project repo hint: the runtime materializes each project's repo into @@ -115,12 +117,25 @@ function issueMessage( `Issue id: ${issue.id}.\n` + repoLine + (runId ? `Current AgentRun id: ${runId}.\n` : "") + + (executionCapability + ? `Run execution capability: ${executionCapability}. Pass it only as ` + + `executionCapability to runs.complete; never publish it in a comment or log.\n` + : "") + "\n" + (body ? `${body}\n\n` : "") + `Handle the issue according to the engagement mode and Forge run protocol above.` ); } +async function prepareRunExecutionCapability(runId: string): Promise { + const capability = issueRunExecutionCapability(); + await db.agentRun.update({ + where: { id: runId }, + data: { executionCapabilityHash: capability.hash }, + }); + return capability.raw; +} + /** Additional event-specific instructions that are not part of the issue row. */ export function dispatchTriggerContext( kind: string | null | undefined, @@ -596,6 +611,7 @@ async function startNewRuns(): Promise { }); try { + const executionCapability = already ? await prepareRunExecutionCapability(already.id) : null; const orchestrationContext = await loadRunOrchestrationContext(db, { workspaceId: evt.workspaceId, issueId: issue.id, @@ -616,6 +632,7 @@ async function startNewRuns(): Promise { }, instruction, already?.id ?? null, + executionCapability, ) + formatOrchestrationContextForPrompt(orchestrationContext) + runtimePolicyGrantContext(runtimePolicy), @@ -854,6 +871,7 @@ async function startUnbackedAgentRuns(limit: number): Promise { }); try { + const executionCapability = await prepareRunExecutionCapability(run.id); const triggerPayload = triggerEvent?.payload as Record | null; const orchestrationContext = await loadRunOrchestrationContext(db, { workspaceId: run.workspaceId, @@ -873,6 +891,7 @@ async function startUnbackedAgentRuns(limit: number): Promise { }, instruction, run.id, + executionCapability, ) + formatOrchestrationContextForPrompt(orchestrationContext) + operatorContext + @@ -1117,6 +1136,7 @@ async function resumeWaitingRuns(limit: number): Promise { executionStepId: run.executionStepId, snapshot: run.orchestrationContextSnapshot, }); + const executionCapability = await prepareRunExecutionCapability(run.id); const message = issueMessage( { @@ -1127,6 +1147,7 @@ async function resumeWaitingRuns(limit: number): Promise { }, instruction, run.id, + executionCapability, ) + formatOrchestrationContextForPrompt(orchestrationContext) + `\n\nYou paused this run earlier` + @@ -1575,7 +1596,9 @@ async function subscribeRun(run: { ...base, kind: "STEP", currentStep: "thinking", - payload: { thinking: e.text.slice(0, 280) }, + // Provider-supplied progress summaries are useful evidence, + // but remain bounded and are never copied into comments. + payload: { thinking: e.text.slice(0, 4_000) }, }), ), run.id, diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index ce1fb584..c9121a92 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -829,9 +829,12 @@ export async function linkExternalResourceToIssue( // Derived PR mentions must not erase the link used by future import de-dupe // and source-only title/status synchronization. if (existingLink?.kind === "SOURCE" && args.kind !== "SOURCE") return existingLink; - // Generic GitHub URL attachment and recovery paths use RELATES_TO. Replaying - // those paths must not erase a previously established native semantic link. - if (args.preserveExistingRelation && args.kind === "RELATES_TO" && existingLink) { + // RELATES_TO is an inferred/generic fallback and must never erase a stronger + // explicit semantic relation. This applies to webhook replays too: a PR body + // that no longer contains a recognizable keyword is not evidence that an + // operator/agent's prior IMPLEMENTS, FIXES, RELEASES, or REVIEWS link became + // generic. Explicit callers can still reclassify between semantic kinds. + if (args.kind === "RELATES_TO" && existingLink && existingLink.kind !== "RELATES_TO") { return existingLink; } diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 7d9e80e5..d954bce8 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { agentConnectionIdSchema } from "@/server/validators"; import { AgentProvider, + AgentConnectionKind, AgentRunStatus, AgentStatus, ApiKeyKind, @@ -18,6 +19,9 @@ import { ConnectorDeliveryStatus, CycleStatus, EventKind, + ActionRequestKind, + ActionRequestStatus, + NotificationSeverity, ExecutionPlanStatus, InitiativeStatus, NoteStatus, @@ -73,6 +77,7 @@ import { } from "@/server/services/run-budget"; import { logger } from "@/server/logger"; import { handoffCompletedRunToStep, maybeAutoJudge } from "@/server/services/orchestration-service"; +import { verifyRunExecutionCapability } from "@/server/services/run-execution-capability"; import { evaluateIssueCompletionCandidate } from "@/server/services/completion-candidate"; import { deliverWebhook } from "@/server/services/plugin-runtime"; import { buildChatContextBundle } from "@/server/services/chat-context"; @@ -7139,6 +7144,14 @@ export const mcpTools = { .describe( "Optional BODY comment already posted as this run's final response. When omitted, Forge creates an agent-authored final comment from summary so every completed run closes visibly in the issue thread.", ), + executionCapability: z + .string() + .min(20) + .max(120) + .optional() + .describe( + "One-run capability supplied only in a managed-runtime dispatch. Required when that runtime reports completion through its MCP transport connection.", + ), producedArtifactIds: z.array(z.string().cuid()).max(50).optional(), verificationResult: z .array( @@ -7176,6 +7189,7 @@ export const mcpTools = { runId: string; summary?: string; completionCommentId?: string; + executionCapability?: string; producedArtifactIds?: string[]; verificationResult?: Array<{ id?: string; @@ -7205,6 +7219,8 @@ export const mcpTools = { startedAt: true, engagementMode: true, connectionId: true, + executionCapabilityHash: true, + connection: { select: { kind: true } }, executionStepId: true, agent: { select: { profileKey: true, name: true } }, issue: { @@ -7220,7 +7236,13 @@ export const mcpTools = { if (run.agentId !== linkedAgentId) { throw new Error("AgentRun belongs to a different agent than the calling key."); } - if (ctx.connectionId && run.connectionId && ctx.connectionId !== run.connectionId) { + const sameConnection = + !ctx.connectionId || !run.connectionId || ctx.connectionId === run.connectionId; + const authorizedRuntimeTransport = + !sameConnection && + run.connection?.kind === AgentConnectionKind.MANAGED_RUNTIME && + verifyRunExecutionCapability(input.executionCapability, run.executionCapabilityHash); + if (!sameConnection && !authorizedRuntimeTransport) { throw new Error("AgentRun belongs to a different execution connection."); } if (run.status !== AgentRunStatus.ACTIVE && run.status !== AgentRunStatus.WAITING) { @@ -7300,6 +7322,7 @@ export const mcpTools = { } data.completionMeta = completionMeta as Prisma.InputJsonValue; data.lifecycleConfidence = "CONFIRMED"; + data.executionCapabilityHash = null; const completed = await db.$transaction(async (tx) => { const updated = await tx.agentRun.updateMany({ where: { @@ -7450,7 +7473,7 @@ export const mcpTools = { .boolean() .default(false) .describe( - "When true, emit AGENT_RUN_BLOCKED for operator activity and notifications. It does not wake the parked agent.", + "Use only when a human decision or input can unblock the run. Creates one deduplicated operator ActionRequest and emits AGENT_RUN_BLOCKED; infrastructure/lifecycle errors should remain active for retry instead.", ), }), async run(input: { runId: string; reason?: string; blocking: boolean }, ctx: McpContext) { @@ -7460,7 +7483,19 @@ export const mcpTools = { } const run = await db.agentRun.findFirst({ where: { id: input.runId, workspaceId: ctx.workspaceId }, - select: { id: true, agentId: true, issueId: true, status: true }, + select: { + id: true, + agentId: true, + issueId: true, + status: true, + issue: { + select: { + authorId: true, + number: true, + workspace: { select: { key: true } }, + }, + }, + }, }); if (!run) throw new Error("AgentRun not found in this workspace."); if (run.agentId !== linkedAgentId) { @@ -7519,6 +7554,34 @@ export const mcpTools = { reason: reasonLabel, }, }); + const { createActionRequest } = await import("@/server/services/action-request-service"); + await createActionRequest(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.userId, + actorAgentId: linkedAgentId, + title: `${run.issue.workspace.key}-${run.issue.number} needs your input`, + body: reasonLabel, + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + assignedUserId: run.issue.authorId, + sourceType: "agent-run", + sourceId: run.id, + dedupeKey: `agent-run-waiting:${run.id}`, + issueId: run.issueId, + }); + } else { + await tx.actionRequest.updateMany({ + where: { + workspaceId: ctx.workspaceId, + status: ActionRequestStatus.OPEN, + dedupeKey: `agent-run-waiting:${run.id}`, + }, + data: { + status: ActionRequestStatus.RESOLVED, + resolvedAt: new Date(), + resolution: "The run is no longer waiting on operator input.", + }, + }); } return next; }); @@ -7561,22 +7624,37 @@ export const mcpTools = { // Re-arm run-budget enforcement: clear any breach marker so a // budget-paused run can't resume uncapped (no-op for normal waits). const rearmedMeta = clearBudgetMarkers(run.completionMeta); - return db.agentRun.update({ - where: { id: run.id }, - data: { - status: AgentRunStatus.ACTIVE, - lastEventAt: new Date(), - controlState: "NONE", - controlRequestedAt: null, - ...(rearmedMeta !== undefined ? { completionMeta: rearmedMeta } : {}), - ...(input.currentStep !== undefined ? { currentStep: input.currentStep } : {}), - }, - select: { - id: true, - status: true, - currentStep: true, - lastEventAt: true, - }, + return db.$transaction(async (tx) => { + const updated = await tx.agentRun.update({ + where: { id: run.id }, + data: { + status: AgentRunStatus.ACTIVE, + lastEventAt: new Date(), + controlState: "NONE", + controlRequestedAt: null, + ...(rearmedMeta !== undefined ? { completionMeta: rearmedMeta } : {}), + ...(input.currentStep !== undefined ? { currentStep: input.currentStep } : {}), + }, + select: { + id: true, + status: true, + currentStep: true, + lastEventAt: true, + }, + }); + await tx.actionRequest.updateMany({ + where: { + workspaceId: ctx.workspaceId, + status: ActionRequestStatus.OPEN, + dedupeKey: `agent-run-waiting:${run.id}`, + }, + data: { + status: ActionRequestStatus.RESOLVED, + resolvedAt: new Date(), + resolution: "Run resumed.", + }, + }); + return updated; }); }, }, diff --git a/src/server/services/run-execution-capability.ts b/src/server/services/run-execution-capability.ts new file mode 100644 index 00000000..2bbcb070 --- /dev/null +++ b/src/server/services/run-execution-capability.ts @@ -0,0 +1,20 @@ +import "server-only"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +const CAPABILITY_PREFIX = "frun_"; + +export function hashRunExecutionCapability(raw: string): string { + return createHash("sha256").update(raw, "utf8").digest("hex"); +} + +export function issueRunExecutionCapability(): { raw: string; hash: string } { + const raw = `${CAPABILITY_PREFIX}${randomBytes(32).toString("base64url")}`; + return { raw, hash: hashRunExecutionCapability(raw) }; +} + +export function verifyRunExecutionCapability(raw: string | null | undefined, hash: string | null) { + if (!raw || !hash) return false; + const supplied = Buffer.from(hashRunExecutionCapability(raw), "hex"); + const expected = Buffer.from(hash, "hex"); + return supplied.length === expected.length && timingSafeEqual(supplied, expected); +} diff --git a/tests/lifecycle/lifecycle-journey.spec.ts b/tests/lifecycle/lifecycle-journey.spec.ts index 2ea3157b..faf60324 100644 --- a/tests/lifecycle/lifecycle-journey.spec.ts +++ b/tests/lifecycle/lifecycle-journey.spec.ts @@ -37,8 +37,10 @@ test.describe("Issue lifecycle operator journey", () => { await expect(page.getByText("Asks", { exact: true })).toBeVisible(); await expect(page.getByText("Runtime approvals", { exact: true })).toBeVisible(); await expect(page.getByText("Review gates", { exact: true })).toBeVisible(); - await expect(page.getByText("Stalled runs", { exact: true })).toBeVisible(); - await expect(page.getByText("FRG-9003", { exact: true })).toBeVisible(); + await expect(page.getByText("Run recovery", { exact: true })).toBeVisible(); + await expect( + page.getByRole("link", { name: "@e2ebot FRG-9003 Running lifecycle regression tests" }), + ).toBeVisible(); await expect(page.getByText("Running lifecycle regression tests").first()).toBeVisible(); await capture(page, "01-command-center-desktop"); await recordAccessibility(page, "01-command-center-desktop"); @@ -56,6 +58,18 @@ test.describe("Issue lifecycle operator journey", () => { page.getByRole("heading", { name: "Lifecycle Lab · Waiting for user reply" }), ).toBeVisible(); await expect(page.getByLabel("Agent workstream")).toContainText(/Waiting on you/i); + await page.getByLabel("Agent workstream").getByRole("button").first().click(); + await expect(page.getByText("Live trace", { exact: true })).toBeVisible(); + const progressDetail = page.locator("details").filter({ + hasText: "Forge confirmed the implementation evidence", + }); + await expect(progressDetail).toBeVisible(); + await expect(progressDetail).not.toHaveAttribute("open", ""); + await progressDetail.locator("summary").click(); + await expect(progressDetail).toHaveAttribute("open", ""); + await expect(progressDetail).toContainText( + "Full progress detail reached its verified end marker.", + ); await page.reload(); await expect(page.getByLabel("Agent workstream")).toContainText(/Waiting on you/i); await capture(page, "02-waiting-issue-desktop");