Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 12 additions & 1 deletion docs/agents/engagement-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/engineering/work-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "AgentRun" ADD COLUMN "executionCapabilityHash" TEXT;
5 changes: 5 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
12 changes: 12 additions & 0 deletions scripts/seed-lifecycle-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 17 additions & 2 deletions src/components/mission-control/run-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
X as XIcon,
Hourglass,
Activity,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -213,7 +214,21 @@ export function RunTimeline({
{relativeTime(evt.createdAt)}
</span>
</div>
{preview && <div className="text-meta truncate text-foreground/70">{preview}</div>}
{preview && preview.length > 120 ? (
<details className="group mt-0.5">
<summary className="focus-ring flex cursor-pointer list-none items-start gap-1 rounded-sm text-foreground/70 marker:hidden">
<span className="text-meta line-clamp-2 min-w-0 flex-1 break-words">
{preview}
</span>
<ChevronDown className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="text-meta mt-1 whitespace-pre-wrap break-words rounded-md border border-border/60 bg-background/50 px-2 py-1.5 text-foreground/80">
{preview}
</div>
</details>
) : preview ? (
<div className="text-meta break-words text-foreground/70">{preview}</div>
) : null}
</div>
</li>
);
Expand Down
36 changes: 36 additions & 0 deletions src/server/services/__tests__/agent-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
99 changes: 99 additions & 0 deletions src/server/services/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect, afterAll, afterEach } from "vitest";
import {
AgentRunStatus,
AgentConnectionKind,
AgentConnectionLiveness,
AgentProvider,
ChatContextMode,
CompletionAutomation,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down
33 changes: 31 additions & 2 deletions src/server/services/agent-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -567,6 +577,7 @@ export async function finishRun(
controlState: "NONE",
controlRequestedAt: null,
controlRequestedById: null,
executionCapabilityHash: null,
},
});
if (claimed.count !== 1) {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading