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");