From eb8c0500badc72624e34fde2a190f5aa11809ad8 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 20 Jul 2026 10:46:00 -0400 Subject: [PATCH] fix: separate run provenance and lifecycle --- CHANGELOG.md | 12 ++ DEVLOG.md | 22 ++++ docs/engineering/work-management.md | 13 ++ docs/guide/settings.md | 9 +- docs/reference/mcp.md | 16 +-- package.json | 2 +- .../issue-detail/agent-run-strip.tsx | 39 +++--- .../issue-detail/work-coordination-panel.tsx | 14 ++- src/lib/run-provenance.ts | 90 ++++++++++++++ src/server/audit.ts | 115 +++++++++++++++++- src/server/routers/agent-run.ts | 29 +++-- src/server/routers/issue.ts | 8 ++ src/server/routers/workspace.ts | 33 +++-- .../__tests__/completion-candidate.test.ts | 67 ++++++++++ src/server/services/__tests__/mcp.test.ts | 112 +++++++++++++++++ src/server/services/mcp-instructions.ts | 2 +- src/server/services/mcp.ts | 29 ++++- tests/unit/run-provenance.test.ts | 60 +++++++++ 18 files changed, 616 insertions(+), 56 deletions(-) create mode 100644 src/lib/run-provenance.ts create mode 100644 tests/unit/run-provenance.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf8ce81..104662e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ The dashboard's **What's New** rail reads this file at request time entries. Keep entries terse — one line per item under each version date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. +## [2026-07-20] — v0.29.3 · Honest delivery provenance + +### Changed + +- **Workstream cards report recorded execution facts.** Agent Profile configuration, MCP or webhook transport, managed runtime execution, run state, and delivery-session state remain separate instead of inheriting a configured runtime onto direct MCP work. +- **Connection presence describes reachability, not work in progress.** Delivery badges say MCP connected, runtime reachable, or connection online and explain that presence never substitutes for an active AgentRun. + +### Fixed + +- **Explicit MCP execution advances issue lifecycle.** Opening an `EXECUTE` run applies the workspace's configured In Progress status, and successful completion applies In Review, with tenant-scoped audit and activity evidence for each server-side transition. +- **New workspaces receive complete lifecycle targets.** In Progress, In Review, and Done statuses are selected when a workspace is created, while non-execution modes remain status-neutral and Ready to Close continues to ignore transport presence. + ## [2026-07-20] — v0.29.2 · Reliable runtime handoff ### Changed diff --git a/DEVLOG.md b/DEVLOG.md index 179ab48a..36b60228 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,28 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-20 — AXI-137 delivery provenance and lifecycle truth + +Separated Agent Profile configuration from output-producing run provenance in +the issue Workstream. Direct Codex Desktop and other MCP activity now reports +its MCP connection without claiming the profile's configured managed runtime; +managed runtime execution is shown only when the run records both that +connection and an external run id. Delivery connection badges now describe +transport reachability and explicitly avoid presenting connection presence as +run state. + +Opening an explicit MCP `EXECUTE` run now applies the workspace's configured +In Progress status, and successful completion continues to apply In Review. +Both server-owned transitions write tenant-scoped issue audit and activity +evidence. Research, review, and discussion modes remain status-neutral. New +workspaces select their initial In Progress, In Review, and Done lifecycle +targets during creation; existing workspaces retain their settings until an +operator deliberately configures them. + +Added focused unit and integration coverage for honest provenance, lifecycle +auditing, non-execution modes, and the invariant that an active MCP connection +or Delivery session does not block Ready to Close without an active run. + ## 2026-07-20 — AXI-136 fast local verification and release workflow Replaced the Unix-only local CI shell chain with a cross-platform TypeScript diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md index 65029df9..cd523039 100644 --- a/docs/engineering/work-management.md +++ b/docs/engineering/work-management.md @@ -42,6 +42,12 @@ client rather than guessed as Codex Desktop or CLI. A runtime is shown only when an output-producing `AgentRun` carries both a runtime connection and an external run id. Direct MCP or UI work does not imply runtime execution. +Connection presence is endpoint reachability, not execution state. “MCP +connected,” “runtime reachable,” and similar badges can remain current after a +run has completed because the client is still connected. Ready to Close and +stale-run recovery use `AgentRun` lifecycle records, never connection presence +or an `IN_REVIEW` Delivery session, to decide whether work is still active. + ## Project branch contract Branch topology is project configuration, not an Axiom-wide constant. Every @@ -109,6 +115,13 @@ Claimed → In progress → PR open → In review → Ready to merge → Merged ownership lease. A stale session remains a lease and raises a shared action request; it must be resumed or abandoned before replacement work starts. +For direct MCP work, clients open an explicit run before implementation. +Opening an `EXECUTE` run moves a Backlog or Todo issue to the workspace's +configured In Progress status; successfully completing it moves the issue to +the configured In Review status. Research, review, and discussion runs do not +change issue status. These are server-side, audited transitions so clients do +not need to race a separate status mutation. + Release, deploy, and verification transitions require workspace admin authority. Feature work may run in parallel, but merges and production delivery are serialized. diff --git a/docs/guide/settings.md b/docs/guide/settings.md index ecd06627..c49c20fe 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -64,6 +64,11 @@ Workspace-scoped statuses, each in a fixed category (`BACKLOG`, `TODO`, - Reorder within a category. - Set the workspace default (used for fresh issues with no explicit status). +- Configure agent lifecycle targets for **In Progress**, **In Review**, and + **Done**. New workspaces select the first status in each matching category. + An explicit MCP `EXECUTE` run applies In Progress when it opens and In Review + when it completes; research, review, and discussion runs never move issue + status automatically. ### Templates @@ -144,7 +149,7 @@ content between instances as portable JSON. sprints, agents, and issues (with assignees, labels, and relations) plus comments. Infra rows — API keys, webhooks, the audit log, and attachment bytes — are excluded. -- **Import** loads a snapshot into the current workspace. It is *additive*: +- **Import** loads a snapshot into the current workspace. It is _additive_: config rows are matched by natural key and reused, issues are created fresh with new numbers, relations and comments are rewired onto them, and unknown authors fall back to you. Nothing is deleted. @@ -221,7 +226,7 @@ See [Automation → API keys](/automation/api-keys.html). URL: `/settings/auth`. **Instance-admin only** — gated on the operator whose email matches `ADMIN_EMAIL`, since sign-in providers are global to -the whole self-hosted instance (auth is per *user*, not per workspace). +the whole self-hosted instance (auth is per _user_, not per workspace). Configure how people sign in, without a redeploy: diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 8636c155..aded516e 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -498,14 +498,14 @@ heartbeat returns a missing-row error. `recordUsage` requires `WRITE_ISSUES` and an agent-linked key whose `linkedAgentId` matches the run's `agentId`. `list` requires `READ_ISSUES`. -| Tool | Summary | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` | Open (or resume) a tracked run on an issue **you** are working — `{ issueId, summary?, mode? }`. Requires an agent-linked key; the run belongs to the key's `linkedAgentId`. Resumes an existing ACTIVE/WAITING run for the `(issue, agent)` pair instead of stacking a duplicate, and stamps a STARTED event so it shows in Mission Control. Lets an agent (e.g. a local CLI session) proactively self-report work rather than only getting a run as a dispatch side-effect. Issue-anchored; drive it afterward with `recordUsage` / `setWaiting` / `complete`. Scope `WRITE_ISSUES`. | -| `recordUsage` | Update token + cost columns on an `AgentRun`. `{ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? }`. Idempotent — latest call replaces (cumulative as reported by the agent). | -| `complete` | Close an active/waiting run. `{ runId, summary, producedArtifactIds?, verificationResult?, followUps?, confidence?, verdict? }`. Requires an agent-linked key matching the run. Execute enforces issue artifact/checklist gates; Research requires `confidence`; Review requires `verdict`; Discuss is reply-only. Stores `completionMeta` with the Forge run contract version. | -| `setWaiting` / `resumeWork` | Mark a run blocked on the operator, or resume it. Requires an agent-linked key matching the run. | -| `list` | List `AgentRun` rows for "my recent history" introspection. `{ agentId?, issueId?, status?, limit? = 50, before? }` → newest-first by `startedAt`. Each row includes scalars (status, currentStep, startedAt, finishedAt, lastEventAt, tokensIn, tokensOut, tokensCached, costUsd) plus `issue { id, number, title, workspace: { key } }` and `agent { id, profileKey }`. | -| `kick` | Operator-driven nudge for a stalled run. `{ runId }`. Re-fires the dispatch webhook for the underlying issue without changing assignment or `controlState`. Only kicks an `ACTIVE` run that's been quiet 5+ minutes; younger runs return `{ ok: true, kicked: false }`. Records `AGENT_RUN_KICKED`. Scope: `WRITE_ISSUES`. | +| Tool | Summary | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `open` | Open (or resume) a tracked run on an issue **you** are working — `{ issueId, summary?, mode? }`. Requires an agent-linked key; the run belongs to the key's `linkedAgentId`. Resumes an existing ACTIVE/WAITING run for the `(issue, agent)` pair instead of stacking a duplicate, and stamps a STARTED event so it shows in Mission Control. An `EXECUTE` run also applies the workspace's configured In Progress status with audit evidence; `RESEARCH`, `REVIEW`, and `DISCUSS` do not change issue status. Lets an agent (e.g. a local CLI session) proactively self-report work rather than only getting a run as a dispatch side-effect. Issue-anchored; drive it afterward with `recordUsage` / `setWaiting` / `complete`. Scope `WRITE_ISSUES`. | +| `recordUsage` | Update token + cost columns on an `AgentRun`. `{ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? }`. Idempotent — latest call replaces (cumulative as reported by the agent). | +| `complete` | Close an active/waiting run. `{ runId, summary, producedArtifactIds?, verificationResult?, followUps?, confidence?, verdict? }`. Requires an agent-linked key matching the run. Execute enforces issue artifact/checklist gates and applies the configured In Review status after successful completion with audit evidence; Research requires `confidence`; Review requires `verdict`; Discuss is reply-only. Stores `completionMeta` with the Forge run contract version. | +| `setWaiting` / `resumeWork` | Mark a run blocked on the operator, or resume it. Requires an agent-linked key matching the run. | +| `list` | List `AgentRun` rows for "my recent history" introspection. `{ agentId?, issueId?, status?, limit? = 50, before? }` → newest-first by `startedAt`. Each row includes scalars (status, currentStep, startedAt, finishedAt, lastEventAt, tokensIn, tokensOut, tokensCached, costUsd) plus `issue { id, number, title, workspace: { key } }` and `agent { id, profileKey }`. | +| `kick` | Operator-driven nudge for a stalled run. `{ runId }`. Re-fires the dispatch webhook for the underlying issue without changing assignment or `controlState`. Only kicks an `ACTIVE` run that's been quiet 5+ minutes; younger runs return `{ ok: true, kicked: false }`. Records `AGENT_RUN_KICKED`. Scope: `WRITE_ISSUES`. | `costUsd` is taken verbatim from the agent for v1; a server-side rate table per model is a future enhancement. diff --git a/package.json b/package.json index e57fa59e..3b5b5e56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.29.2", + "version": "0.29.3", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", diff --git a/src/components/issue-detail/agent-run-strip.tsx b/src/components/issue-detail/agent-run-strip.tsx index f80a0d60..c08798ce 100644 --- a/src/components/issue-detail/agent-run-strip.tsx +++ b/src/components/issue-detail/agent-run-strip.tsx @@ -33,6 +33,7 @@ import { useRealtime, useRealtimeConnection } from "@/hooks/use-realtime"; import { useWorkspace } from "@/hooks/use-workspace"; import type { RuntimePolicySnapshot } from "@/lib/runtime-enforcement"; import type { RuntimeToolCapability } from "@/lib/runtime-tools"; +import { workstreamRunProvenance } from "@/lib/run-provenance"; import { trpc } from "@/lib/trpc"; import { presenceAvailability } from "@/lib/transport-display"; import { cn, relativeTime } from "@/lib/utils"; @@ -67,6 +68,13 @@ export type WorkstreamLatestRun = { summary?: string | null; engagementMode?: string | null; runtimePolicy?: unknown; + externalRunId?: string | null; + connection?: { + kind: "MCP_CLIENT" | "MANAGED_RUNTIME" | "WEBHOOK" | "ON_DEMAND"; + displayName?: string | null; + clientName?: string | null; + runtime?: { name?: string | null } | null; + } | null; agent: WorkstreamAgent; }; @@ -263,7 +271,7 @@ export function AgentRunStrip({
{mode ? : null} - {displayAgent ? : null} + {displayAgent ? : null} @@ -546,34 +554,31 @@ function WorkModeBadge({ mode }: { mode: EngagementModeValue }) { ); } -function RuntimeBadge({ +function ExecutionProvenanceBadge({ agent, - policy, + run, }: { agent: WorkstreamAgent; - policy: RuntimePolicySnapshot | null; + run: WorkstreamLatestRun | null; }) { - const runtimeName = - policy?.runtimeName ?? agent.runtime?.name ?? providerLabel(agent.provider) ?? "Unattached"; - const persistence = agent.runtimeMode === "EPHEMERAL" ? "Session" : "Persistent"; - const configured = Boolean( - agent.runtimeId || agent.webhookUrl || agent.runtime || agent.provider, - ); + const configuredRuntimeName = + agent.runtime?.name ?? providerLabel(agent.provider) ?? (agent.runtimeId ? "Runtime" : null); + const provenance = workstreamRunProvenance({ + run, + configuredRuntimeName, + configuredRuntimeMode: agent.runtimeMode, + }); return ( - Runs on {runtimeName} · {persistence} + {provenance.label} ); } diff --git a/src/components/issue-detail/work-coordination-panel.tsx b/src/components/issue-detail/work-coordination-panel.tsx index b698461c..909ec1e0 100644 --- a/src/components/issue-detail/work-coordination-panel.tsx +++ b/src/components/issue-detail/work-coordination-panel.tsx @@ -555,10 +555,14 @@ function deliveryProvenance(session: DeliverySession): { const statusLabel = connection.status === "ACTIVE" ? connection.confidence === "CONFIRMED" - ? "confirmed active" - : "activity inferred" + ? connection.kind === "MCP_CLIENT" + ? "MCP connected" + : connection.kind === "MANAGED_RUNTIME" + ? "runtime reachable" + : "connection online" + : "connection recently seen" : connection.status === "QUIET" && unconfirmedMcp - ? "quiet · status unconfirmed" + ? "MCP presence unconfirmed" : connection.status.toLowerCase(); const badgeTone = connection.status === "DISCONNECTED" || connection.status === "REVOKED" @@ -570,7 +574,9 @@ function deliveryProvenance(session: DeliverySession): { : "border-border bg-subtle/60 text-muted-foreground"; const statusDescription = connection.status === "ACTIVE" && connection.confidence === "CONFIRMED" - ? "Forge has direct, current evidence that this delivery connection is active." + ? connection.kind === "MCP_CLIENT" + ? "Forge has direct, current evidence that this MCP transport is connected. This is connection presence, not evidence that an AgentRun is still working." + : "Forge has direct, current evidence that this delivery connection is reachable. This is connection presence, not AgentRun state." : connection.status === "ACTIVE" ? "Recent activity was observed, but live connection presence is not fully confirmed." : connection.status === "QUIET" && unconfirmedMcp diff --git a/src/lib/run-provenance.ts b/src/lib/run-provenance.ts new file mode 100644 index 00000000..ee056442 --- /dev/null +++ b/src/lib/run-provenance.ts @@ -0,0 +1,90 @@ +export type WorkstreamRunConnection = { + kind: "MCP_CLIENT" | "MANAGED_RUNTIME" | "WEBHOOK" | "ON_DEMAND"; + displayName?: string | null; + clientName?: string | null; + runtime?: { name?: string | null } | null; +}; + +export type WorkstreamRunProvenance = { + label: string; + description: string; + recorded: boolean; +}; + +function connectionName(connection: WorkstreamRunConnection): string | null { + return connection.displayName ?? connection.clientName ?? connection.runtime?.name ?? null; +} + +export function workstreamRunProvenance(input: { + run?: { + externalRunId?: string | null; + connection?: WorkstreamRunConnection | null; + runtimePolicy?: unknown; + } | null; + configuredRuntimeName?: string | null; + configuredRuntimeMode?: string | null; +}): WorkstreamRunProvenance { + const connection = input.run?.connection; + if (connection) { + const name = connectionName(connection); + if ( + connection.kind === "MANAGED_RUNTIME" && + input.run?.externalRunId && + connection.runtime?.name + ) { + return { + label: `Ran on ${connection.runtime.name}`, + description: + "This managed runtime is recorded on the output-producing run with an external run id.", + recorded: true, + }; + } + if (connection.kind === "MCP_CLIENT") { + return { + label: `Via MCP${name ? ` · ${name}` : ""}`, + description: + "This run was opened and updated through an MCP client. No managed runtime execution is claimed.", + recorded: true, + }; + } + if (connection.kind === "WEBHOOK") { + return { + label: `Via webhook${name ? ` · ${name}` : ""}`, + description: + "This run is attributed to a webhook connection. A managed runtime is shown only when its execution is recorded separately.", + recorded: true, + }; + } + return { + label: `Via on-demand connection${name ? ` · ${name}` : ""}`, + description: + "This run is attributed to an on-demand connection. A managed runtime is shown only when its execution is recorded separately.", + recorded: true, + }; + } + + if (input.run) { + return { + label: "Execution source not recorded", + description: + "This historical run has no concrete connection provenance. Forge will not infer execution from the Agent Profile configuration.", + recorded: false, + }; + } + + if (input.configuredRuntimeName) { + const lifetime = input.configuredRuntimeMode === "EPHEMERAL" ? "session" : "persistent"; + return { + label: `Configured for ${input.configuredRuntimeName} · ${lifetime}`, + description: + "This is the Agent Profile's configured execution target, not evidence that a run executed there.", + recorded: false, + }; + } + + return { + label: "No execution target configured", + description: "No run connection or Agent Profile execution target is available.", + recorded: false, + }; +} diff --git a/src/server/audit.ts b/src/server/audit.ts index d901d52d..644b12ad 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -321,6 +321,85 @@ async function maybeAutoTransitionOnAssign( return target.id; } +/** + * Server-side issue lifecycle for an agent-initiated run. Unlike assignment, + * opening an MCP EXECUTE run is itself direct evidence that work began, so it + * uses the configured started status and writes a dedicated issue audit/event. + * Non-EXECUTE modes never move the issue. + */ +export async function maybeAutoTransitionOnRunStart( + tx: PrismaClient | Prisma.TransactionClient, + params: { + workspaceId: string; + issueId: string; + runId: string; + engagementMode: string; + actorId: string | null; + actorAgentId?: string | null; + }, +): Promise { + if (params.engagementMode !== "EXECUTE") return null; + const workspace = await tx.workspace.findUnique({ + where: { id: params.workspaceId }, + select: { startedStatusId: true }, + }); + if (!workspace?.startedStatusId) return null; + + const [target, issue] = await Promise.all([ + tx.status.findFirst({ + where: { + id: workspace.startedStatusId, + workspaceId: params.workspaceId, + category: "IN_PROGRESS", + }, + select: { id: true }, + }), + tx.issue.findFirst({ + where: { id: params.issueId, workspaceId: params.workspaceId, deletedAt: null }, + select: { + id: true, + statusId: true, + startedAt: true, + status: { select: { id: true, name: true, category: true } }, + }, + }), + ]); + if (!target || !issue) return null; + if (["IN_PROGRESS", "IN_REVIEW", "DONE", "CANCELED"].includes(issue.status.category)) { + return null; + } + + const after = await tx.issue.update({ + where: { id: issue.id }, + data: { statusId: target.id, startedAt: issue.startedAt ?? new Date() }, + select: { + id: true, + statusId: true, + startedAt: true, + status: { select: { id: true, name: true, category: true } }, + }, + }); + await recordChange(tx, { + workspaceId: params.workspaceId, + actorId: params.actorId, + actorAgentId: params.actorAgentId ?? null, + entity: "Issue", + entityId: issue.id, + action: "run-start-auto-transition", + before: issue as unknown as Prisma.InputJsonValue, + after: after as unknown as Prisma.InputJsonValue, + eventKind: EventKind.ISSUE_STATUS_CHANGED, + subjectType: "issue", + subjectId: issue.id, + payload: { + source: "agent-run", + runId: params.runId, + statusId: target.id, + }, + }); + return target.id; +} + /** * Server-side auto-transition on a successful EXECUTE completion. When the * workspace has `reviewStatusId` set and the issue isn't already IN_REVIEW @@ -345,6 +424,11 @@ export async function maybeAutoTransitionOnComplete( workspaceId: string, issueId: string, engagementMode: string, + audit?: { + runId: string; + actorId: string | null; + actorAgentId?: string | null; + }, ): Promise { if (engagementMode !== "EXECUTE") return null; const ws = await tx.workspace.findUnique({ @@ -373,10 +457,39 @@ export async function maybeAutoTransitionOnComplete( } if (issue.statusId === target.id) return null; - await tx.issue.update({ + const before = await tx.issue.findUniqueOrThrow({ + where: { id: issueId }, + select: { + id: true, + statusId: true, + status: { select: { id: true, name: true, category: true } }, + }, + }); + const after = await tx.issue.update({ where: { id: issueId }, data: { statusId: target.id }, + select: { + id: true, + statusId: true, + status: { select: { id: true, name: true, category: true } }, + }, }); + if (audit) { + await recordChange(tx, { + workspaceId, + actorId: audit.actorId, + actorAgentId: audit.actorAgentId ?? null, + entity: "Issue", + entityId: issueId, + action: "run-complete-auto-transition", + before: before as unknown as Prisma.InputJsonValue, + after: after as unknown as Prisma.InputJsonValue, + eventKind: EventKind.ISSUE_STATUS_CHANGED, + subjectType: "issue", + subjectId: issueId, + payload: { source: "agent-run", runId: audit.runId, statusId: target.id }, + }); + } return target.id; } diff --git a/src/server/routers/agent-run.ts b/src/server/routers/agent-run.ts index d7e13894..6e1fcaae 100644 --- a/src/server/routers/agent-run.ts +++ b/src/server/routers/agent-run.ts @@ -43,6 +43,8 @@ type RunWithPolicyInputs = { followUps?: Prisma.JsonValue | null; completionMeta?: Prisma.JsonValue | null; runtimePolicy?: Prisma.JsonValue | null; + externalRunId?: string | null; + connection?: { kind: string } | null; issue?: { expectedOutput?: string | null; verificationChecklist?: Prisma.JsonValue | null; @@ -67,15 +69,20 @@ function enrichRun(run: T) { run.runtimePolicy && typeof run.runtimePolicy === "object" && !Array.isArray(run.runtimePolicy) ? run.runtimePolicy : null; + const canInferManagedPolicy = + Boolean(run.externalRunId) && (!run.connection || run.connection.kind === "MANAGED_RUNTIME"); const runtimePolicy = existingPolicy ?? - buildRuntimePolicySnapshot({ - contractVersion: FORGE_RUN_CONTRACT_VERSION, - engagementMode: run.engagementMode, - adapterKey: run.agent.runtime?.adapterKey ?? (run.agent.provider === "HERMES" ? "hermes" : null), - runtimeName: run.agent.runtime?.name ?? null, - config: run.agent.runtime?.config, - }); + (canInferManagedPolicy + ? buildRuntimePolicySnapshot({ + contractVersion: FORGE_RUN_CONTRACT_VERSION, + engagementMode: run.engagementMode, + adapterKey: + run.agent.runtime?.adapterKey ?? (run.agent.provider === "HERMES" ? "hermes" : null), + runtimeName: run.agent.runtime?.name ?? null, + config: run.agent.runtime?.config, + }) + : null); return { ...run, runtimePolicy, @@ -180,6 +187,14 @@ export const agentRunRouter = router({ statusComment: { select: { id: true, body: true, currentStep: true, updatedAt: true, revisions: true }, }, + connection: { + select: { + kind: true, + displayName: true, + clientName: true, + runtime: { select: { name: true } }, + }, + }, }, }); return run ? enrichRun(run) : null; diff --git a/src/server/routers/issue.ts b/src/server/routers/issue.ts index cfa5fddb..dcbbb24a 100644 --- a/src/server/routers/issue.ts +++ b/src/server/routers/issue.ts @@ -697,6 +697,14 @@ export const issueRouter = router({ followUps: true, completionMeta: true, runtimePolicy: true, + connection: { + select: { + kind: true, + displayName: true, + clientName: true, + runtime: { select: { name: true } }, + }, + }, wakeAttempts: true, lastWakeDeliveryId: true, agent: { diff --git a/src/server/routers/workspace.ts b/src/server/routers/workspace.ts index deb1a05e..99d2bcb4 100644 --- a/src/server/routers/workspace.ts +++ b/src/server/routers/workspace.ts @@ -323,17 +323,32 @@ export const workspaceRouter = router({ }), }, }); - const completionStatus = await ctx.db.status.findFirst({ - where: { workspaceId: workspace.id, category: "DONE" }, + const lifecycleStatuses = await ctx.db.status.findMany({ + where: { + workspaceId: workspace.id, + category: { in: ["IN_PROGRESS", "IN_REVIEW", "DONE"] }, + }, orderBy: [{ position: "asc" }, { id: "asc" }], - select: { id: true }, + select: { id: true, category: true }, }); - const configuredWorkspace = completionStatus - ? await ctx.db.workspace.update({ - where: { id: workspace.id }, - data: { completionStatusId: completionStatus.id }, - }) - : workspace; + const startedStatusId = lifecycleStatuses.find( + (status) => status.category === "IN_PROGRESS", + )?.id; + const reviewStatusId = lifecycleStatuses.find( + (status) => status.category === "IN_REVIEW", + )?.id; + const completionStatusId = lifecycleStatuses.find((status) => status.category === "DONE")?.id; + const configuredWorkspace = + startedStatusId || reviewStatusId || completionStatusId + ? await ctx.db.workspace.update({ + where: { id: workspace.id }, + data: { + ...(startedStatusId ? { startedStatusId } : {}), + ...(reviewStatusId ? { reviewStatusId } : {}), + ...(completionStatusId ? { completionStatusId } : {}), + }, + }) + : workspace; // Best-effort bucket create. If MinIO is unavailable we still return // the workspace — attachments simply won't work until ops fixes it. diff --git a/src/server/services/__tests__/completion-candidate.test.ts b/src/server/services/__tests__/completion-candidate.test.ts index f11b524a..f87e215e 100644 --- a/src/server/services/__tests__/completion-candidate.test.ts +++ b/src/server/services/__tests__/completion-candidate.test.ts @@ -399,4 +399,71 @@ describe("completion candidate policy", () => { }, }); }); + + it("does not treat a connected MCP delivery session as an active run", async () => { + const { fixture, prisma } = await setup(CompletionAutomation.RECOMMEND); + const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); + await linkedPullRequest(fixture, issue.id, "merged", "success"); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `mcp-presence-${Date.now()}`, + name: "Codex", + }, + }); + const connection = await prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + status: "ACTIVE", + confidence: "CONFIRMED", + instanceKey: `desktop-${Date.now()}`, + displayName: "Codex Desktop", + clientName: "codex-mcp-client", + lastSeenAt: new Date(), + }, + }); + await prisma.workSession.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + ownerUserId: fixture.user.id, + ownerAgentId: agent.id, + ownerConnectionId: connection.id, + source: "MCP", + status: "IN_REVIEW", + repoFullName: "acme/forge", + branch: `codex/mcp-presence-${Date.now()}`, + baseBranch: "main", + }, + }); + + const result = await evaluateIssueCompletionCandidate(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + actorId: fixture.user.id, + sourceType: "github-pull-request", + sourceId: "mcp-presence", + sourceLabel: "Merged implementation PR", + }); + + expect(result.outcome).toBe("RECOMMENDED"); + const request = await prisma.actionRequest.findFirstOrThrow({ + where: { issueId: issue.id, status: ActionRequestStatus.OPEN }, + }); + expect(request.payload).toMatchObject({ + assessment: { + state: "READY", + facts: expect.arrayContaining([ + expect.objectContaining({ + key: "agent-runs", + summary: "No active runs", + status: "PASS", + }), + ]), + }, + }); + }); }); diff --git a/src/server/services/__tests__/mcp.test.ts b/src/server/services/__tests__/mcp.test.ts index de97074b..3b147964 100644 --- a/src/server/services/__tests__/mcp.test.ts +++ b/src/server/services/__tests__/mcp.test.ts @@ -315,6 +315,99 @@ describe("mcp comment lifecycle", () => { ]), ); }); + + it("runs.open moves an EXECUTE issue to the configured started status with audit evidence", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "MCS" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const started = await prisma.status.findFirstOrThrow({ + where: { workspaceId: fixture.workspace.id, category: "IN_PROGRESS" }, + }); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { startedStatusId: started.id }, + }); + const issue = await createIssue(fixture, { statusCategory: "TODO" }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `execute-start-${Date.now()}`, + name: "Execute starter", + }, + }); + const { ctx } = buildMcpCtx(fixture, { linkedAgentId: agent.id }); + + const opened = (await call( + "runs.open", + { issueId: issue.id, mode: "EXECUTE", summary: "Starting implementation" }, + ctx, + )) as { runId: string }; + + await expect( + prisma.issue.findUniqueOrThrow({ + where: { id: issue.id }, + select: { statusId: true, startedAt: true }, + }), + ).resolves.toMatchObject({ statusId: started.id, startedAt: expect.any(Date) }); + await expect( + prisma.auditLog.findFirst({ + where: { + workspaceId: fixture.workspace.id, + entityId: issue.id, + action: "run-start-auto-transition", + }, + }), + ).resolves.toBeTruthy(); + await expect( + prisma.activityEvent.findFirst({ + where: { + workspaceId: fixture.workspace.id, + subjectType: "issue", + subjectId: issue.id, + kind: "ISSUE_STATUS_CHANGED", + payload: { path: ["runId"], equals: opened.runId }, + }, + }), + ).resolves.toBeTruthy(); + }); + + it.each(["RESEARCH", "REVIEW", "DISCUSS"] as const)( + "runs.open leaves issue status unchanged for %s work", + async (mode) => { + const fixture = await createWorkspaceFixture({ keyPrefix: "MCN" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const started = await prisma.status.findFirstOrThrow({ + where: { workspaceId: fixture.workspace.id, category: "IN_PROGRESS" }, + }); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { startedStatusId: started.id }, + }); + const issue = await createIssue(fixture, { statusCategory: "TODO" }); + const before = await prisma.issue.findUniqueOrThrow({ + where: { id: issue.id }, + select: { statusId: true }, + }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `${mode.toLowerCase()}-${Date.now()}`, + name: `${mode} worker`, + }, + }); + const { ctx } = buildMcpCtx(fixture, { linkedAgentId: agent.id }); + + await call("runs.open", { issueId: issue.id, mode }, ctx); + + await expect( + prisma.issue.findUniqueOrThrow({ + where: { id: issue.id }, + select: { statusId: true }, + }), + ).resolves.toEqual(before); + }, + ); }); describe("mcp workspace and access keys", () => { @@ -4291,6 +4384,25 @@ describe("mcp runs.complete + completion contract", () => { }); expect(after.statusId).toBe(inReview.id); expect(after.status?.category).toBe("IN_REVIEW"); + await expect( + prisma.auditLog.findFirst({ + where: { + workspaceId: fixture.workspace.id, + entityId: issue.id, + action: "run-complete-auto-transition", + }, + }), + ).resolves.toBeTruthy(); + await expect( + prisma.activityEvent.findFirst({ + where: { + workspaceId: fixture.workspace.id, + subjectType: "issue", + subjectId: issue.id, + kind: "ISSUE_STATUS_CHANGED", + }, + }), + ).resolves.toBeTruthy(); }); it("runs.complete can recommend the verified issue for completion", async () => { diff --git a/src/server/services/mcp-instructions.ts b/src/server/services/mcp-instructions.ts index b0f50f8f..38f2c11b 100644 --- a/src/server/services/mcp-instructions.ts +++ b/src/server/services/mcp-instructions.ts @@ -1,2 +1,2 @@ export const FORGE_MCP_INSTRUCTIONS = - "Forge is the delivery source of truth for project work. For code changes, inspect and claim the issue's work session before editing. If another connection owns delivery, use workSessions.join as a contributor/reviewer or request an explicit workSessions.handoff; never start a competing implementation. Open an explicit execution record with runs.open before publishing run status; comments and other MCP metadata never create a run implicitly. Heartbeat at meaningful phase changes and after commits, link implementation pull requests with github.link(kind=IMPLEMENTS), then attach them with workSessions.attachPullRequest. Keep the issue timeline useful to humans: use comments.upsertStatus for meaningful interim checkpoints, not mechanical logs, and publish one durable comments.create handoff with the implementation outcome, pull request, validation, and caveats. The workspace delivery timeline policy returned by attachPullRequest may recommend, require, or automatically create that handoff."; + "Forge is the delivery source of truth for project work. For code changes, inspect and claim the issue's work session before editing. If another connection owns delivery, use workSessions.join as a contributor/reviewer or request an explicit workSessions.handoff; never start a competing implementation. Open an explicit execution record with runs.open before publishing run status; an EXECUTE run applies the workspace's configured started status, while Research, Review, and Discuss leave issue status unchanged. Comments and other MCP metadata never create a run implicitly. Heartbeat at meaningful phase changes and after commits, link implementation pull requests with github.link(kind=IMPLEMENTS), then attach them with workSessions.attachPullRequest. Keep the issue timeline useful to humans: use comments.upsertStatus for meaningful interim checkpoints, not mechanical logs, and publish one durable comments.create handoff with the implementation outcome, pull request, validation, and caveats. Complete the run with runs.complete; successful EXECUTE completion applies the configured review status. The workspace delivery timeline policy returned by attachPullRequest may recommend, require, or automatically create that handoff."; diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 7d8a61e7..7d9e80e5 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -36,7 +36,11 @@ import { db } from "@/server/db"; import { decryptSecret } from "@/server/crypto"; import { getInstallationTokenForApp } from "@/server/services/github-app"; import { deriveRepoPath } from "@/server/services/repo-path"; -import { recordChange, maybeAutoTransitionOnComplete } from "@/server/audit"; +import { + recordChange, + maybeAutoTransitionOnComplete, + maybeAutoTransitionOnRunStart, +} from "@/server/audit"; import { publish } from "@/server/realtime"; import { maybeApplyAgentTemplate } from "@/server/services/agent-template"; import { maybeAutoDispatch } from "@/server/services/dispatcher"; @@ -6923,8 +6927,8 @@ export const mcpTools = { ); } } - const { run, isNew } = await db.$transaction((tx) => - openOrTouchRun(tx, { + const { run, isNew } = await db.$transaction(async (tx) => { + const opened = await openOrTouchRun(tx, { workspaceId: ctx.workspaceId, issueId: issue.id, agentId: linkedAgentId, @@ -6935,8 +6939,17 @@ export const mcpTools = { engagementMode: (input.mode as EngagementMode | undefined) ?? "EXECUTE", currentStep: input.summary ?? "opened by agent", resumeWaiting: true, - }), - ); + }); + await maybeAutoTransitionOnRunStart(tx, { + workspaceId: ctx.workspaceId, + issueId: issue.id, + runId: opened.run.id, + engagementMode: opened.run.engagementMode, + actorId: ctx.userId, + actorAgentId: linkedAgentId, + }); + return opened; + }); return { runId: run.id, isNew, status: run.status, issueId: issue.id }; }, }, @@ -7317,7 +7330,11 @@ export const mcpTools = { // A genuinely successful EXECUTE completion (this path only, not // abandons/stops that also emit AGENT_RUN_COMPLETED elsewhere) is a // "ready for review" signal when the workspace opted in. - await maybeAutoTransitionOnComplete(tx, ctx.workspaceId, run.issueId, run.engagementMode); + await maybeAutoTransitionOnComplete(tx, ctx.workspaceId, run.issueId, run.engagementMode, { + runId: run.id, + actorId: ctx.userId, + actorAgentId: linkedAgentId, + }); if (run.executionStepId) { await handoffCompletedRunToStep(tx, { workspaceId: ctx.workspaceId, diff --git a/tests/unit/run-provenance.test.ts b/tests/unit/run-provenance.test.ts new file mode 100644 index 00000000..89445c0c --- /dev/null +++ b/tests/unit/run-provenance.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { workstreamRunProvenance } from "@/lib/run-provenance"; + +describe("workstreamRunProvenance", () => { + it("attributes direct MCP work to the transport instead of the configured runtime", () => { + const result = workstreamRunProvenance({ + run: { + connection: { + kind: "MCP_CLIENT", + displayName: "Codex Desktop", + }, + }, + configuredRuntimeName: "Codex app server", + configuredRuntimeMode: "PERSISTENT", + }); + + expect(result).toMatchObject({ + label: "Via MCP · Codex Desktop", + recorded: true, + }); + expect(result.label).not.toContain("Codex app server"); + }); + + it("claims managed runtime execution only when the run has an external runtime id", () => { + expect( + workstreamRunProvenance({ + run: { + externalRunId: "runtime-run-42", + connection: { + kind: "MANAGED_RUNTIME", + displayName: "Codex Runtime", + runtime: { name: "Codex app server" }, + }, + }, + }), + ).toMatchObject({ label: "Ran on Codex app server", recorded: true }); + }); + + it("does not infer execution for a historical run without a connection", () => { + expect( + workstreamRunProvenance({ + run: {}, + configuredRuntimeName: "Codex app server", + configuredRuntimeMode: "PERSISTENT", + }), + ).toMatchObject({ label: "Execution source not recorded", recorded: false }); + }); + + it("shows profile configuration only when there is no run", () => { + expect( + workstreamRunProvenance({ + configuredRuntimeName: "Codex app server", + configuredRuntimeMode: "PERSISTENT", + }), + ).toMatchObject({ + label: "Configured for Codex app server · persistent", + recorded: false, + }); + }); +});