diff --git a/DEVLOG.md b/DEVLOG.md index 7f6033bb..d7350ea5 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,23 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-29 — Session-scoped MCP Delivery lifecycle + +- Separated generic MCP connection presence from WorkSession lifecycle + reconciliation. A tool call can confirm the endpoint without clearing stale + recovery requests for every Delivery session that endpoint owns. +- Added owner-scoped `workSessions.abandon` so direct MCP clients can + explicitly release a no-output code session without inventing merged, + released, or deployed evidence. +- Made direct MCP Execute completion fail closed when its active code session + has neither an attached implementation PR nor explicit abandonment. +- Added lifecycle next-action guidance to MCP claim/run responses and expanded + operator documentation for shared connections, heartbeats, PR attachment, + abandonment, and completion ordering. +- Added integration coverage for quiet-request churn, multiple Delivery + sessions sharing one MCP connection, explicit abandonment, and guarded run + completion. + ## 2026-07-28 — v0.31.0 release preparation Prepared the serialized minor release for AXI-164 after implementation PR #92 diff --git a/docs/agents/providers-and-transports.md b/docs/agents/providers-and-transports.md index cc6d7601..b0936eeb 100644 --- a/docs/agents/providers-and-transports.md +++ b/docs/agents/providers-and-transports.md @@ -7,9 +7,9 @@ available, and how chat is served — and why a Codex CLI session must **not** There are two independent axes: -- **Tier** — *how reachable and how rich* the connection is (presence + +- **Tier** — _how reachable and how rich_ the connection is (presence + transport). First-class managed runtime → session CLI → basic webhook. -- **Engine** — *who owns the agent loop* for a chat turn: **Runs** (the +- **Engine** — _who owns the agent loop_ for a chat turn: **Runs** (the runtime) or **Streaming/Completions** (Forge). Orthogonal to tier; see [Chat & Dispatch Engines](./engines.md). @@ -35,23 +35,30 @@ agent's work to the credential owner. Availability is interpreted from the connection's declared liveness model, not from its provider name: -| Connection | Positive signals | Silence means | -|---|---|---| -| Managed runtime | Runtime heartbeat, run events, provider state | A confirmed stall is possible after the workspace threshold | -| MCP client | MCP initialize/session, tool calls, explicit lease heartbeat | Quiet / status unconfirmed; never a confirmed stall from silence alone | -| Webhook | Durable delivery plus acknowledgement | Delivery failed or acknowledgement missing | -| On-demand | Successful probe when invoked | Not currently running; global online/offline is not meaningful | +| Connection | Positive signals | Silence means | +| --------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Managed runtime | Runtime heartbeat, run events, provider state | A confirmed stall is possible after the workspace threshold | +| MCP client | MCP initialize/session and tool calls confirm the connection; explicit session heartbeat confirms only that Delivery lease | Quiet / status unconfirmed; never a confirmed stall from silence alone | +| Webhook | Durable delivery plus acknowledgement | Delivery failed or acknowledgement missing | +| On-demand | Successful probe when invoked | Not currently running; global online/offline is not meaningful | Git commits, pull-request changes, checks, and reviews count as work evidence, but do not claim that a client process is alive. Operator surfaces show both the latest lifecycle signal and the latest external work evidence, along with the confidence of the resulting state. +Connection presence is not inherited by the WorkSessions that connection owns. +A generic MCP call can confirm that the endpoint is reachable, but it neither +renews nor clears recovery for any code-delivery session. Agents heartbeat the +exact session at meaningful phases, attach its PR, or explicitly hand off or +abandon it. Direct Execute runs with an unbound active code session must settle +that session before `runs.complete`. + ## Tier 1 — First-class agents (managed runtimes) The agent is a **full workspace member**: always-on presence, realtime chat, orchestration, issues, and dispatched work. Forge holds **no model API key** — -the runtime runs the model; the agent answers *as itself*. +the runtime runs the model; the agent answers _as itself_. - **Hermes** — persistent daemon hosting multiple profiles (Victor, Mizu) behind one gateway (`/v1/runs`). Owns the loop, streams, approvals, @@ -74,14 +81,14 @@ the runtime runs the model; the agent answers *as itself*. **Engine choice (per agent):** -| | **Runs** (recommended) | **Streaming** (Completions) | -|---|---|---| -| Loop owner | The runtime | Forge | -| Agent memory / persona / commands | **Preserved** — runs as itself | None (stateless) | -| Tools | The agent's own | Forge's chat allowlist + approvals | -| Same engine as dispatched work | Yes | No | -| Latency | Slightly higher | Lowest | -| Model | Provider-native | Any OpenAI-compatible | +| | **Runs** (recommended) | **Streaming** (Completions) | +| --------------------------------- | ------------------------------ | ---------------------------------- | +| Loop owner | The runtime | Forge | +| Agent memory / persona / commands | **Preserved** — runs as itself | None (stateless) | +| Tools | The agent's own | Forge's chat allowlist + approvals | +| Same engine as dispatched work | Yes | No | +| Latency | Slightly higher | Lowest | +| Model | Provider-native | Any OpenAI-compatible | We default first-class agents to **Runs** so Hermes/Codex keep their memory, session, and native commands. Flip to **Streaming** only for a stateless @@ -96,7 +103,7 @@ functionality while the session is active**, but **ephemeral presence** — not always online. Best for in-session, active work rather than always-on duty. - **ACP** — Agent Client Protocol: a portable, bidirectional agent session. - The CLI chats *as itself* while live, with no per-vendor wiring. + The CLI chats _as itself_ while live, with no per-vendor wiring. `transport: "acp"`, `chatMode: "acp"`. **Daemon-mediated** (ACP is stdio JSON-RPC): on the daemon host set `FORGE_ACP_CMD=" acp"` (e.g. `claude-code-acp`, `codex acp`, `opencode acp`) and run `forge daemon start` @@ -105,8 +112,8 @@ always online. Best for in-session, active work rather than always-on duty. - **MCP (pull/act, today)** — the CLI connects over MCP with a Bearer key to **read context and take actions**. It does **not** serve an interactive chat turn (`chatMode: "none"`) — it has no model key and isn't a chat backend. - Chatting with such an agent shows a "no chat model configured" notice *by - design*; to chat with it as itself, give it an ACP session or promote it to + Chatting with such an agent shows a "no chat model configured" notice _by + design_; to chat with it as itself, give it an ACP session or promote it to a first-class app-server runtime. The `forge` **local daemon** is a managed bridge in this tier: `forge daemon @@ -122,11 +129,11 @@ runtimes. ## At a glance -| Tier | Examples | Transport | Presence | Chat | Best for | -|------|----------|-----------|----------|------|----------| -| 1 — First-class | Hermes, Codex app server | `runs-api`, `app-server` | Always-on | Runs (or Streaming) | Full members: chat + dispatch + orchestration | -| 2 — Session CLI | Claude Code, Codex CLI, OpenCode | `acp`, `mcp`, `local-daemon` | Session/ephemeral | ACP (as itself) or pull/act | In-session active work | -| 3 — Basic | Custom bot | `webhook`, `http` | Delivery-derived | None | BYO integrations | +| Tier | Examples | Transport | Presence | Chat | Best for | +| --------------- | -------------------------------- | ---------------------------- | ----------------- | --------------------------- | --------------------------------------------- | +| 1 — First-class | Hermes, Codex app server | `runs-api`, `app-server` | Always-on | Runs (or Streaming) | Full members: chat + dispatch + orchestration | +| 2 — Session CLI | Claude Code, Codex CLI, OpenCode | `acp`, `mcp`, `local-daemon` | Session/ephemeral | ACP (as itself) or pull/act | In-session active work | +| 3 — Basic | Custom bot | `webhook`, `http` | Delivery-derived | None | BYO integrations | ## Codex sandboxing & approvals @@ -134,7 +141,7 @@ A Codex app-server runtime touches a real filesystem, so its blast radius is controlled on **two layers**: 1. **The bridge container is the hard boundary.** The reference bridge - (`~/docker/codex-bridge/`) mounts only the operator's Codex *auth* + (`~/docker/codex-bridge/`) mounts only the operator's Codex _auth_ (read-only) and a single scoped workspace (`/work`). The host filesystem is unreachable from inside, so even a full-access Codex turn can't read host secrets. This is fixed by the deployment, not a per-runtime setting. The @@ -145,11 +152,11 @@ controlled on **two layers**: `sandboxPolicy` / `approvalPolicy` / `cwd` overrides). Edit it in **Settings → Runtimes → (the Codex runtime) → Codex sandbox**: - | Field | Values | Effect | - |-------|--------|--------| - | **Sandbox mode** | `Full access` · `Workspace-write` · `Read-only` | OS-level file/network scope. Workspace-write limits writes to the workspace root. | - | **Approval policy** | `Never` · `On request` · `On failure` · `Untrusted` | Anything but `Never` makes Codex raise an approval before risky commands/edits — Forge renders these as **accept/deny cards in chat**. | - | **Workspace root** | a path, e.g. `/work/agent-forge` | The turn's working dir; in workspace-write it's the only writable root. Setting it also makes Forge declare the Codex runtime as having repo tools for preflight and runtime cards. | + | Field | Values | Effect | + | ------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | **Sandbox mode** | `Full access` · `Workspace-write` · `Read-only` | OS-level file/network scope. Workspace-write limits writes to the workspace root. | + | **Approval policy** | `Never` · `On request` · `On failure` · `Untrusted` | Anything but `Never` makes Codex raise an approval before risky commands/edits — Forge renders these as **accept/deny cards in chat**. | + | **Workspace root** | a path, e.g. `/work/agent-forge` | The turn's working dir; in workspace-write it's the only writable root. Setting it also makes Forge declare the Codex runtime as having repo tools for preflight and runtime cards. | Defaults (no config) = **full access, no prompts** — the original behavior. Forge tightens this per run: non-Execute dispatches (Research, Review, and diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md index c4d425bb..0ea95de6 100644 --- a/docs/engineering/work-management.md +++ b/docs/engineering/work-management.md @@ -64,6 +64,13 @@ asks for a Delivery disposition instead of implying that work is still executing. Resume it, attach or advance its delivery evidence, hand it off, or abandon it explicitly. +Generic traffic from the same MCP connection is also connection evidence only. +One Desktop task or CLI process can own more than one Delivery session, so an +unrelated tool call must not refresh or clear every session's recovery request. +Only the exact session's heartbeat, PR attachment or advancement, handoff, +abandonment, terminal reconciliation, or audited operator confirmation changes +that session's recovery state. + MCP-quiet recovery is coordination evidence, not an independent product decision. It does not by itself block a completion recommendation, and a safe Ready to Close assessment supersedes the redundant recovery ask. Terminal @@ -167,6 +174,13 @@ 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. +An MCP-owned Execute run cannot complete while its code-delivery session is +still Claimed, In Progress, or Stale without an implementation PR. Attach the native PR +with `workSessions.attachPullRequest`, or explicitly release a no-output session +with `workSessions.abandon`, before calling `runs.complete`. This prevents a +client ending its turn while Forge still reports an ambiguous active delivery +lease. + 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/src/app/api/mcp/rpc/route.ts b/src/app/api/mcp/rpc/route.ts index 9158a710..4d8f902c 100644 --- a/src/app/api/mcp/rpc/route.ts +++ b/src/app/api/mcp/rpc/route.ts @@ -19,7 +19,7 @@ import { mcpServerInfo } from "@/server/build-info"; import { FORGE_MCP_INSTRUCTIONS } from "@/server/services/mcp-instructions"; import { db } from "@/server/db"; import { touchAgentConnection, upsertAgentConnection } from "@/server/services/agent-connection"; -import { resolveMcpQuietRequestsForConnection } from "@/server/services/work-session"; +import { reconcileFreshMcpQuietRequestsForConnection } from "@/server/services/work-session"; /** * Standard MCP (Model Context Protocol) endpoint — Streamable HTTP transport @@ -250,7 +250,7 @@ async function resolveMcpConnection( }, }); } - await resolveMcpQuietRequestsForConnection(db, auth.workspaceId, connection.id); + await reconcileFreshMcpQuietRequestsForConnection(db, auth.workspaceId, connection.id); return connection; } diff --git a/src/server/services/__tests__/work-session.test.ts b/src/server/services/__tests__/work-session.test.ts index 9889bbe0..304b2cd9 100644 --- a/src/server/services/__tests__/work-session.test.ts +++ b/src/server/services/__tests__/work-session.test.ts @@ -7,12 +7,13 @@ import { attachPullRequest, claimWorkSession, listIssueWorkSessions, - resolveMcpQuietRequestsForConnection, + reconcileFreshMcpQuietRequestsForConnection, syncWorkSessionsFromPullRequest, sweepStaleWorkSessions, touchWorkSession, } from "@/server/services/work-session"; import { handoffWorkSession, joinWorkSession } from "@/server/services/work-session-participant"; +import { touchAgentConnection } from "@/server/services/agent-connection"; import { createIssue, createWorkspaceFixture, @@ -655,6 +656,8 @@ describe("work session coordination", () => { expect(FORGE_MCP_INSTRUCTIONS).toContain("comments.upsertStatus"); expect(FORGE_MCP_INSTRUCTIONS).toContain("comments.create"); expect(FORGE_MCP_INSTRUCTIONS).toContain("workSessions.attachPullRequest"); + expect(FORGE_MCP_INSTRUCTIONS).toContain("workSessions.abandon"); + expect(FORGE_MCP_INSTRUCTIONS).toContain("before runs.complete"); const attachTool = mcpTools["workSessions.attachPullRequest"]; expect(attachTool.description).toContain("human-readable issue handoff"); expect( @@ -666,6 +669,107 @@ describe("work session coordination", () => { ).toBe(true); }); + it("requires direct MCP Execute runs to disposition an unbound delivery session", async () => { + const { fixture, prisma, issue } = await setup(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `disposition-${Date.now()}`, + name: "Disposition agent", + }, + }); + const connection = await prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + status: "ACTIVE", + confidence: "CONFIRMED", + instanceKey: `disposition-${Date.now()}`, + }, + }); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/disposition-required", + source: WorkSessionSource.MCP, + actor: { + userId: fixture.user.id, + agentId: agent.id, + connectionId: connection.id, + }, + }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + connectionId: connection.id, + status: "ACTIVE", + engagementMode: "EXECUTE", + }, + }); + const ctx = { + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + pluginId: null, + connectionId: connection.id, + apiKey: { + keyId: "disposition-key", + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + pluginId: null, + scopes: ["READ_ISSUES", "WRITE_ISSUES", "WRITE_COMMENTS"], + projectIds: [], + labelIds: [], + initiativeIds: [], + linkedAgentId: agent.id, + }, + } as unknown as McpContext; + + await expect( + mcpTools["runs.complete"].run( + { + runId: run.id, + summary: "No implementation was required.", + recommendIssueCompletion: false, + } as never, + ctx, + ), + ).rejects.toThrow(/workSessions\.attachPullRequest.*workSessions\.abandon/); + + await prisma.workSession.update({ + where: { id: session.id }, + data: { status: "STALE", staleAt: new Date() }, + }); + await expect( + mcpTools["runs.complete"].run( + { + runId: run.id, + summary: "No implementation was required.", + recommendIssueCompletion: false, + } as never, + ctx, + ), + ).rejects.toThrow(/workSessions\.attachPullRequest.*workSessions\.abandon/); + + await expect( + mcpTools["workSessions.abandon"].run({ sessionId: session.id } as never, ctx), + ).resolves.toMatchObject({ id: session.id, status: "ABANDONED", endedAt: expect.any(Date) }); + await expect( + mcpTools["runs.complete"].run( + { + runId: run.id, + summary: "No implementation was required.", + recommendIssueCompletion: false, + } as never, + ctx, + ), + ).resolves.toMatchObject({ id: run.id, status: "COMPLETED" }); + }); + it("marks quiet leases stale and creates one shared action request", async () => { const { fixture, prisma, issue } = await setup(); await prisma.workspace.update({ @@ -863,18 +967,17 @@ describe("work session coordination", () => { }); expect( - await resolveMcpQuietRequestsForConnection(prisma, fixture.workspace.id, connection.id), - ).toBe(1); + await reconcileFreshMcpQuietRequestsForConnection( + prisma, + fixture.workspace.id, + connection.id, + ), + ).toBe(0); await expect( prisma.actionRequest.findFirstOrThrow({ where: { dedupeKey: `work-session-mcp-quiet:${session.id}` }, }), - ).resolves.toMatchObject({ status: "RESOLVED", resolution: "MCP connection resumed." }); - - await prisma.actionRequest.updateMany({ - where: { dedupeKey: `work-session-mcp-quiet:${session.id}` }, - data: { status: "OPEN", resolvedAt: null, resolution: null }, - }); + ).resolves.toMatchObject({ status: "OPEN", resolvedAt: null }); await touchWorkSession(prisma, { workspaceId: fixture.workspace.id, sessionId: session.id, @@ -887,6 +990,80 @@ describe("work session coordination", () => { ).resolves.toMatchObject({ status: "RESOLVED", resolution: "Work session resumed." }); }); + it("does not treat generic activity on a shared MCP connection as session lifecycle evidence", async () => { + const { fixture, prisma, issue } = await setup(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { workSessionStaleMinutes: 1 }, + }); + const secondIssue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `shared-mcp-${Date.now()}`, + name: "Shared MCP client", + }, + }); + const connection = await prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + status: "QUIET", + confidence: "UNCONFIRMED", + instanceKey: `shared-${Date.now()}`, + }, + }); + const sessions = await Promise.all( + [ + { issueId: issue.id, branch: "codex/shared-one" }, + { issueId: secondIssue.id, branch: "codex/shared-two" }, + ].map(({ issueId, branch }) => + claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId, + repoFullName: "acme/forge", + branch, + source: WorkSessionSource.MCP, + actor: { + userId: fixture.user.id, + agentId: agent.id, + connectionId: connection.id, + }, + }), + ), + ); + await prisma.workSession.updateMany({ + where: { id: { in: sessions.map((session) => session.id) } }, + data: { lastHeartbeatAt: new Date(Date.now() - 5 * 60_000) }, + }); + await sweepStaleWorkSessions(prisma); + + await touchAgentConnection(prisma, connection.id); + expect( + await reconcileFreshMcpQuietRequestsForConnection( + prisma, + fixture.workspace.id, + connection.id, + ), + ).toBe(0); + expect( + await prisma.actionRequest.count({ + where: { + workspaceId: fixture.workspace.id, + status: "OPEN", + dedupeKey: { + in: sessions.map((session) => `work-session-mcp-quiet:${session.id}`), + }, + }, + }), + ).toBe(2); + await expect( + prisma.agentConnection.findUniqueOrThrow({ where: { id: connection.id } }), + ).resolves.toMatchObject({ status: "ACTIVE", confidence: "CONFIRMED" }); + }); + it("resolves MCP recovery requests when the issue is already terminal", async () => { const { fixture, prisma, issue } = await setup(); await prisma.workspace.update({ diff --git a/src/server/services/mcp-exec.ts b/src/server/services/mcp-exec.ts index e5bae69e..262ed5bc 100644 --- a/src/server/services/mcp-exec.ts +++ b/src/server/services/mcp-exec.ts @@ -204,6 +204,7 @@ async function resolveExplicitPolicyTarget( case "workSessions.claim": return { kind: "issues", issueIds: [input.issueId as string] }; case "workSessions.heartbeat": + case "workSessions.abandon": case "workSessions.attachPullRequest": { const sessionId = input.sessionId as string | undefined; if (!sessionId) return { kind: "none" }; diff --git a/src/server/services/mcp-instructions.ts b/src/server/services/mcp-instructions.ts index 38f2c11b..62198b19 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; 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."; + "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. Generic MCP traffic confirms only connection presence and never renews a Delivery session. Heartbeat the exact session with workSessions.heartbeat at meaningful phase changes and after commits. Link implementation pull requests with github.link(kind=IMPLEMENTS), then attach them with workSessions.attachPullRequest. If implementation ends without a PR, explicitly release the lease with workSessions.abandon. 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. Attach the implementation PR or abandon the owned code session before 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-policy.ts b/src/server/services/mcp-policy.ts index 670ddb00..94b16f42 100644 --- a/src/server/services/mcp-policy.ts +++ b/src/server/services/mcp-policy.ts @@ -228,6 +228,13 @@ export const EXPLICIT_MCP_TOOL_POLICIES = { allowedModes: EXECUTE_ONLY, actor: "linked-agent-required", }, + "workSessions.abandon": { + access: "write", + targetType: "issue", + mutationKind: "issue-state", + allowedModes: EXECUTE_ONLY, + actor: "linked-agent-required", + }, "workSessions.attachPullRequest": { access: "write", targetType: "issue", diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index d954bce8..c7f2745d 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -31,6 +31,7 @@ import { RelationKind, RuntimeKind, StatusCategory, + WorkSessionStatus, WorkSessionSource, type CanvasStyleKind, type EngagementMode, @@ -155,6 +156,7 @@ import { type GitHubResourceType, } from "@/server/services/github/types"; import { + advanceWorkSession, attachPullRequest, claimWorkSession, listIssueWorkSessions, @@ -6955,7 +6957,17 @@ export const mcpTools = { }); return opened; }); - return { runId: run.id, isNew, status: run.status, issueId: issue.id }; + return { + runId: run.id, + isNew, + status: run.status, + issueId: issue.id, + lifecycle: { + completeWith: "runs.complete", + delivery: + "For code work, keep the exact WorkSession alive with workSessions.heartbeat, then attach its implementation PR or abandon it before completing this run.", + }, + }; }, }, @@ -7249,6 +7261,36 @@ export const mcpTools = { throw new Error(`Run is ${run.status}; only ACTIVE / WAITING runs can be completed.`); } await assertKeyScope(scopeCtx(ctx), { entity: "issue", id: run.issueId }); + if ( + run.engagementMode === "EXECUTE" && + run.connectionId && + run.connection?.kind === AgentConnectionKind.MCP_CLIENT + ) { + const unresolvedDelivery = await db.workSession.findFirst({ + where: { + workspaceId: ctx.workspaceId, + issueId: run.issueId, + ownerConnectionId: run.connectionId, + endedAt: null, + pullRequestId: null, + status: { + in: [ + WorkSessionStatus.CLAIMED, + WorkSessionStatus.IN_PROGRESS, + WorkSessionStatus.STALE, + ], + }, + }, + select: { id: true }, + }); + if (unresolvedDelivery) { + throw new Error( + `Active Delivery session ${unresolvedDelivery.id} has no implementation PR. ` + + "Call workSessions.attachPullRequest after linking the PR, or call " + + "workSessions.abandon when no implementation will be delivered, before runs.complete.", + ); + } + } if (input.completionCommentId) { const completionComment = await db.comment.findFirst({ @@ -7427,7 +7469,29 @@ export const mcpTools = { ], }) : null; - return { ...completed, completionRecommendation }; + const deliverySession = run.connectionId + ? await db.workSession.findFirst({ + where: { + workspaceId: ctx.workspaceId, + issueId: run.issueId, + ownerConnectionId: run.connectionId, + }, + orderBy: { createdAt: "desc" }, + select: { id: true, status: true, pullRequestId: true, endedAt: true }, + }) + : null; + return { + ...completed, + completionRecommendation, + delivery: deliverySession + ? { + sessionId: deliverySession.id, + status: deliverySession.status, + hasPullRequest: Boolean(deliverySession.pullRequestId), + terminal: Boolean(deliverySession.endedAt), + } + : null, + }; }, }, @@ -8473,12 +8537,22 @@ export const mcpTools = { const agentId = ctx.apiKey?.linkedAgentId; if (!agentId) throw new Error("workSessions.claim requires a linked agent key."); await assertKeyScope(scopeCtx(ctx), { entity: "issue", id: input.issueId }); - return claimWorkSession(db, { + const session = await claimWorkSession(db, { workspaceId: ctx.workspaceId, ...input, source: WorkSessionSource.MCP, actor: { userId: ctx.userId, agentId, connectionId: ctx.connectionId ?? null }, }); + return { + ...session, + lifecycle: { + heartbeatWith: "workSessions.heartbeat", + attachPullRequestWith: "workSessions.attachPullRequest", + abandonWith: "workSessions.abandon", + requiredBeforeRunCompletion: + "Attach the native implementation PR or explicitly abandon this session before runs.complete.", + }, + }; }, }, @@ -8520,6 +8594,36 @@ export const mcpTools = { }, }, + "workSessions.abandon": { + description: + "Explicitly release an owned code-delivery session when no implementation will be delivered. This is terminal, audited, and resolves its recovery requests; it never claims that work was merged, released, or deployed.", + scopes: ["WRITE_ISSUES"] as const, + input: z.object({ sessionId: z.string().cuid() }), + async run(input: { sessionId: string }, ctx: McpContext) { + const agentId = ctx.apiKey?.linkedAgentId; + if (!agentId) throw new Error("workSessions.abandon requires a linked agent key."); + const owned = await db.workSession.findFirst({ + where: { + id: input.sessionId, + workspaceId: ctx.workspaceId, + endedAt: null, + ...(ctx.connectionId + ? { ownerConnectionId: ctx.connectionId } + : { ownerAgentId: agentId, ownerConnectionId: null }), + }, + select: { id: true, issueId: true }, + }); + if (!owned) throw new Error("Active work session is not owned by this agent connection."); + await assertKeyScope(scopeCtx(ctx), { entity: "issue", id: owned.issueId }); + return advanceWorkSession(db, { + workspaceId: ctx.workspaceId, + sessionId: owned.id, + status: WorkSessionStatus.ABANDONED, + actor: { userId: ctx.userId, agentId, connectionId: ctx.connectionId ?? null }, + }); + }, + }, + "workSessions.join": { description: "Join an existing delivery session as an explicit contributor or reviewer without taking primary branch/PR authority.", @@ -15257,7 +15361,15 @@ export const MCP_TOOL_PROFILES: Record = { "chat.appendDraftChunk", "chat.finalizeDraft", "runs", - "workSessions", + // Keep the everyday Delivery lifecycle directly advertised. Explicit + // ownership handoff remains available through catalog.call so the compact + // default stays below the provider headroom budget. + "workSessions.list", + "workSessions.claim", + "workSessions.heartbeat", + "workSessions.abandon", + "workSessions.join", + "workSessions.attachPullRequest", "actionRequests.list", "actionRequests.create", "workspace", diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts index 99ab9d51..eba452b8 100644 --- a/src/server/services/work-session.ts +++ b/src/server/services/work-session.ts @@ -167,23 +167,34 @@ async function resolveTerminalDeliveryConflictRequests(db: PrismaClient) { } /** - * Any authenticated signal from an MCP connection renews its observation - * lease. Clear delivery recovery asks for sessions that connection still owns, - * even when the signal was a generic MCP ping/tool call rather than an explicit - * workSessions heartbeat. + * Repair quiet requests only when their own Delivery session has recent + * lifecycle evidence. Connection-level activity must not clear every request + * owned by that endpoint: one MCP client may own several independent sessions, + * and a generic tool call proves only that the transport is present. + * + * Session-scoped mutations already resolve their request directly. This + * reconciliation is a safety net for the narrow case where the heartbeat was + * persisted but request cleanup did not complete. */ -export async function resolveMcpQuietRequestsForConnection( +export async function reconcileFreshMcpQuietRequestsForConnection( db: PrismaClient, workspaceId: string, connectionId: string, - resolution = "MCP connection resumed.", + resolution = "Delivery session produced a recent lifecycle signal.", ) { + const workspace = await db.workspace.findFirst({ + where: { id: workspaceId, deletedAt: null }, + select: { workSessionStaleMinutes: true }, + }); + if (!workspace || workspace.workSessionStaleMinutes <= 0) return 0; + const cutoff = new Date(Date.now() - workspace.workSessionStaleMinutes * 60_000); const sessions = await db.workSession.findMany({ where: { workspaceId, ownerConnectionId: connectionId, endedAt: null, status: { in: [...ACTIVE_WORK_SESSION_STATUSES] }, + lastHeartbeatAt: { gte: cutoff }, }, select: { id: true }, }); diff --git a/tests/unit/mcp-tool-profiles.test.ts b/tests/unit/mcp-tool-profiles.test.ts index b399ec84..72938661 100644 --- a/tests/unit/mcp-tool-profiles.test.ts +++ b/tests/unit/mcp-tool-profiles.test.ts @@ -75,6 +75,8 @@ describe("mcp tool profiles (AXI-82)", () => { expect(runtime).toContain("runs.complete"); expect(runtime).toContain("actionRequests.list"); expect(runtime).toContain("workSessions.claim"); + expect(runtime).toContain("workSessions.abandon"); + expect(runtime).not.toContain("workSessions.handoff"); expect(runtime).toContain("chat.appendMessage"); expect(runtime).toContain("chat.finalizeDraft"); expect(runtime).not.toContain("chat.connector.negotiate");