diff --git a/DEVLOG.md b/DEVLOG.md index 388f3057..adda90de 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -119,6 +119,7 @@ crash-stranded `PROCESSING` row. The platform plugin also retains a failed final draft and reuses its original event id, preventing duplicate final messages on retry. Focused review coverage passes **33/33** TypeScript tests and **8/8** Python adapter tests. + ## 2026-07-16 — AXI-86 Artifact Studio Implemented Forge as the artifact control plane for people and agents. The @@ -482,6 +483,7 @@ Verification: focused dispatcher and operator-attention coverage passed passed; the full Vitest suite passed (**1,211 passed; 1 skipped**); a fresh production build and the responsive Command Center Playwright contract passed (**1/1** across desktop, tablet, and mobile); and `git diff --check` passed. + ## 2026-07-13 — Artifacts system research and implementation brief Created a documentation-only Artifacts audit in an isolated worktree using @@ -13571,6 +13573,49 @@ Mission Control Playwright suite passed both journeys. --- +## 2026-07-16 — Canonical agent connections and delivery ownership + +Introduced `AgentConnection` as the durable identity for managed runtimes, MCP +clients, webhooks, and on-demand execution. Runs, run events, work sessions, and +participants now preserve connection provenance and liveness confidence, while +MCP initialization establishes a session identity that is refreshed on every +request and revoked with its API key. + +Separated transport-specific liveness semantics: managed runtimes retain +heartbeat/stall recovery, while silent MCP clients become quiet and +unconfirmed without losing delivery ownership or being automatically +redispatched. Work sessions now enforce one active primary connection, support +explicit join and handoff operations, and block conflicting runtime dispatch +with an actionable operator request. PR merge reconciliation also closes +pre-merge execute runs so delivery state cannot remain falsely active. + +Expanded Agent Profile, Access, Command Center, Agent Policy, and issue Delivery +surfaces with connection type, confidence, last-seen evidence, participants, +ownership conflicts, and audited reconciliation. Updated the operator and +transport documentation and refreshed the Agent Studio audit captures. + +Release review tightened the migration and recovery edges: a connectionless +legacy lease can now be adopted only by a valid connection for the same linked +agent, self-join preserves or repairs the primary participant, and both explicit +delivery heartbeats and generic authenticated MCP signals resolve obsolete +quiet-client recovery asks. The compact runtime tool profile keeps the complete +agent chat loop while moving connector-only negotiation/delivery behind the +catalog, preserving its combined sub-50 provider budget. + +The issue Activity surface now classifies unattributed worker, automation, and +connector events instead of rendering a bare `system` actor. Generic issue +updates use payload evidence to summarize delivery actions, changed fields, and +label deltas, falling back to compact “Updated” copy only when no meaningful +detail exists. + +Verification: Prisma validation passed; lint and typecheck passed (existing +lint warnings only); focused release-review coverage passed 27/27; the complete +Vitest suite passed 1,386 tests with one intentional live-connector skip; the +fresh production Next.js build passed; and the final production-mode Playwright +run passed all 52 journeys. + +--- + ## 2026-07-15 — Mission Control attention surfaces Audited Mission Control, the cross-workspace Inbox, Activity, and top-bar diff --git a/docs/agents/profiles-and-bindings.md b/docs/agents/profiles-and-bindings.md index 3aa31325..5559630f 100644 --- a/docs/agents/profiles-and-bindings.md +++ b/docs/agents/profiles-and-bindings.md @@ -1,6 +1,7 @@ # Agent Profiles & Bindings -An agent in Forge isn't a single row. It's a **three-tier** model: +An agent in Forge isn't a single row. Its durable identity and governance use a +**three-tier** model: 1. A global **profile** — the agent's definition (who it is, what it can do), owned by a user, independent of any workspace. @@ -14,6 +15,12 @@ This is why the same `victor` can work in three workspaces with different capacity and dispatch rules in each, while staying one identity everywhere. +Execution adds a fourth, attempt-scoped primitive: **AgentConnection**. A +connection is one concrete managed runtime, MCP client, webhook, or on-demand +endpoint acting through a workspace binding. It is intentionally not another +identity tier: one binding may have several connections, and each run or work +session snapshots the connection that actually performed the work. + ## Tier 1 — Profiles (the definition) A **profile** (`AgentProfile`) is the source of truth for an agent's @@ -100,11 +107,15 @@ member can **request** one: Profiles created directly by an instance admin skip this — they're pre-approved at creation (`requestedById` stays null). -## MCP clients (the connection credentials) +## MCP clients (credentials and connections) MCP clients are **not execution runtimes**. A profile has at most one primary runtime, while each workspace binding may have any number of linked `ApiKey` -credentials for Codex, Claude, Hermes, or other trusted MCP clients. +credentials for Codex, Claude, Hermes, or other trusted MCP clients. The key is +only authentication. An MCP initialize handshake registers an +`AgentConnection` containing the client name/version, negotiated session id, +capabilities, and last-seen state. Rotating a key therefore does not rewrite +the connection recorded on historical runs or deliveries. Create, inspect, rotate, revoke, and remove those credentials at **`/w/[slug]/settings/access`**. Mission Control aggregates the linked clients by @@ -123,6 +134,7 @@ preselected. The former `/settings/clients` and See [Runtimes](/agents/runtimes.html). - **MCP clients** — each binding can hold zero-or-many linked credentials; these authenticate clients but do not replace the primary execution runtime. + The clients themselves appear as distinct Agent Connections. - **Auto-dispatch** — `autoDispatchEligible` and the binding's `capabilities` feed the workspace dispatcher; `autoDispatchMode` (which agent gets picked) is a different axis from engagement mode diff --git a/docs/agents/providers-and-transports.md b/docs/agents/providers-and-transports.md index 50c6bd2d..ac79f6c1 100644 --- a/docs/agents/providers-and-transports.md +++ b/docs/agents/providers-and-transports.md @@ -13,6 +13,36 @@ There are two independent axes: runtime) or **Streaming/Completions** (Forge). Orthogonal to tier; see [Chat & Dispatch Engines](./engines.md). +There are also four deliberately separate identities in the execution record: + +- **Agent profile / binding** — who is acting and what it may do in the + workspace. +- **Agent connection** — the concrete managed runtime, MCP client, webhook, or + on-demand endpoint used for this attempt. +- **Agent run** — one execution attempt against an issue. +- **Work session** — the primary branch and pull-request coordination lease. + +An API key authenticates a caller; it is not the execution identity. Forge +snapshots the connection on runs and work sessions so key rotation or later +profile relinking cannot rewrite historical attribution. + +## Connection-aware liveness + +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 | + +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. + ## Tier 1 — First-class agents (managed runtimes) The agent is a **full workspace member**: always-on presence, realtime chat, diff --git a/docs/agents/runtimes.md b/docs/agents/runtimes.md index bd8f2638..d1b2f752 100644 --- a/docs/agents/runtimes.md +++ b/docs/agents/runtimes.md @@ -10,6 +10,9 @@ A Mission Control agent profile selects **zero or one primary execution runtime* Codex, Claude, Hermes, and custom MCP clients connect with separate linked credentials in the workspace's **Agent access** page; a binding can have many of those clients. Adding an MCP client never changes the profile's runtime. +Both are represented as concrete `AgentConnection` endpoints when they +participate in work, so delivery attribution can say “Codex via Codex Desktop +MCP” or “Victor via Hermes runtime” without changing the Agent identity. ::: ::: tip Distinct from `runtimeMode` diff --git a/docs/audits/agent-studio-2026-07-15/after/01-global-agent-profiles.png b/docs/audits/agent-studio-2026-07-15/after/01-global-agent-profiles.png index 36886a0d..7de166b2 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/01-global-agent-profiles.png and b/docs/audits/agent-studio-2026-07-15/after/01-global-agent-profiles.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/02-create-agent-profile.png b/docs/audits/agent-studio-2026-07-15/after/02-create-agent-profile.png index 5d61c342..7b3dc1b9 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/02-create-agent-profile.png and b/docs/audits/agent-studio-2026-07-15/after/02-create-agent-profile.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/03-agent-profile-detail.png b/docs/audits/agent-studio-2026-07-15/after/03-agent-profile-detail.png index 6add3e1c..5e234479 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/03-agent-profile-detail.png and b/docs/audits/agent-studio-2026-07-15/after/03-agent-profile-detail.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/04-edit-agent-identity.png b/docs/audits/agent-studio-2026-07-15/after/04-edit-agent-identity.png index f802cb90..161b79ab 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/04-edit-agent-identity.png and b/docs/audits/agent-studio-2026-07-15/after/04-edit-agent-identity.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/05-add-mcp-client.png b/docs/audits/agent-studio-2026-07-15/after/05-add-mcp-client.png index 1369f875..6d0808a7 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/05-add-mcp-client.png and b/docs/audits/agent-studio-2026-07-15/after/05-add-mcp-client.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/06-global-runtimes.png b/docs/audits/agent-studio-2026-07-15/after/06-global-runtimes.png index 11c8414c..f34eda2f 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/06-global-runtimes.png and b/docs/audits/agent-studio-2026-07-15/after/06-global-runtimes.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/07-workspace-agent-bindings.png b/docs/audits/agent-studio-2026-07-15/after/07-workspace-agent-bindings.png index 6d065193..8636673b 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/07-workspace-agent-bindings.png and b/docs/audits/agent-studio-2026-07-15/after/07-workspace-agent-bindings.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/08-workspace-agent-policy-expanded.png b/docs/audits/agent-studio-2026-07-15/after/08-workspace-agent-policy-expanded.png index 5868d04f..67d9a957 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/08-workspace-agent-policy-expanded.png and b/docs/audits/agent-studio-2026-07-15/after/08-workspace-agent-policy-expanded.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/09-agent-access.png b/docs/audits/agent-studio-2026-07-15/after/09-agent-access.png index 2e0234c7..46df4b8e 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/09-agent-access.png and b/docs/audits/agent-studio-2026-07-15/after/09-agent-access.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/10-instance-agent-policy.png b/docs/audits/agent-studio-2026-07-15/after/10-instance-agent-policy.png index cb15a4a3..9851e841 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/10-instance-agent-policy.png and b/docs/audits/agent-studio-2026-07-15/after/10-instance-agent-policy.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/11-mission-control.png b/docs/audits/agent-studio-2026-07-15/after/11-mission-control.png index 577ef4dd..7b61eb50 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/11-mission-control.png and b/docs/audits/agent-studio-2026-07-15/after/11-mission-control.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/12-mobile-agent-profiles.png b/docs/audits/agent-studio-2026-07-15/after/12-mobile-agent-profiles.png index f377e1d0..d7622069 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/12-mobile-agent-profiles.png and b/docs/audits/agent-studio-2026-07-15/after/12-mobile-agent-profiles.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/13-mobile-agent-profile-detail.png b/docs/audits/agent-studio-2026-07-15/after/13-mobile-agent-profile-detail.png index d089837f..6aead361 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/13-mobile-agent-profile-detail.png and b/docs/audits/agent-studio-2026-07-15/after/13-mobile-agent-profile-detail.png differ diff --git a/docs/audits/agent-studio-2026-07-15/after/14-mobile-workspace-agent-bindings.png b/docs/audits/agent-studio-2026-07-15/after/14-mobile-workspace-agent-bindings.png index bc4ad07d..8a387716 100644 Binary files a/docs/audits/agent-studio-2026-07-15/after/14-mobile-workspace-agent-bindings.png and b/docs/audits/agent-studio-2026-07-15/after/14-mobile-workspace-agent-bindings.png differ diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md index 2aa1c3a6..1544e9c6 100644 --- a/docs/engineering/work-management.md +++ b/docs/engineering/work-management.md @@ -22,6 +22,13 @@ Branch names include the issue key and owner namespace, for example Never work directly in a long-lived integration/release branch or in the production checkout. +The lease is owned by a concrete execution connection, not merely an Agent +Profile. Two MCP clients, or an MCP client and a managed runtime, remain +distinct participants even when they act as the same logical agent. One +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. + ## Project branch contract Branch topology is project configuration, not an Axiom-wide constant. Every @@ -56,6 +63,9 @@ integration, scheduled stabilization, or an upstream convention. mirrors those facts into the delivery lifecycle. - Resolve file overlap in the PR. Do not move uncommitted patches between worktrees or share one worktree between tasks. +- If dispatch discovers an existing primary connection, block a second execute + attempt and ask the operator to join, hand off, or cancel. Never infer that + two connections are the same owner from a shared Agent Profile or API key. ## Delivery lifecycle diff --git a/prisma/migrations/20260716170000_agent_connections/migration.sql b/prisma/migrations/20260716170000_agent_connections/migration.sql new file mode 100644 index 00000000..4e063491 --- /dev/null +++ b/prisma/migrations/20260716170000_agent_connections/migration.sql @@ -0,0 +1,200 @@ +-- Separate logical agent identity from the concrete endpoint performing work. +-- Legacy provenance is backfilled only where the transport is unambiguous; +-- ambiguous FORGE_AGENT sessions intentionally remain nullable. + +CREATE TYPE "AgentConnectionKind" AS ENUM ('MANAGED_RUNTIME', 'MCP_CLIENT', 'WEBHOOK', 'ON_DEMAND'); +CREATE TYPE "AgentConnectionLiveness" AS ENUM ('HEARTBEAT', 'LEASE', 'ON_DEMAND', 'PUSH_ACK'); +CREATE TYPE "AgentConnectionStatus" AS ENUM ('ACTIVE', 'QUIET', 'DISCONNECTED', 'REVOKED'); +CREATE TYPE "LivenessConfidence" AS ENUM ('UNCONFIRMED', 'INFERRED', 'CONFIRMED'); +CREATE TYPE "AgentConnectionCapability" AS ENUM ('HEARTBEAT', 'LIFECYCLE_REPORTING', 'CANCELLATION', 'STREAMING', 'TOOL_ACTIVITY', 'PUSH_ACK'); +CREATE TYPE "WorkSessionParticipantRole" AS ENUM ('PRIMARY', 'CONTRIBUTOR', 'REVIEWER'); + +CREATE TABLE "AgentConnection" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "kind" "AgentConnectionKind" NOT NULL, + "livenessModel" "AgentConnectionLiveness" NOT NULL, + "status" "AgentConnectionStatus" NOT NULL DEFAULT 'QUIET', + "confidence" "LivenessConfidence" NOT NULL DEFAULT 'UNCONFIRMED', + "runtimeId" TEXT, + "apiKeyId" TEXT, + "instanceKey" TEXT, + "displayName" TEXT, + "clientName" TEXT, + "clientVersion" TEXT, + "capabilities" "AgentConnectionCapability"[] NOT NULL DEFAULT ARRAY[]::"AgentConnectionCapability"[], + "metadata" JSONB, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3), + "connectedAt" TIMESTAMP(3), + "disconnectedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AgentConnection_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "AgentRun" + ADD COLUMN "connectionId" TEXT, + ADD COLUMN "lifecycleConfidence" "LivenessConfidence" NOT NULL DEFAULT 'UNCONFIRMED'; + +ALTER TABLE "AgentRunEvent" ADD COLUMN "connectionId" TEXT; +ALTER TABLE "WorkSession" ADD COLUMN "ownerConnectionId" TEXT; + +CREATE TABLE "WorkSessionParticipant" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "workSessionId" TEXT NOT NULL, + "connectionId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "role" "WorkSessionParticipantRole" NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "leftAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "WorkSessionParticipant_pkey" PRIMARY KEY ("id") +); + +-- Managed runtimes are the strongest deterministic legacy mapping. +INSERT INTO "AgentConnection" ( + "id", "workspaceId", "agentId", "kind", "livenessModel", "status", + "confidence", "runtimeId", "instanceKey", "displayName", "capabilities", + "firstSeenAt", "lastSeenAt", "connectedAt", "createdAt", "updatedAt" +) +SELECT + 'ac_' || md5(a."id" || ':runtime:' || a."runtimeId"), + a."workspaceId", a."id", 'MANAGED_RUNTIME', 'HEARTBEAT', + CASE WHEN a."status" = 'OFFLINE' THEN 'QUIET'::"AgentConnectionStatus" ELSE 'ACTIVE'::"AgentConnectionStatus" END, + 'INFERRED', a."runtimeId", 'runtime:' || a."runtimeId", r."name", + ARRAY['HEARTBEAT', 'LIFECYCLE_REPORTING', 'CANCELLATION', 'STREAMING']::"AgentConnectionCapability"[], + COALESCE(a."createdAt", CURRENT_TIMESTAMP), + COALESCE(a."lastHeartbeatAt", r."heartbeatAt"), r."connectedAt", + COALESCE(a."createdAt", CURRENT_TIMESTAMP), CURRENT_TIMESTAMP +FROM "Agent" a +JOIN "Runtime" r ON r."id" = a."runtimeId" +WHERE a."runtimeId" IS NOT NULL +ON CONFLICT DO NOTHING; + +-- CODEX_DESKTOP is an explicit historical transport signal. One conservative +-- legacy client is created per agent; future MCP initialize calls register +-- their real session/client instances separately. +INSERT INTO "AgentConnection" ( + "id", "workspaceId", "agentId", "kind", "livenessModel", "status", + "confidence", "instanceKey", "displayName", "clientName", "capabilities", + "firstSeenAt", "lastSeenAt", "createdAt", "updatedAt" +) +SELECT + 'ac_' || md5(a."id" || ':legacy:codex-desktop'), + a."workspaceId", a."id", 'MCP_CLIENT', 'LEASE', 'QUIET', 'INFERRED', + 'legacy:codex-desktop:' || a."id", 'Codex Desktop (legacy)', 'Codex Desktop', + ARRAY['TOOL_ACTIVITY']::"AgentConnectionCapability"[], + MIN(ws."createdAt"), MAX(ws."lastHeartbeatAt"), MIN(ws."createdAt"), CURRENT_TIMESTAMP +FROM "WorkSession" ws +JOIN "Agent" a ON a."id" = ws."ownerAgentId" +WHERE ws."source" = 'CODEX_DESKTOP' +GROUP BY a."id", a."workspaceId" +ON CONFLICT DO NOTHING; + +-- A configured direct webhook is also an unambiguous endpoint. Keep it +-- distinct even when the same agent additionally has a managed runtime. +INSERT INTO "AgentConnection" ( + "id", "workspaceId", "agentId", "kind", "livenessModel", "status", + "confidence", "instanceKey", "displayName", "capabilities", + "firstSeenAt", "lastSeenAt", "createdAt", "updatedAt" +) +SELECT + 'ac_' || md5(a."id" || ':webhook'), a."workspaceId", a."id", + 'WEBHOOK', 'PUSH_ACK', 'QUIET', 'UNCONFIRMED', + 'legacy:webhook:' || a."id", a."name" || ' webhook', + ARRAY['PUSH_ACK']::"AgentConnectionCapability"[], + a."createdAt", a."lastHeartbeatAt", a."createdAt", CURRENT_TIMESTAMP +FROM "Agent" a +WHERE a."webhookUrl" IS NOT NULL +ON CONFLICT DO NOTHING; + +-- Backfill work-session provenance only for explicit Codex Desktop sessions or +-- an agent's managed runtime. FORGE_AGENT without a runtime remains unknown: +-- linked API keys historically represented both identity and transport. +UPDATE "WorkSession" ws +SET "ownerConnectionId" = ac."id" +FROM "AgentConnection" ac +WHERE ws."ownerAgentId" = ac."agentId" + AND ws."workspaceId" = ac."workspaceId" + AND ( + (ws."source" = 'CODEX_DESKTOP' AND ac."kind" = 'MCP_CLIENT') + OR + (ws."source" = 'FORGE_AGENT' AND ac."kind" = 'MANAGED_RUNTIME') + ); + +-- A run can be mapped when its agent has one managed runtime. Other historical +-- runs stay unconfirmed rather than acquiring fabricated MCP provenance. +UPDATE "AgentRun" ar +SET "connectionId" = ac."id", "lifecycleConfidence" = 'INFERRED' +FROM "AgentConnection" ac +WHERE ar."agentId" = ac."agentId" + AND ar."workspaceId" = ac."workspaceId" + AND ac."kind" = 'MANAGED_RUNTIME'; + +UPDATE "AgentRunEvent" are +SET "connectionId" = ar."connectionId" +FROM "AgentRun" ar +WHERE are."runId" = ar."id" AND ar."connectionId" IS NOT NULL; + +INSERT INTO "WorkSessionParticipant" ( + "id", "workspaceId", "workSessionId", "connectionId", "agentId", "role", + "joinedAt", "createdAt", "updatedAt" +) +SELECT + 'wsp_' || md5(ws."id" || ':' || ws."ownerConnectionId"), + ws."workspaceId", ws."id", ws."ownerConnectionId", ws."ownerAgentId", + 'PRIMARY', ws."createdAt", ws."createdAt", CURRENT_TIMESTAMP +FROM "WorkSession" ws +WHERE ws."ownerConnectionId" IS NOT NULL AND ws."ownerAgentId" IS NOT NULL +ON CONFLICT DO NOTHING; + +CREATE UNIQUE INDEX "AgentConnection_workspaceId_agentId_kind_instanceKey_key" + ON "AgentConnection"("workspaceId", "agentId", "kind", "instanceKey"); +CREATE INDEX "AgentConnection_workspaceId_status_lastSeenAt_idx" + ON "AgentConnection"("workspaceId", "status", "lastSeenAt"); +CREATE INDEX "AgentConnection_agentId_kind_status_idx" + ON "AgentConnection"("agentId", "kind", "status"); +CREATE INDEX "AgentConnection_runtimeId_idx" ON "AgentConnection"("runtimeId"); +CREATE INDEX "AgentConnection_apiKeyId_idx" ON "AgentConnection"("apiKeyId"); +CREATE INDEX "AgentRun_connectionId_status_idx" ON "AgentRun"("connectionId", "status"); +CREATE INDEX "AgentRunEvent_connectionId_createdAt_idx" ON "AgentRunEvent"("connectionId", "createdAt"); +CREATE INDEX "WorkSession_ownerConnectionId_status_idx" ON "WorkSession"("ownerConnectionId", "status"); +CREATE UNIQUE INDEX "WorkSessionParticipant_workSessionId_connectionId_key" + ON "WorkSessionParticipant"("workSessionId", "connectionId"); +CREATE INDEX "WorkSessionParticipant_workspaceId_role_leftAt_idx" + ON "WorkSessionParticipant"("workspaceId", "role", "leftAt"); +CREATE INDEX "WorkSessionParticipant_agentId_leftAt_idx" + ON "WorkSessionParticipant"("agentId", "leftAt"); +CREATE INDEX "WorkSessionParticipant_connectionId_leftAt_idx" + ON "WorkSessionParticipant"("connectionId", "leftAt"); +CREATE UNIQUE INDEX "WorkSessionParticipant_one_active_primary" + ON "WorkSessionParticipant"("workSessionId") + WHERE "role" = 'PRIMARY' AND "leftAt" IS NULL; + +ALTER TABLE "AgentConnection" ADD CONSTRAINT "AgentConnection_workspaceId_fkey" + FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AgentConnection" ADD CONSTRAINT "AgentConnection_agentId_fkey" + FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AgentConnection" ADD CONSTRAINT "AgentConnection_runtimeId_fkey" + FOREIGN KEY ("runtimeId") REFERENCES "Runtime"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "AgentConnection" ADD CONSTRAINT "AgentConnection_apiKeyId_fkey" + FOREIGN KEY ("apiKeyId") REFERENCES "ApiKey"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_connectionId_fkey" + FOREIGN KEY ("connectionId") REFERENCES "AgentConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "AgentRunEvent" ADD CONSTRAINT "AgentRunEvent_connectionId_fkey" + FOREIGN KEY ("connectionId") REFERENCES "AgentConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "WorkSession" ADD CONSTRAINT "WorkSession_ownerConnectionId_fkey" + FOREIGN KEY ("ownerConnectionId") REFERENCES "AgentConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "WorkSessionParticipant" ADD CONSTRAINT "WorkSessionParticipant_workspaceId_fkey" + FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkSessionParticipant" ADD CONSTRAINT "WorkSessionParticipant_workSessionId_fkey" + FOREIGN KEY ("workSessionId") REFERENCES "WorkSession"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkSessionParticipant" ADD CONSTRAINT "WorkSessionParticipant_connectionId_fkey" + FOREIGN KEY ("connectionId") REFERENCES "AgentConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkSessionParticipant" ADD CONSTRAINT "WorkSessionParticipant_agentId_fkey" + FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 55eaca4a..d706c7e3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -284,6 +284,56 @@ enum WorkSessionSource { MANUAL } +/// The transport or execution endpoint through which an agent binding acts. +/// This is deliberately separate from AgentProvider: Codex, Hermes, or a +/// custom profile can each be reached through more than one connection kind. +enum AgentConnectionKind { + MANAGED_RUNTIME + MCP_CLIENT + WEBHOOK + ON_DEMAND +} + +/// How Forge can determine whether an AgentConnection is still available. +enum AgentConnectionLiveness { + HEARTBEAT + LEASE + ON_DEMAND + PUSH_ACK +} + +/// Last observed operational state for a connection. QUIET is not a failure: +/// it means Forge lacks a recent positive signal for this endpoint. +enum AgentConnectionStatus { + ACTIVE + QUIET + DISCONNECTED + REVOKED +} + +/// Confidence attached to lifecycle assertions. MCP silence and external Git +/// activity generally produce INFERRED/UNCONFIRMED rather than CONFIRMED. +enum LivenessConfidence { + UNCONFIRMED + INFERRED + CONFIRMED +} + +enum AgentConnectionCapability { + HEARTBEAT + LIFECYCLE_REPORTING + CANCELLATION + STREAMING + TOOL_ACTIVITY + PUSH_ACK +} + +enum WorkSessionParticipantRole { + PRIMARY + CONTRIBUTOR + REVIEWER +} + enum ChatRole { USER AGENT @@ -1089,22 +1139,22 @@ model Workspace { /// Minimum interval between operator/agent manual refresh requests per PR. githubManualCooldownSeconds Int @default(30) /// Timeout for one interactive runtime connector HTTP request. - connectorRequestTimeoutSeconds Int @default(15) + connectorRequestTimeoutSeconds Int @default(15) /// Total connector delivery attempts before the delivery is dead-lettered. - connectorDeliveryMaxAttempts Int @default(6) + connectorDeliveryMaxAttempts Int @default(6) /// Lease for an in-flight connector delivery. The stream heartbeat renews /// it; the worker safely reclaims it after a process crash. - connectorProcessingLeaseSeconds Int @default(300) + connectorProcessingLeaseSeconds Int @default(300) /// Initial retry delay for connector deliveries; retries grow exponentially. - connectorRetryInitialSeconds Int @default(2) + connectorRetryInitialSeconds Int @default(2) /// Maximum connector retry delay. - connectorRetryMaxSeconds Int @default(300) + connectorRetryMaxSeconds Int @default(300) /// Total generic webhook attempts before a delivery is dead-lettered. - webhookRetryMaxAttempts Int @default(6) + webhookRetryMaxAttempts Int @default(6) /// Initial retry delay for generic webhook deliveries. - webhookRetryInitialSeconds Int @default(5) + webhookRetryInitialSeconds Int @default(5) /// Maximum generic webhook retry delay. - webhookRetryMaxSeconds Int @default(300) + webhookRetryMaxSeconds Int @default(300) /// An active code work session with no heartbeat beyond this threshold is /// surfaced as stale. 0 disables automatic stale marking. workSessionStaleMinutes Int @default(120) @@ -1159,6 +1209,7 @@ model Workspace { notificationStates NotificationState[] plugins Plugin[] apiKeys ApiKey[] + agentConnections AgentConnection[] webhooks Webhook[] metrics MetricAggregate[] skills Skill[] @@ -1190,6 +1241,7 @@ model Workspace { externalResourceLinks ExternalResourceLink[] externalWebhookEvents ExternalWebhookEvent[] workSessions WorkSession[] + workSessionParticipants WorkSessionParticipant[] pins Pin[] recentItems RecentItem[] notes Note[] @@ -2004,10 +2056,11 @@ model ApiKey { revokedAt DateTime? createdAt DateTime @default(now()) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - plugin Plugin? @relation(fields: [pluginId], references: [id], onDelete: Cascade) - linkedAgent Agent? @relation("AgentApiKeys", fields: [linkedAgentId], references: [id], onDelete: SetNull) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + plugin Plugin? @relation(fields: [pluginId], references: [id], onDelete: Cascade) + linkedAgent Agent? @relation("AgentApiKeys", fields: [linkedAgentId], references: [id], onDelete: SetNull) + agentConnections AgentConnection[] @@index([workspaceId]) @@index([prefix]) @@ -2237,14 +2290,15 @@ model Runtime { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull) - agents Agent[] @relation("AgentRuntime") - agentProfiles AgentProfile[] @relation("AgentProfileRuntime") - secrets RuntimeSecret[] - repos RuntimeRepo[] - githubApp GithubApp? @relation(fields: [githubAppId], references: [id], onDelete: SetNull) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull) + agents Agent[] @relation("AgentRuntime") + agentProfiles AgentProfile[] @relation("AgentProfileRuntime") + secrets RuntimeSecret[] + repos RuntimeRepo[] + githubApp GithubApp? @relation(fields: [githubAppId], references: [id], onDelete: SetNull) connectorSessions ConnectorSession[] + agentConnections AgentConnection[] @@index([workspaceId, archivedAt]) @@index([ownerId]) @@ -2550,51 +2604,131 @@ model ExternalResourceLink { @@index([createdById]) } +/// One concrete endpoint through which an Agent workspace binding acts. +/// Identity (Agent), credential (ApiKey), compute host (Runtime), and active +/// execution (AgentRun/WorkSession) remain separate concerns. +model AgentConnection { + id String @id @default(cuid()) + workspaceId String + agentId String + kind AgentConnectionKind + livenessModel AgentConnectionLiveness + status AgentConnectionStatus @default(QUIET) + confidence LivenessConfidence @default(UNCONFIRMED) + runtimeId String? + apiKeyId String? + /// Stable identity supplied by the endpoint (for MCP, the negotiated + /// session/client id). Null means the endpoint is legacy/unidentified. + instanceKey String? + displayName String? + clientName String? + clientVersion String? + capabilities AgentConnectionCapability[] @default([]) + /// Sanitized, bounded protocol/runtime metadata only; never credentials or + /// raw environment values. + metadata Json? + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime? + connectedAt DateTime? + disconnectedAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + runtime Runtime? @relation(fields: [runtimeId], references: [id], onDelete: SetNull) + apiKey ApiKey? @relation(fields: [apiKeyId], references: [id], onDelete: SetNull) + runs AgentRun[] + runEvents AgentRunEvent[] + ownedSessions WorkSession[] @relation("WorkSessionOwnerConnection") + participants WorkSessionParticipant[] + + @@unique([workspaceId, agentId, kind, instanceKey]) + @@index([workspaceId, status, lastSeenAt]) + @@index([agentId, kind, status]) + @@index([runtimeId]) + @@index([apiKeyId]) +} + /// A shared coordination lease for implementation work. It prevents a Forge /// agent, Codex Desktop task, or contributor from silently starting a second /// branch for the same issue. Terminal rows remain as the issue's delivery /// history; a partial unique index in the migration permits one active row. model WorkSession { - id String @id @default(cuid()) - workspaceId String - issueId String - ownerUserId String? - ownerAgentId String? - source WorkSessionSource - status WorkSessionStatus @default(CLAIMED) - repoFullName String - branch String - baseBranch String @default("main") + id String @id @default(cuid()) + workspaceId String + issueId String + ownerUserId String? + ownerAgentId String? + /// Concrete endpoint that owns execution/heartbeat authority. The logical + /// owner remains ownerAgentId; nullable preserves legacy/manual sessions. + ownerConnectionId String? + source WorkSessionSource + status WorkSessionStatus @default(CLAIMED) + repoFullName String + branch String + baseBranch String @default("main") /// Local path is diagnostic only; it is never used as an execution target. - worktreePath String? - headSha String? - pullRequestId String? - releasedVersion String? - deployedSha String? - lastHeartbeatAt DateTime @default(now()) - staleAt DateTime? - mergedAt DateTime? - releasedAt DateTime? - deployedAt DateTime? - verifiedAt DateTime? - endedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) - ownerUser User? @relation("WorkSessionOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull) - ownerAgent Agent? @relation("WorkSessionOwnerAgent", fields: [ownerAgentId], references: [id], onDelete: SetNull) - pullRequest ExternalResource? @relation(fields: [pullRequestId], references: [id], onDelete: SetNull) + worktreePath String? + headSha String? + pullRequestId String? + releasedVersion String? + deployedSha String? + lastHeartbeatAt DateTime @default(now()) + staleAt DateTime? + mergedAt DateTime? + releasedAt DateTime? + deployedAt DateTime? + verifiedAt DateTime? + endedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + ownerUser User? @relation("WorkSessionOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull) + ownerAgent Agent? @relation("WorkSessionOwnerAgent", fields: [ownerAgentId], references: [id], onDelete: SetNull) + ownerConnection AgentConnection? @relation("WorkSessionOwnerConnection", fields: [ownerConnectionId], references: [id], onDelete: SetNull) + pullRequest ExternalResource? @relation(fields: [pullRequestId], references: [id], onDelete: SetNull) + participants WorkSessionParticipant[] @@unique([workspaceId, repoFullName, branch]) @@index([workspaceId, status, lastHeartbeatAt]) @@index([issueId, createdAt]) @@index([ownerUserId, status]) @@index([ownerAgentId, status]) + @@index([ownerConnectionId, status]) @@index([pullRequestId]) } +/// Explicit connection-level collaboration on one delivery lease. The +/// denormalized agentId makes attribution durable and cheap to query even if a +/// connection is later revoked. Exactly one active PRIMARY is enforced by a +/// partial unique index in the migration. +model WorkSessionParticipant { + id String @id @default(cuid()) + workspaceId String + workSessionId String + connectionId String + agentId String + role WorkSessionParticipantRole + joinedAt DateTime @default(now()) + leftAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade) + connection AgentConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + + @@unique([workSessionId, connectionId]) + @@index([workspaceId, role, leftAt]) + @@index([agentId, leftAt]) + @@index([connectionId, leftAt]) +} + /// Inbound external webhook idempotency and diagnostics. For GitHub this /// stores `X-GitHub-Delivery` plus event/action so redeliveries can be /// acknowledged without duplicating Forge side effects. @@ -2773,35 +2907,37 @@ model Agent { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - profile AgentProfile? @relation("AgentProfileBinding", fields: [profileId], references: [id], onDelete: SetNull) - runtime Runtime? @relation("AgentRuntime", fields: [runtimeId], references: [id], onDelete: SetNull) - assignedIssues Issue[] @relation("IssueAssignedAgent") - claimedIssues Issue[] @relation("IssueClaimedByAgent") - apiKeys ApiKey[] @relation("AgentApiKeys") - authoredComments Comment[] @relation("AgentAuthoredComments") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + profile AgentProfile? @relation("AgentProfileBinding", fields: [profileId], references: [id], onDelete: SetNull) + runtime Runtime? @relation("AgentRuntime", fields: [runtimeId], references: [id], onDelete: SetNull) + assignedIssues Issue[] @relation("IssueAssignedAgent") + claimedIssues Issue[] @relation("IssueClaimedByAgent") + apiKeys ApiKey[] @relation("AgentApiKeys") + authoredComments Comment[] @relation("AgentAuthoredComments") dispatchRules DispatchRule[] runs AgentRun[] + connections AgentConnection[] + workSessionParticipants WorkSessionParticipant[] chatThreads ChatThread[] connectorSessions ConnectorSession[] issueWatches IssueWatcher[] - authoredArtifacts Artifact[] @relation("ArtifactCreatorAgent") - authoredArtifactVersions ArtifactVersion[] @relation("ArtifactVersionCreatorAgent") + authoredArtifacts Artifact[] @relation("ArtifactCreatorAgent") + authoredArtifactVersions ArtifactVersion[] @relation("ArtifactVersionCreatorAgent") artifactGrants ArtifactGrant[] - artifactComments ArtifactComment[] @relation("ArtifactCommentAuthorAgent") - artifactDeployments ArtifactDeployment[] @relation("ArtifactDeploymentCreatorAgent") - ownedContextSets ContextSet[] @relation("ContextSetOwnerAgent") - createdExecutionPlans ExecutionPlan[] @relation("ExecutionPlanCreatorAgent") - createdGoals Goal[] @relation("GoalCreatorAgent") - executionSteps ExecutionStep[] @relation("ExecutionStepAgent") - crewMemberships AgentCrewMember[] @relation("AgentCrewMember") - requestedReviewGates ReviewGate[] @relation("ReviewGateRequesterAgent") - resolvedReviewGates ReviewGate[] @relation("ReviewGateResolverAgent") - requestedActionRequests ActionRequest[] @relation("ActionRequestRequesterAgent") - assignedActionRequests ActionRequest[] @relation("ActionRequestAssigneeAgent") - actorAuditLogs AuditLog[] @relation("AuditLogActorAgent") - actorActivityEvents ActivityEvent[] @relation("ActivityEventActorAgent") - ownedWorkSessions WorkSession[] @relation("WorkSessionOwnerAgent") + artifactComments ArtifactComment[] @relation("ArtifactCommentAuthorAgent") + artifactDeployments ArtifactDeployment[] @relation("ArtifactDeploymentCreatorAgent") + ownedContextSets ContextSet[] @relation("ContextSetOwnerAgent") + createdExecutionPlans ExecutionPlan[] @relation("ExecutionPlanCreatorAgent") + createdGoals Goal[] @relation("GoalCreatorAgent") + executionSteps ExecutionStep[] @relation("ExecutionStepAgent") + crewMemberships AgentCrewMember[] @relation("AgentCrewMember") + requestedReviewGates ReviewGate[] @relation("ReviewGateRequesterAgent") + resolvedReviewGates ReviewGate[] @relation("ReviewGateResolverAgent") + requestedActionRequests ActionRequest[] @relation("ActionRequestRequesterAgent") + assignedActionRequests ActionRequest[] @relation("ActionRequestAssigneeAgent") + actorAuditLogs AuditLog[] @relation("AuditLogActorAgent") + actorActivityEvents ActivityEvent[] @relation("ActivityEventActorAgent") + ownedWorkSessions WorkSession[] @relation("WorkSessionOwnerAgent") @@unique([workspaceId, profileKey]) @@index([workspaceId, status]) @@ -2862,7 +2998,11 @@ model AgentRun { workspaceId String issueId String agentId String + /// Endpoint that originated/owns this execution attempt. Nullable for + /// legacy runs and manual activity whose transport cannot be established. + connectionId String? status AgentRunStatus @default(ACTIVE) + lifecycleConfidence LivenessConfidence @default(UNCONFIRMED) startedAt DateTime @default(now()) /// Updated on every appended AgentRunEvent — what the watchdog reads. lastEventAt DateTime @default(now()) @@ -3014,21 +3154,23 @@ model AgentRun { /// not an enum, for the same anti-churn reason as `triggerKind`. runEngineSource String? - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) - agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) - executionStep ExecutionStep? @relation("AgentRunStep", fields: [executionStepId], references: [id], onDelete: SetNull) - controlRequestedBy User? @relation("AgentRunControlRequester", fields: [controlRequestedById], references: [id], onDelete: SetNull) - clearedBy User? @relation("AgentRunClearer", fields: [clearedById], references: [id], onDelete: SetNull) - supersededBy AgentRun? @relation("AgentRunSupersede", fields: [supersededByRunId], references: [id], onDelete: SetNull) - supersedes AgentRun[] @relation("AgentRunSupersede") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + connection AgentConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull) + executionStep ExecutionStep? @relation("AgentRunStep", fields: [executionStepId], references: [id], onDelete: SetNull) + controlRequestedBy User? @relation("AgentRunControlRequester", fields: [controlRequestedById], references: [id], onDelete: SetNull) + clearedBy User? @relation("AgentRunClearer", fields: [clearedById], references: [id], onDelete: SetNull) + supersededBy AgentRun? @relation("AgentRunSupersede", fields: [supersededByRunId], references: [id], onDelete: SetNull) + supersedes AgentRun[] @relation("AgentRunSupersede") events AgentRunEvent[] - statusComment Comment? @relation("AgentRunStatusComment") + statusComment Comment? @relation("AgentRunStatusComment") @@index([workspaceId, status, lastEventAt]) @@index([workspaceId, status, clearedAt, lastEventAt]) @@index([issueId, startedAt]) @@index([agentId, status]) + @@index([connectionId, status]) /// Inbox query: "what does Victor owe right now?" Filters unacked /// (acknowledgedAt IS NULL) active runs ordered by recency. @@index([workspaceId, agentId, acknowledgedAt, lastEventAt]) @@ -3055,18 +3197,23 @@ model AgentRun { /// their loop without schema churn — STARTED, STEP, TOOL_CALL, STATUS, /// BLOCKED, COMPLETED, ERRORED, … are conventional. model AgentRunEvent { - id String @id @default(cuid()) - workspaceId String - runId String - kind String - payload Json? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + workspaceId String + runId String + /// Connection that reported this event. It may differ from the run's owner + /// for explicitly joined reviewer/contributor activity. + connectionId String? + kind String + payload Json? + createdAt DateTime @default(now()) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade) + connection AgentConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull) @@index([runId, createdAt]) @@index([workspaceId, createdAt]) + @@index([connectionId, createdAt]) } model ChatThread { @@ -3095,11 +3242,11 @@ model ChatThread { createdAt DateTime @default(now()) archivedAt DateTime? - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) - messages ChatMessage[] - reads ChatThreadRead[] + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + messages ChatMessage[] + reads ChatThreadRead[] connectorSessions ConnectorSession[] @@index([workspaceId, userId, agentId, lastMessageAt]) @@ -3163,14 +3310,14 @@ model ChatMessage { lastWakeDeliveryId String? createdAt DateTime @default(now()) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) - connectorSession ConnectorSession? @relation(fields: [connectorSessionId], references: [id], onDelete: SetNull) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) + connectorSession ConnectorSession? @relation(fields: [connectorSessionId], references: [id], onDelete: SetNull) connectorDeliveries ConnectorDelivery[] - @@index([threadId, createdAt]) @@unique([threadId, sequence]) @@unique([connectorSessionId, externalMessageId]) + @@index([threadId, createdAt]) @@index([replyToMessageId]) @@index([connectorSessionId, createdAt]) @@index([workspaceId, createdAt]) @@ -3183,41 +3330,41 @@ model ChatMessage { /// native interactive session. Hermes `/v1/runs` remains represented by /// AgentRun.externalRunId and is intentionally not stored here. model ConnectorSession { - id String @id @default(cuid()) - workspaceId String - runtimeId String - agentId String - chatThreadId String? - connectorKey String @default("hermes-sessions") - externalSessionId String - sessionClass ChatSessionClass @default(INTERACTIVE) - lifecycle ConnectorSessionLifecycle @default(CONNECTING) - ownership ConnectorSessionOwnership @default(FORGE) - memoryKey String - memoryKeyVersion Int @default(2) - protocolVersion String? - capabilities Json? - negotiatedAt DateTime? - negotiationError String? @db.Text - resumeCursor String? - lastExternalSequence Int? - subscriptionLeaseOwner String? + id String @id @default(cuid()) + workspaceId String + runtimeId String + agentId String + chatThreadId String? + connectorKey String @default("hermes-sessions") + externalSessionId String + sessionClass ChatSessionClass @default(INTERACTIVE) + lifecycle ConnectorSessionLifecycle @default(CONNECTING) + ownership ConnectorSessionOwnership @default(FORGE) + memoryKey String + memoryKeyVersion Int @default(2) + protocolVersion String? + capabilities Json? + negotiatedAt DateTime? + negotiationError String? @db.Text + resumeCursor String? + lastExternalSequence Int? + subscriptionLeaseOwner String? subscriptionLeaseExpiresAt DateTime? - lastConnectedAt DateTime? - lastEventAt DateTime? - lastDeliveryAt DateTime? - lastErrorAt DateTime? - lastError String? @db.Text - retryCount Int @default(0) - nextRetryAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - closedAt DateTime? - - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - runtime Runtime @relation(fields: [runtimeId], references: [id], onDelete: Cascade) - agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) - chatThread ChatThread? @relation(fields: [chatThreadId], references: [id], onDelete: Cascade) + lastConnectedAt DateTime? + lastEventAt DateTime? + lastDeliveryAt DateTime? + lastErrorAt DateTime? + lastError String? @db.Text + retryCount Int @default(0) + nextRetryAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + closedAt DateTime? + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + runtime Runtime @relation(fields: [runtimeId], references: [id], onDelete: Cascade) + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + chatThread ChatThread? @relation(fields: [chatThreadId], references: [id], onDelete: Cascade) messages ChatMessage[] deliveries ConnectorDelivery[] diff --git a/src/app/(app)/settings/access/page.tsx b/src/app/(app)/settings/access/page.tsx index cd1ee3e5..c7fb272f 100644 --- a/src/app/(app)/settings/access/page.tsx +++ b/src/app/(app)/settings/access/page.tsx @@ -179,8 +179,16 @@ export default function AccessPage() { message: string; }>({ state: "idle", message: "" }); - const [rotateTarget, setRotateTarget] = useState<{ id: string; name: string } | null>(null); - const [revokeTarget, setRevokeTarget] = useState<{ id: string; name: string } | null>(null); + const [rotateTarget, setRotateTarget] = useState<{ + id: string; + name: string; + activeDeliveries?: number; + } | null>(null); + const [revokeTarget, setRevokeTarget] = useState<{ + id: string; + name: string; + activeDeliveries?: number; + } | null>(null); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); const baseUrl = @@ -537,6 +545,11 @@ export default function AccessPage() { {agentKeys.map((k) => { const expired = !!k.expiresAt && !k.revokedAt && new Date(k.expiresAt) < new Date(); + const activeDeliveries = k.agentConnections.reduce( + (total, connection) => total + connection._count.ownedSessions, + 0, + ); + const latestConnection = k.agentConnections[0] ?? null; return (
  • @@ -563,13 +576,33 @@ export default function AccessPage() { {k.lastUsedAt ? `used ${relativeTime(k.lastUsedAt)}` : "never used"} {k.expiresAt && !k.revokedAt && <> . expires {relativeTime(k.expiresAt)}}
    +
    + + {k.agentConnections.length} registered client + {k.agentConnections.length === 1 ? "" : "s"} + + {latestConnection?.lastSeenAt && ( + + · last seen {relativeTime(latestConnection.lastSeenAt)} + {latestConnection.clientName || latestConnection.displayName + ? ` · ${latestConnection.displayName ?? latestConnection.clientName}` + : ""} + + )} + {activeDeliveries > 0 && ( + + {activeDeliveries} active{" "} + {activeDeliveries === 1 ? "delivery" : "deliveries"} + + )} +
    setRotateTarget({ id: k.id, name: k.name })} - onRevoke={() => setRevokeTarget({ id: k.id, name: k.name })} + onRotate={() => setRotateTarget({ id: k.id, name: k.name, activeDeliveries })} + onRevoke={() => setRevokeTarget({ id: k.id, name: k.name, activeDeliveries })} onDelete={() => setDeleteTarget({ id: k.id, name: k.name })} showRotate /> @@ -1030,7 +1063,11 @@ export default function AccessPage() { open={!!rotateTarget} onOpenChange={(o) => !o && setRotateTarget(null)} title={rotateTarget ? `Rotate ${rotateTarget.name}?` : "Rotate key?"} - description="The old key is revoked immediately. Update your agent before closing the reveal." + description={ + rotateTarget?.activeDeliveries + ? `The old key is revoked immediately and its clients own ${rotateTarget.activeDeliveries} active ${rotateTarget.activeDeliveries === 1 ? "delivery" : "deliveries"}. Copy the replacement before closing the reveal, then hand off or reconnect those sessions.` + : "The old key is revoked immediately. Update your agent before closing the reveal." + } primaryLabel="Rotate" loading={rotate.isPending} onConfirm={() => { @@ -1044,7 +1081,11 @@ export default function AccessPage() { onOpenChange={(o) => !o && setRevokeTarget(null)} variant="destructive" title={revokeTarget ? `Revoke ${revokeTarget.name}?` : "Revoke key?"} - description="Disables the key immediately. The row is retained for audit." + description={ + revokeTarget?.activeDeliveries + ? `This key's clients own ${revokeTarget.activeDeliveries} active ${revokeTarget.activeDeliveries === 1 ? "delivery" : "deliveries"}. Revoking disconnects future access but does not transfer ownership; hand off or abandon those sessions explicitly.` + : "Disables the key immediately. The row is retained for audit." + } primaryLabel="Revoke" loading={revoke.isPending} onConfirm={() => { diff --git a/src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx b/src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx index b503c49d..446b4261 100644 --- a/src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx +++ b/src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx @@ -1349,12 +1349,14 @@ function ConnectionCard({ agent }: { agent: AgentRow }) { title={ - Runtime & readiness + Connections & execution } + hint="Concrete endpoints acting as this profile; identity stays stable across clients." > - {/* How chat is served — engine + runtime/transport. */} + {/* Resolved chat path is useful even before a durable endpoint has + registered (legacy clients appear below as unidentified). */}
    {transport && } {disabled && ( @@ -1364,7 +1366,21 @@ function ConnectionCard({ agent }: { agent: AgentRow }) { )}
    - {runtime ? ( + {agent.connections.length > 0 ? ( + <> +
      + {agent.connections.map((connection) => ( + + ))} +
    + {agent._count.connections > agent.connections.length && ( +
    + Showing the 25 most recently seen of {agent._count.connections} registered + connections. Older revoked and disconnected records remain in the audit trail. +
    + )} + + ) : runtime ? ( ) : ( -
    - No managed runtime attached —{" "} - {transport?.mode === "completions" - ? "chat uses a configured model (Streaming engine)." - : transport?.mode === "dispatch" - ? "chat is delivered by the agent's daemon/webhook." - : "attach a runtime or configure a model to chat."} +
    +
    No registered client instances
    +
    + {transport?.mode === "completions" + ? "Chat is available on demand; the first execution will register its endpoint." + : transport?.mode === "dispatch" + ? "Legacy dispatch is configured, but this endpoint has not registered durable provenance yet." + : "Attach a runtime or connect an MCP client to register an execution endpoint."} +
    )} @@ -1434,6 +1452,85 @@ function ConnectionCard({ agent }: { agent: AgentRow }) { ); } +type AgentConnectionRowData = AgentRow["connections"][number]; + +const CONNECTION_KIND_LABEL: Record = { + MANAGED_RUNTIME: "Runtime", + MCP_CLIENT: "MCP", + WEBHOOK: "Webhook", + ON_DEMAND: "On-demand", +}; + +function AgentConnectionRow({ connection }: { connection: AgentConnectionRowData }) { + const name = + connection.displayName ?? + connection.clientName ?? + connection.runtime?.name ?? + CONNECTION_KIND_LABEL[connection.kind]; + const kind = CONNECTION_KIND_LABEL[connection.kind]; + const confirmedActive = connection.status === "ACTIVE" && connection.confidence === "CONFIRMED"; + const unconfirmedQuiet = connection.status === "QUIET" && connection.confidence === "UNCONFIRMED"; + const statusLabel = unconfirmedQuiet + ? "Quiet · status unconfirmed" + : connection.status === "ACTIVE" + ? connection.confidence === "CONFIRMED" + ? "Confirmed active" + : "Activity inferred" + : connection.status.toLowerCase(); + const statusTone = + connection.status === "REVOKED" || connection.status === "DISCONNECTED" + ? "text-danger" + : unconfirmedQuiet + ? "text-warning" + : confirmedActive + ? "text-success" + : "text-muted-foreground"; + const lastSignal = connection.lastSeenAt ?? connection.connectedAt ?? connection.firstSeenAt; + + return ( +
  • +
    +
    + {connection.kind === "MCP_CLIENT" && connection.confidence !== "CONFIRMED" && ( +
    + MCP activity proves recent access, not that the client process is still running. +
    + )} +
  • + ); +} + function ReadinessRow({ label, children }: { label: string; children: React.ReactNode }) { return (
    diff --git a/src/app/(app)/w/[slug]/command-center/page.tsx b/src/app/(app)/w/[slug]/command-center/page.tsx index e446659b..7306d10b 100644 --- a/src/app/(app)/w/[slug]/command-center/page.tsx +++ b/src/app/(app)/w/[slug]/command-center/page.tsx @@ -263,7 +263,7 @@ export default function CommandCenterPage() { ) : null} {data.stalledRuns.length > 0 ? ( ; + connection: { + kind: "MANAGED_RUNTIME" | "MCP_CLIENT" | "WEBHOOK" | "ON_DEMAND"; + status: "ACTIVE" | "QUIET" | "DISCONNECTED" | "REVOKED"; + confidence: "UNCONFIRMED" | "INFERRED" | "CONFIRMED"; + displayName: string | null; + clientName: string | null; + lastSeenAt: Date | string | null; + } | null; agent: { profileKey: string }; issue: { id: string; @@ -1250,7 +1258,7 @@ function groupRunRecoveries(runs: CCRunFailure[]): CCRunFailure[][] { // failures remain issue-specific because their recovery evidence differs. const key = run.recoveryReason === "active-stale" - ? `${run.agent.profileKey}:${run.recoveryReason}` + ? `${run.agent.profileKey}:${run.recoveryReason}:${run.connection?.kind ?? "legacy"}` : run.id; const group = groups.get(key) ?? []; group.push(run); @@ -1346,6 +1354,7 @@ function RunFailureCard({ const ts = run.finishedAt ?? run.lastEventAt; const excerpt = run.recoveryDetail ?? run.summary ?? run.currentStep ?? run.issue.title; const ActionIcon = recoveryActionIcon(run.recommendedAction); + const presentation = runRecoveryPresentation(run); return ( <>
    @@ -1357,19 +1366,19 @@ function RunFailureCard({ > {run.issue.workspace.key}-{run.issue.number} - - {run.recoveryReason.replace("-", " ")} + + {presentation.badge}
    - {run.recoveryTitle} + {presentation.title}
    - {canRecover ? ( + {canRecover && !presentation.inspectFirst ? ( + ) : presentation.inspectFirst ? ( + + Inspect + ) : null} {confirmElement} @@ -1405,6 +1421,62 @@ function RunFailureCard({ ); } +function runRecoveryPresentation(run: CCRunFailure): { + badge: string; + title: string; + connection: string; + guidance: string; + tone: string; + inspectFirst: boolean; +} { + const connection = run.connection; + const name = connection?.displayName ?? connection?.clientName; + if ( + connection?.kind === "MCP_CLIENT" && + run.recoveryReason === "active-stale" && + connection.confidence !== "CONFIRMED" + ) { + return { + badge: "MCP · unconfirmed", + title: "MCP client is quiet; lifecycle is unconfirmed", + connection: name ? `MCP · ${name}` : "MCP client", + guidance: + "Silence alone is not a confirmed stall; inspect evidence before handoff or abandon.", + tone: "bg-warning/10 text-warning", + inspectFirst: true, + }; + } + if (connection?.kind === "MANAGED_RUNTIME") { + const confirmed = connection.confidence === "CONFIRMED"; + return { + badge: confirmed ? "Runtime · confirmed" : "Runtime · inferred", + title: confirmed ? "Managed runtime run stalled" : run.recoveryTitle, + connection: name ? `Runtime · ${name}` : "Managed runtime", + guidance: confirmed ? "Runtime lifecycle evidence confirms recovery is safe." : "", + tone: confirmed ? "bg-danger/10 text-danger" : "bg-warning/10 text-warning", + inspectFirst: false, + }; + } + if (connection?.kind === "WEBHOOK") { + return { + badge: "Webhook", + title: "Webhook-dispatched run needs recovery", + connection: name ? `Webhook · ${name}` : "Webhook", + guidance: "Check delivery acknowledgement and retries before treating the agent as stalled.", + tone: "bg-danger/10 text-danger", + inspectFirst: true, + }; + } + return { + badge: run.recoveryReason.replace("-", " "), + title: run.recoveryTitle, + connection: "Legacy or unidentified connection", + guidance: "", + tone: "bg-warning/10 text-warning", + inspectFirst: false, + }; +} + function RunRecoveryIncidentCard({ runs, slug, @@ -1419,19 +1491,20 @@ function RunRecoveryIncidentCard({ onRecover: (action: CCRunFailure["recommendedAction"], runIds: string[]) => void; }) { const first = runs[0]!; + const presentation = runRecoveryPresentation(first); return (
    - @{first.agent.profileKey} · {runs.length} affected runs + @{first.agent.profileKey} · {presentation.badge} · {runs.length} affected runs
    - {canRecover ? ( + {canRecover && !presentation.inspectFirst ? ( ) : null}
    diff --git a/src/app/(app)/w/[slug]/settings/agents/page.tsx b/src/app/(app)/w/[slug]/settings/agents/page.tsx index 98611913..f6d61783 100644 --- a/src/app/(app)/w/[slug]/settings/agents/page.tsx +++ b/src/app/(app)/w/[slug]/settings/agents/page.tsx @@ -1,15 +1,18 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { toast } from "sonner"; import { Activity, + AlertTriangle, ArrowRight, Bot, ChevronDown, CircleDot, Clock, MessageSquare, + Save, + ShieldCheck, X, } from "lucide-react"; import type { EngagementMode } from "@prisma/client"; @@ -113,6 +116,9 @@ export default function AgentsBindingPage() {
    + + + {/* Bound agents */}
    (EMPTY_OPERATIONS_POLICY); + + useEffect(() => { + if (!current) return; + setPolicy({ + agentIdleTimeoutMinutes: current.agentIdleTimeoutMinutes, + assignmentSlaMinutes: current.assignmentSlaMinutes, + agentRunStaleMinutes: current.agentRunStaleMinutes, + agentProgressUpdateMinutes: current.agentProgressUpdateMinutes, + agentRunQuietMinutes: current.agentRunQuietMinutes, + reviewStartTimeoutMinutes: current.reviewStartTimeoutMinutes, + workSessionStaleMinutes: current.workSessionStaleMinutes ?? 120, + requiredAckSeconds: current.requiredAckSeconds, + autoRedispatchOnStall: current.autoRedispatchOnStall, + autoRedispatchOnNoack: current.autoRedispatchOnNoack, + }); + }, [current]); + + const dirty = useMemo(() => { + if (!current) return false; + return ( + policy.agentIdleTimeoutMinutes !== current.agentIdleTimeoutMinutes || + policy.assignmentSlaMinutes !== current.assignmentSlaMinutes || + policy.agentRunStaleMinutes !== current.agentRunStaleMinutes || + policy.agentProgressUpdateMinutes !== current.agentProgressUpdateMinutes || + policy.agentRunQuietMinutes !== current.agentRunQuietMinutes || + policy.reviewStartTimeoutMinutes !== current.reviewStartTimeoutMinutes || + policy.workSessionStaleMinutes !== (current.workSessionStaleMinutes ?? 120) || + policy.requiredAckSeconds !== current.requiredAckSeconds || + policy.autoRedispatchOnStall !== current.autoRedispatchOnStall || + policy.autoRedispatchOnNoack !== current.autoRedispatchOnNoack + ); + }, [current, policy]); + + const update = trpc.workspace.update.useMutation({ + onSuccess: () => { + toast.success("Agent detection policy updated."); + void utils.workspace.current.invalidate(); + }, + onError: (error) => toast.error(error.message), + }); + + const setNumber = (key: keyof OperationsPolicy, value: string) => { + setPolicy((prior) => ({ ...prior, [key]: Math.max(0, Number(value) || 0) })); + }; + + const save = () => { + if (!isAdmin || !dirty || update.isPending) return; + update.mutate(policy); + }; + + return ( +
    + + {update.isPending ? "Saving…" : "Save policy"} + + } + > + +
    + setNumber("agentRunQuietMinutes", value)} + /> + setNumber("agentProgressUpdateMinutes", value)} + /> + setNumber("workSessionStaleMinutes", value)} + /> +
    + +
    + +
    + Transport-aware recovery. Managed + runtimes may be confirmed stalled from lifecycle signals. MCP silence is shown as status + unconfirmed and never triggers redispatch by itself. +
    +
    + +
    + + + Advanced detection policy + + 0 disables a threshold + + +
    + setNumber("agentIdleTimeoutMinutes", value)} + /> + setNumber("assignmentSlaMinutes", value)} + /> + setNumber("agentRunStaleMinutes", value)} + /> + setNumber("reviewStartTimeoutMinutes", value)} + /> + setNumber("requiredAckSeconds", value)} + /> + + setPolicy((prior) => ({ ...prior, autoRedispatchOnStall: checked })) + } + /> + + setPolicy((prior) => ({ ...prior, autoRedispatchOnNoack: checked })) + } + /> +
    +
    + + {!isAdmin && ( +
    + Workspace admin access is required to edit + detection policy. +
    + )} +
    +
    + ); +} + +function AgentOperationsAttention({ slug }: { slug: string }) { + const { data: sessions, isLoading } = trpc.workSession.active.useQuery(undefined, { + refetchOnWindowFocus: true, + }); + const concerns = useMemo( + () => + (sessions ?? []).flatMap((session) => { + const connection = session.ownerConnection; + const mismatch = Boolean( + connection?.agent && session.ownerAgent && connection.agent.id !== session.ownerAgent.id, + ); + if (mismatch) { + return [ + { + session, + label: "Attribution mismatch", + detail: `${session.ownerAgent?.name ?? "Recorded owner"} owns delivery; ${connection?.agent.name ?? "another agent"} owns the connection.`, + tone: "text-danger", + }, + ]; + } + if (connection?.status === "DISCONNECTED" || connection?.status === "REVOKED") { + return [ + { + session, + label: "Connection unavailable", + detail: `The ${connection.kind.toLowerCase().replaceAll("_", " ")} endpoint is ${connection.status.toLowerCase()}.`, + tone: "text-danger", + }, + ]; + } + if ( + connection?.kind === "MCP_CLIENT" && + (connection.status === "QUIET" || connection.confidence === "UNCONFIRMED") + ) { + return [ + { + session, + label: "MCP status unconfirmed", + detail: "Delivery remains owned; silence alone will not redispatch it.", + tone: "text-warning", + }, + ]; + } + if (!connection && session.ownerAgent) { + return [ + { + session, + label: "Legacy provenance", + detail: "The logical agent is known, but the executing client was not registered.", + tone: "text-muted-foreground", + }, + ]; + } + return []; + }), + [sessions], + ); + + return ( +
    + {concerns.length} concern{concerns.length === 1 ? "" : "s"} + + ) : undefined + } + > + {isLoading ? ( + + Checking execution ownership… + + ) : concerns.length === 0 ? ( + + No connection or delivery ownership + concerns. + + ) : ( + + {concerns.map(({ session, label, detail, tone }) => ( + + + + + + {session.issue.workspace.key}-{session.issue.number} + + {session.issue.title} + + + {label} · {detail} + + + + + ))} + + )} +
    + ); +} + +function PolicyNumber({ + label, + hint, + suffix, + value, + disabled, + onChange, +}: { + label: string; + hint: string; + suffix: string; + value: number; + disabled: boolean; + onChange: (value: string) => void; +}) { + return ( + +
    + onChange(event.target.value)} + className="h-8 pr-10 font-mono" + /> + + {suffix} + +
    +
    + ); +} + +function PolicyToggle({ + label, + hint, + checked, + disabled, + onChange, +}: { + label: string; + hint: string; + checked: boolean; + disabled: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + + + ); +} + /* ── A single bound agent with inline per-binding policy ────────────── */ type BoundAgent = inferRouterOutputs["agents"]["bindings"]["list"][number]; diff --git a/src/app/(app)/w/[slug]/settings/workspace/page.tsx b/src/app/(app)/w/[slug]/settings/workspace/page.tsx index e5c7d6a6..867d84e6 100644 --- a/src/app/(app)/w/[slug]/settings/workspace/page.tsx +++ b/src/app/(app)/w/[slug]/settings/workspace/page.tsx @@ -477,19 +477,6 @@ export default function WorkspaceSettingsPage() { disabled={!canEdit} /> - - setAgentIdleTimeoutMinutes(Number(e.target.value) || 0)} - disabled={!canEdit} - /> -
    @@ -547,133 +534,9 @@ export default function WorkspaceSettingsPage() { -
    - -
    - - setAssignmentSlaMinutes(Number(e.target.value) || 0)} - disabled={!canEdit} - /> - - - setAgentRunStaleMinutes(Number(e.target.value) || 0)} - disabled={!canEdit} - /> - - - setAgentProgressUpdateMinutes(Number(e.target.value) || 0)} - disabled={!canEdit} - /> - - - setAgentRunQuietMinutes(Number(e.target.value) || 0)} - disabled={!canEdit} - /> - - - setReviewStartTimeoutMinutes(Number(e.target.value) || 0)} - disabled={!canEdit} - /> - - - setWorkSessionStaleMinutes(Number(e.target.value) || 0)} - disabled={!canEdit} - /> - -
    - - setRequiredAckSeconds(Number(e.target.value) || 0)} - disabled={!canEdit} - /> - -
    - - - -
    -
    -
    -
    {runTokenBudget === 0 && runCostBudgetUsd === 0 && runMaxMinutes === 0 && ( diff --git a/src/app/api/mcp/rpc/route.ts b/src/app/api/mcp/rpc/route.ts index 88c9b2f7..9158a710 100644 --- a/src/app/api/mcp/rpc/route.ts +++ b/src/app/api/mcp/rpc/route.ts @@ -1,4 +1,5 @@ import { Buffer } from "node:buffer"; +import { randomUUID } from "node:crypto"; import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; @@ -16,6 +17,9 @@ import { rateLimit } from "@/server/rate-limit"; import { logger } from "@/server/logger"; 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"; /** * Standard MCP (Model Context Protocol) endpoint — Streamable HTTP transport @@ -160,15 +164,96 @@ type CatalogToolRunResult = | { ok: false; rpcError: JsonRpcError } | { ok: true; result: unknown; isError?: boolean }; -function mcpContext(auth: NonNullable>>): McpContext { +function mcpContext( + auth: NonNullable>>, + connectionId?: string | null, +): McpContext { return { workspaceId: auth.workspaceId, userId: auth.userId, pluginId: auth.pluginId, apiKey: auth, + connectionId: connectionId ?? null, + }; +} + +type McpClientInfo = { name: string; version?: string }; + +function initializeClient( + body: unknown, +): { clientInfo: McpClientInfo | null; protocol?: string } | null { + const messages = Array.isArray(body) ? body : [body]; + const initialize = messages.find( + (item): item is JsonRpcRequest => + !!item && typeof item === "object" && (item as JsonRpcRequest).method === "initialize", + ); + if (!initialize) return null; + const params = + initialize.params && typeof initialize.params === "object" && !Array.isArray(initialize.params) + ? (initialize.params as Record) + : {}; + const raw = + params.clientInfo && typeof params.clientInfo === "object" && !Array.isArray(params.clientInfo) + ? (params.clientInfo as Record) + : null; + const name = typeof raw?.name === "string" ? raw.name.trim().slice(0, 120) : ""; + const version = typeof raw?.version === "string" ? raw.version.trim().slice(0, 80) : undefined; + return { + clientInfo: name ? { name, ...(version ? { version } : {}) } : null, + ...(typeof params.protocolVersion === "string" + ? { protocol: params.protocolVersion.slice(0, 40) } + : {}), }; } +async function resolveMcpConnection( + auth: NonNullable>>, + body: unknown, + suppliedSessionId: string | null, +) { + const agentId = auth.linkedAgentId; + if (!agentId) return null; + const initialize = initializeClient(body); + const sessionId = suppliedSessionId?.trim().slice(0, 255) || null; + + let connection: Awaited> | null = null; + if (!initialize && sessionId) { + const existing = await db.agentConnection.findFirst({ + where: { + workspaceId: auth.workspaceId, + agentId, + kind: "MCP_CLIENT", + instanceKey: sessionId, + revokedAt: null, + }, + }); + if (existing) connection = await touchAgentConnection(db, existing.id); + } + + if (!connection) { + const clientName = initialize?.clientInfo?.name ?? null; + connection = await upsertAgentConnection(db, { + workspaceId: auth.workspaceId, + agentId, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + apiKeyId: auth.keyId, + instanceKey: sessionId ?? (initialize ? `mcp-${randomUUID()}` : `legacy-${auth.keyId}`), + displayName: clientName || "Unidentified MCP client", + clientName, + clientVersion: initialize?.clientInfo?.version ?? null, + capabilities: ["TOOL_ACTIVITY"], + metadata: { + transport: "streamable-http", + protocolVersion: initialize?.protocol ?? PROTOCOL_VERSION, + identified: Boolean(clientName), + }, + }); + } + await resolveMcpQuietRequestsForConnection(db, auth.workspaceId, connection.id); + return connection; +} + function toolContent(result: unknown, isError = false) { return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -227,6 +312,7 @@ async function handleCatalogTool( name: CatalogToolName, args: unknown, auth: NonNullable>>, + connectionId?: string | null, ): Promise { const parsed = catalogToolDefs[name].input.safeParse(args ?? {}); if (!parsed.success) { @@ -287,7 +373,7 @@ async function handleCatalogTool( const exec = await executeMcpTool({ name: input.name, input: input.arguments, - ctx: mcpContext(auth), + ctx: mcpContext(auth, connectionId), source: "json-rpc", }); if (exec.ok) return { ok: true, result: { toolName: exec.toolName, result: exec.result } }; @@ -308,6 +394,7 @@ async function handleRpc( auth: Awaited> | null, authError: ApiKeyError | null, listOptions: ListOptions, + connectionId?: string | null, ) { const { id, method, params } = msg; @@ -358,7 +445,7 @@ async function handleRpc( if (!p.name) return fail(id, { code: -32602, message: "Missing tool name." }); if (isCatalogToolName(p.name)) { - const catalog = await handleCatalogTool(p.name, p.arguments ?? {}, auth); + const catalog = await handleCatalogTool(p.name, p.arguments ?? {}, auth, connectionId); if (!catalog.ok) return fail(id, catalog.rpcError); return ok(id, toolContent(catalog.result, catalog.isError ?? false)); } @@ -366,7 +453,7 @@ async function handleRpc( const exec = await executeMcpTool({ name: p.name, input: p.arguments ?? {}, - ctx: mcpContext(auth), + ctx: mcpContext(auth, connectionId), source: "json-rpc", }); @@ -444,6 +531,17 @@ export async function POST(req: NextRequest) { ); } + // MCP Streamable HTTP is otherwise stateless. Negotiate a durable endpoint + // identity on initialize and accept it on later requests via the standard + // session header. Older clients that omit the header get one clearly marked + // credential-level "Unidentified MCP client" connection. + const connection = auth + ? await resolveMcpConnection(auth, body, req.headers.get("mcp-session-id")) + : null; + const responseHeaders = connection?.instanceKey + ? { "Mcp-Session-Id": connection.instanceKey } + : undefined; + // `tools/list` narrowing (AXI-82): default is the compact runtime profile. // `?profile=full` opts into the full direct catalog; `?tools=issues,comments,…` // hand-picks namespaces. Configure it on the MCP server URL when a client @@ -463,15 +561,23 @@ export async function POST(req: NextRequest) { // Support both single requests and batched arrays (JSON-RPC 2.0 spec). if (Array.isArray(body)) { const responses = await Promise.all( - body.map((msg) => handleRpc(msg as JsonRpcRequest, auth, authError, listOptions)), + body.map((msg) => + handleRpc(msg as JsonRpcRequest, auth, authError, listOptions, connection?.id), + ), ); const filtered = responses.filter((r) => r != null); - return NextResponse.json(filtered); + return NextResponse.json(filtered, { headers: responseHeaders }); } - const result = await handleRpc(body as JsonRpcRequest, auth, authError, listOptions); - if (result == null) return new NextResponse(null, { status: 204 }); - return NextResponse.json(result); + const result = await handleRpc( + body as JsonRpcRequest, + auth, + authError, + listOptions, + connection?.id, + ); + if (result == null) return new NextResponse(null, { status: 204, headers: responseHeaders }); + return NextResponse.json(result, { headers: responseHeaders }); } /** diff --git a/src/components/issue-detail/issue-activity-panel.tsx b/src/components/issue-detail/issue-activity-panel.tsx index 8c32c6a0..0542a223 100644 --- a/src/components/issue-detail/issue-activity-panel.tsx +++ b/src/components/issue-detail/issue-activity-panel.tsx @@ -3,7 +3,12 @@ import { Activity as ActivityIcon } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { AgentAvatar } from "@/components/agents/agent-avatar"; -import { activityActorName, activityActorOwnerTitle } from "@/lib/activity-actor"; +import { + activityActorKind, + activityActorName, + activityActorOwnerTitle, +} from "@/lib/activity-actor"; +import { issueUpdateCopy } from "@/lib/activity-update-summary"; import { trpc } from "@/lib/trpc"; import { relativeTime } from "@/lib/utils"; @@ -152,6 +157,9 @@ function activityCopy( payload: unknown, actorAgentProfileKey?: string | null, ): { label: string; detail?: string | null; phase?: string } { + if (kind === "ISSUE_UPDATED") { + return issueUpdateCopy(payload) ?? KIND_LABEL.ISSUE_UPDATED!; + } if (kind === "AGENT_ASSIGNED") { const dispatch = readDispatch(payload); const handle = @@ -301,6 +309,7 @@ export function IssueActivityPanel({ issueId }: { issueId: string }) { {rows.map((e) => { const agent = e.actorAgent; const actorLabel = activityActorName(e); + const actorKind = activityActorKind(e); const actorOwnerTitle = activityActorOwnerTitle(e); const copy = activityCopy(e.kind, e.payload, agent?.profileKey ?? null); return ( @@ -315,19 +324,19 @@ export function IssueActivityPanel({ issueId }: { issueId: string }) { size="xs" /> ) : ( - + )}
    {actorLabel} - {agent && ( + {actorKind !== "human" && ( - agent + {actorKind} )} {copy.phase && ( diff --git a/src/components/issue-detail/work-coordination-panel.tsx b/src/components/issue-detail/work-coordination-panel.tsx index dcf22d8b..cb757767 100644 --- a/src/components/issue-detail/work-coordination-panel.tsx +++ b/src/components/issue-detail/work-coordination-panel.tsx @@ -13,12 +13,14 @@ import { X, } from "lucide-react"; import { toast } from "sonner"; +import type { inferRouterOutputs } from "@trpc/server"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Combobox } from "@/components/ui/combobox"; import { trpc } from "@/lib/trpc"; import { cn, relativeTime } from "@/lib/utils"; import { useWorkspace } from "@/hooks/use-workspace"; +import type { AppRouter } from "@/server/routers/_app"; const TERMINAL = new Set(["VERIFIED", "ABANDONED"]); @@ -91,11 +93,31 @@ export function WorkCoordinationPanel({ onSuccess: () => invalidate(), onError: (error) => toast.error(error.message), }); + const reconcileOwnership = trpc.workSession.reconcileOwnership.useMutation({ + onSuccess: () => { + toast.success("Delivery ownership reconciled."); + invalidate(); + }, + onError: (error) => toast.error(error.message), + }); const owner = latest?.ownerAgent ? `${latest.ownerAgent.name} · @${latest.ownerAgent.profileKey}` : (latest?.ownerUser?.name ?? latest?.ownerUser?.email ?? "Unassigned"); const status = latest ? (STATUS_COPY[latest.status] ?? STATUS_COPY.CLAIMED) : null; + const provenance = latest ? deliveryProvenance(latest) : null; + const connectionMismatch = Boolean( + latest?.ownerConnection?.agent && + latest.ownerAgent && + latest.ownerConnection.agent.id !== latest.ownerAgent.id, + ); + const observedMismatch = Boolean( + latest?.observedImplementation?.agent && + latest.ownerAgent && + latest.observedImplementation.agent.id !== latest.ownerAgent.id, + ); + const ownershipMismatch = connectionMismatch || observedMismatch; + const observedConnection = latest?.observedImplementation?.agent.connections[0] ?? null; return (
    {owner}
    + {provenance && ( +
    + + {provenance.connectionLabel} + + + {provenance.statusLabel} + +
    + )}
    - {latest.source.toLowerCase().replaceAll("_", " ")} · base {latest.baseBranch} · - seen {relativeTime(latest.lastHeartbeatAt)} + base {latest.baseBranch} · activity {relativeTime(latest.lastHeartbeatAt)}
    {latest.worktreePath && (
    + {ownershipMismatch && ( +
    + +
    +
    Delivery attribution mismatch
    + The delivery owner is {latest.ownerAgent?.name ?? "unknown"}, but{" "} + {connectionMismatch && latest.ownerConnection?.agent + ? `the registered connection belongs to ${latest.ownerConnection.agent.name}` + : `the latest observed implementation activity is from ${latest.observedImplementation?.agent.name ?? "another agent"}`} + . Reconcile ownership before handoff or redispatch. + {latest.observedImplementation && ( + + Evidence: {latest.observedImplementation.source.replaceAll("-", " ")} ·{" "} + {relativeTime(latest.observedImplementation.observedAt)}. + + )} + {canRelease && observedConnection && observedMismatch && ( + + )} +
    +
    + )} + + {provenance?.unconfirmedMcp && !ownershipMismatch && ( +
    + +
    + MCP status is unconfirmed.{" "} + Recent access is known, but Forge cannot guarantee the client process lifecycle. + Silence alone will not trigger automatic redispatch. +
    +
    + )} + + {latest.participants.length > 1 && ( +
    +
    + Delivery participants +
    +
      + {latest.participants.map((participant) => ( +
    • + + {participant.role.toLowerCase()} + + + {participant.agent.name} via{" "} + {connectionKindLabel(participant.connection.kind, participant.connection)} + +
    • + ))} +
    +
    + )} + {latest.pullRequest ? ( ); } + +type DeliverySession = inferRouterOutputs["workSession"]["listForIssue"][number]; + +function connectionKindLabel( + kind: NonNullable["kind"], + connection: { + displayName?: string | null; + clientName?: string | null; + runtime?: { name: string } | null; + }, +) { + const kindLabel = + kind === "MCP_CLIENT" + ? "MCP" + : kind === "MANAGED_RUNTIME" + ? "Runtime" + : kind === "WEBHOOK" + ? "Webhook" + : "On-demand"; + const name = connection.displayName ?? connection.clientName ?? connection.runtime?.name; + return name ? `${kindLabel} · ${name}` : kindLabel; +} + +function deliveryProvenance(session: DeliverySession): { + connectionLabel: string; + statusLabel: string; + tone: string; + unconfirmedMcp: boolean; +} { + const connection = session.ownerConnection; + if (!connection) { + const source = + session.source === "CODEX_DESKTOP" + ? "MCP · Codex Desktop" + : session.source === "FORGE_AGENT" + ? "Legacy agent session" + : session.source.toLowerCase().replaceAll("_", " "); + return { + connectionLabel: source, + statusLabel: "provenance not registered", + tone: "text-muted-foreground", + unconfirmedMcp: session.source === "CODEX_DESKTOP", + }; + } + const unconfirmedMcp = connection.kind === "MCP_CLIENT" && connection.confidence !== "CONFIRMED"; + const statusLabel = + connection.status === "ACTIVE" + ? connection.confidence === "CONFIRMED" + ? "confirmed active" + : "activity inferred" + : connection.status === "QUIET" && unconfirmedMcp + ? "quiet · status unconfirmed" + : connection.status.toLowerCase(); + const tone = + connection.status === "DISCONNECTED" || connection.status === "REVOKED" + ? "text-danger" + : connection.status === "QUIET" + ? "text-warning" + : connection.status === "ACTIVE" && connection.confidence === "CONFIRMED" + ? "text-success" + : "text-muted-foreground"; + return { + connectionLabel: connectionKindLabel(connection.kind, connection), + statusLabel, + tone, + unconfirmedMcp, + }; +} diff --git a/src/lib/activity-actor.ts b/src/lib/activity-actor.ts index e4ef8e7c..3e59297e 100644 --- a/src/lib/activity-actor.ts +++ b/src/lib/activity-actor.ts @@ -1,8 +1,47 @@ export type ActivityActorSource = { actor?: { name: string | null } | null; actorAgent?: { name: string | null; profileKey?: string | null } | null; + kind?: string | null; + payload?: unknown; }; +export type ActivityActorKind = "human" | "agent" | "worker" | "automation" | "connector"; + +function payloadRecord(payload: unknown): Record { + return payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : {}; +} + +export function activityActorKind(source: ActivityActorSource): ActivityActorKind { + if (source.actorAgent) return "agent"; + if (source.actor?.name) return "human"; + const payload = payloadRecord(source.payload); + const provenance = [ + payload.connectionKind, + payload.transport, + payload.provider, + payload.source, + payload.sourceType, + payload.action, + ] + .filter((value): value is string => typeof value === "string") + .join(" ") + .toLowerCase(); + if (/connector|webhook|github|mcp|streamable-http/.test(provenance) || payload.deliveryId) { + return "connector"; + } + if ( + source.kind?.startsWith("AGENT_") || + source.kind === "ISSUE_STALLED" || + source.kind === "ISSUE_SLA_BREACH" || + /worker|watchdog|sweep|dispatch|reconcile|work-session/.test(provenance) + ) { + return "worker"; + } + return "automation"; +} + export function activityActorName(source: ActivityActorSource): string { const agentName = source.actorAgent?.name ?? @@ -10,7 +49,35 @@ export function activityActorName(source: ActivityActorSource): string { const humanName = source.actor?.name ?? null; if (agentName) return agentName; - return humanName ?? "system"; + if (humanName) return humanName; + + const payload = payloadRecord(source.payload); + const kind = activityActorKind(source); + if (kind === "connector") { + const provider = typeof payload.provider === "string" ? payload.provider.trim() : ""; + if (provider) { + const providerLabel: Record = { + GITHUB: "GitHub", + MCP: "MCP", + SLACK: "Slack", + JIRA: "Jira", + }; + return `${providerLabel[provider.toUpperCase()] ?? titleCaseProvider(provider)} connector`; + } + if ( + payload.connectionKind === "MCP_CLIENT" || + (typeof payload.transport === "string" && payload.transport.toLowerCase().includes("mcp")) + ) { + return "MCP connector"; + } + return "External connector"; + } + if (kind === "worker") return "Forge worker"; + return "Forge automation"; +} + +function titleCaseProvider(provider: string): string { + return `${provider.charAt(0).toUpperCase()}${provider.slice(1).toLowerCase()}`; } export function activityActorOwnerTitle(source: ActivityActorSource): string | undefined { diff --git a/src/lib/activity-update-summary.ts b/src/lib/activity-update-summary.ts new file mode 100644 index 00000000..b7828a15 --- /dev/null +++ b/src/lib/activity-update-summary.ts @@ -0,0 +1,93 @@ +export type ActivityUpdateCopy = { + label: string; + detail?: string | null; + phase?: string; +}; + +const FIELD_LABELS: Record = { + title: "title", + description: "description", + priority: "priority", + statusId: "status", + projectId: "project", + cycleId: "sprint", + parentId: "parent issue", + dueDate: "due date", + estimate: "estimate", + assigneeIds: "assignees", + assignedAgentId: "assigned agent", + verificationChecklist: "verification checklist", + queued: "queue state", +}; + +function record(payload: unknown): Record | null { + return payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : null; +} + +function humanize(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim() + .toLowerCase(); +} + +function titleCase(value: string): string { + const text = humanize(value); + return text ? `${text.charAt(0).toUpperCase()}${text.slice(1)}` : "Updated"; +} + +function safeScalar(value: unknown): string | null { + if (typeof value === "boolean") return value ? "enabled" : "disabled"; + if (typeof value !== "string" && typeof value !== "number") return null; + const text = String(value).trim(); + if (!text || text.length > 100 || /^c[a-z0-9]{8,}$/i.test(text)) return null; + return text; +} + +/** Build compact evidence-backed copy for generic ISSUE_UPDATED events. */ +export function issueUpdateCopy(payload: unknown): ActivityUpdateCopy | null { + const data = record(payload); + if (!data) return null; + + const action = typeof data.action === "string" ? data.action.trim() : ""; + if (action) { + const branch = safeScalar(data.branch); + const repository = safeScalar(data.repoFullName); + return { + label: titleCase(action), + detail: [repository, branch].filter(Boolean).join(" · ") || null, + phase: action.includes("work-session") ? "delivery" : "automation", + }; + } + + const addCount = Array.isArray(data.add) ? data.add.length : 0; + const removeCount = Array.isArray(data.remove) ? data.remove.length : 0; + if (addCount > 0 || removeCount > 0) { + const detail = [ + addCount > 0 ? `${addCount} added` : null, + removeCount > 0 ? `${removeCount} removed` : null, + ] + .filter(Boolean) + .join(" · "); + return { label: "Labels changed", detail, phase: "labels" }; + } + + const fields = Object.keys(data) + .filter((key) => key in FIELD_LABELS) + .map((key) => ({ key, label: FIELD_LABELS[key]! })); + if (fields.length === 0) return null; + if (fields.length === 1) { + const field = fields[0]!; + const detail = ["title", "priority", "dueDate", "estimate", "queued"].includes(field.key) + ? safeScalar(data[field.key]) + : null; + return { label: `${titleCase(field.label)} updated`, detail, phase: field.label }; + } + return { + label: `Updated ${fields.map((field) => field.label).join(", ")}`, + phase: "fields", + }; +} diff --git a/src/server/routers/access.ts b/src/server/routers/access.ts index 09accc52..acb1ab49 100644 --- a/src/server/routers/access.ts +++ b/src/server/routers/access.ts @@ -4,6 +4,7 @@ import { PluginScope, ApiKeyKind } from "@prisma/client"; import type { PrismaClient } from "@prisma/client"; import { createHash, randomBytes } from "node:crypto"; import { router, adminProcedure, workspaceProcedure } from "@/server/trpc"; +import { revokeAgentConnectionsForApiKey } from "@/server/services/agent-connection"; import { agentId as agentIdSchema } from "./agent"; function generateApiKey(prefix = "forge_sk"): { raw: string; hashed: string; prefix: string } { @@ -33,46 +34,40 @@ async function assertIdsInWorkspace( if (ids.projectIds?.length) { const unique = Array.from(new Set(ids.projectIds)); checks.push( - db.project - .count({ where: { id: { in: unique }, workspaceId } }) - .then((n) => { - if (n !== unique.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "One or more projectIds not in this workspace.", - }); - } - }), + db.project.count({ where: { id: { in: unique }, workspaceId } }).then((n) => { + if (n !== unique.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more projectIds not in this workspace.", + }); + } + }), ); } if (ids.labelIds?.length) { const unique = Array.from(new Set(ids.labelIds)); checks.push( - db.label - .count({ where: { id: { in: unique }, workspaceId } }) - .then((n) => { - if (n !== unique.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "One or more labelIds not in this workspace.", - }); - } - }), + db.label.count({ where: { id: { in: unique }, workspaceId } }).then((n) => { + if (n !== unique.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more labelIds not in this workspace.", + }); + } + }), ); } if (ids.initiativeIds?.length) { const unique = Array.from(new Set(ids.initiativeIds)); checks.push( - db.initiative - .count({ where: { id: { in: unique }, workspaceId } }) - .then((n) => { - if (n !== unique.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "One or more initiativeIds not in this workspace.", - }); - } - }), + db.initiative.count({ where: { id: { in: unique }, workspaceId } }).then((n) => { + if (n !== unique.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more initiativeIds not in this workspace.", + }); + } + }), ); } await Promise.all(checks); @@ -135,6 +130,23 @@ export const accessRouter = router({ linkedAgent: { select: { id: true, name: true, profileKey: true, avatar: true }, }, + agentConnections: { + orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }], + select: { + id: true, + status: true, + displayName: true, + clientName: true, + lastSeenAt: true, + _count: { + select: { + ownedSessions: { + where: { status: { notIn: ["VERIFIED", "ABANDONED"] } }, + }, + }, + }, + }, + }, createdAt: true, lastUsedAt: true, expiresAt: true, @@ -162,14 +174,9 @@ export const accessRouter = router({ labelIds: input.labelIds, initiativeIds: input.initiativeIds, }); - await assertAgentInWorkspace( - ctx.db, - ctx.workspaceId, - input.linkedAgentId, - ); + await assertAgentInWorkspace(ctx.db, ctx.workspaceId, input.linkedAgentId); const { raw, hashed, prefix } = generateApiKey(); - const inferredKind: ApiKeyKind = - input.kind ?? (input.linkedAgentId ? "AGENT" : "PERSONAL"); + const inferredKind: ApiKeyKind = input.kind ?? (input.linkedAgentId ? "AGENT" : "PERSONAL"); const row = await ctx.db.apiKey.create({ data: { workspaceId: ctx.workspaceId, @@ -232,11 +239,7 @@ export const accessRouter = router({ initiativeIds: input.initiativeIds, }); if (input.linkedAgentId !== undefined) { - await assertAgentInWorkspace( - ctx.db, - ctx.workspaceId, - input.linkedAgentId, - ); + await assertAgentInWorkspace(ctx.db, ctx.workspaceId, input.linkedAgentId); } return ctx.db.apiKey.update({ where: { id: prior.id }, @@ -244,12 +247,8 @@ export const accessRouter = router({ ...(input.name !== undefined ? { name: input.name } : {}), ...(input.projectIds !== undefined ? { projectIds: input.projectIds } : {}), ...(input.labelIds !== undefined ? { labelIds: input.labelIds } : {}), - ...(input.initiativeIds !== undefined - ? { initiativeIds: input.initiativeIds } - : {}), - ...(input.linkedAgentId !== undefined - ? { linkedAgentId: input.linkedAgentId } - : {}), + ...(input.initiativeIds !== undefined ? { initiativeIds: input.initiativeIds } : {}), + ...(input.linkedAgentId !== undefined ? { linkedAgentId: input.linkedAgentId } : {}), }, select: { id: true, @@ -274,9 +273,13 @@ export const accessRouter = router({ where: { id: input.id, workspaceId: ctx.workspaceId }, }); if (!key) throw new TRPCError({ code: "NOT_FOUND" }); - return ctx.db.apiKey.update({ - where: { id: input.id }, - data: { revokedAt: new Date() }, + const revokedAt = new Date(); + return ctx.db.$transaction(async (tx) => { + await revokeAgentConnectionsForApiKey(tx, key.id, revokedAt); + return tx.apiKey.update({ + where: { id: input.id }, + data: { revokedAt }, + }); }); }), @@ -287,7 +290,10 @@ export const accessRouter = router({ where: { id: input.id, workspaceId: ctx.workspaceId, pluginId: null }, }); if (!key) throw new TRPCError({ code: "NOT_FOUND" }); - return ctx.db.apiKey.delete({ where: { id: input.id } }); + return ctx.db.$transaction(async (tx) => { + await revokeAgentConnectionsForApiKey(tx, key.id); + return tx.apiKey.delete({ where: { id: input.id } }); + }); }), /** @@ -388,12 +394,15 @@ export const accessRouter = router({ where: { id: input.id, workspaceId: ctx.workspaceId, pluginId: null }, }); if (!prior) throw new TRPCError({ code: "NOT_FOUND" }); - if (prior.revokedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Key already revoked." }); + if (prior.revokedAt) + throw new TRPCError({ code: "BAD_REQUEST", message: "Key already revoked." }); const { raw, hashed, prefix } = generateApiKey(); - const [, next] = await ctx.db.$transaction([ - ctx.db.apiKey.update({ where: { id: prior.id }, data: { revokedAt: new Date() } }), - ctx.db.apiKey.create({ + const revokedAt = new Date(); + const next = await ctx.db.$transaction(async (tx) => { + await revokeAgentConnectionsForApiKey(tx, prior.id, revokedAt); + await tx.apiKey.update({ where: { id: prior.id }, data: { revokedAt } }); + return tx.apiKey.create({ data: { workspaceId: ctx.workspaceId, userId: prior.userId, @@ -410,13 +419,12 @@ export const accessRouter = router({ linkedAgentId: prior.linkedAgentId, expiresAt: prior.expiresAt ? new Date( - Date.now() + - Math.max(1, prior.expiresAt.getTime() - prior.createdAt.getTime()), + Date.now() + Math.max(1, prior.expiresAt.getTime() - prior.createdAt.getTime()), ) : null, }, - }), - ]); + }); + }); return { id: next.id, name: next.name, diff --git a/src/server/routers/agent.ts b/src/server/routers/agent.ts index e1f11914..be2a7aaa 100644 --- a/src/server/routers/agent.ts +++ b/src/server/routers/agent.ts @@ -67,9 +67,7 @@ const upsertInput = z.object({ description: z.string().max(2000).optional(), avatar: z.string().max(200).optional(), provider: z.nativeEnum(AgentProvider).default(AgentProvider.HERMES), - runtimeMode: z - .nativeEnum(AgentRuntimeMode) - .default(AgentRuntimeMode.PERSISTENT), + runtimeMode: z.nativeEnum(AgentRuntimeMode).default(AgentRuntimeMode.PERSISTENT), /// Chat engine override at creation. Null/omitted = integration default. runEngine: z.nativeEnum(RunEngine).nullable().optional(), webhookUrl: z.string().url().max(500).optional().or(z.literal("")), @@ -84,9 +82,7 @@ const upsertInput = z.object({ templateMarkdown: z.string().optional(), }); -function redactWebhookSecret( - value: T, -): T { +function redactWebhookSecret(value: T): T { return { ...value, webhookSecret: value.webhookSecret ? "[redacted]" : value.webhookSecret, @@ -111,7 +107,8 @@ async function resolveRuntimeId( where: { id, workspaceId }, select: { id: true, adapterKey: true }, }); - if (!rt) throw new TRPCError({ code: "BAD_REQUEST", message: "Runtime not found in this workspace." }); + if (!rt) + throw new TRPCError({ code: "BAD_REQUEST", message: "Runtime not found in this workspace." }); const adapter = getRuntimeAdapter(rt.adapterKey); if (adapter && !adapter.multiAgent) { const attached = await db.agent.count({ @@ -168,10 +165,7 @@ export const agentRouter = router({ // Indicative — uses env model availability + linked-key presence (not a // per-thread provider override). Secret is never needed for the readiness // decision, so it isn't selected or exposed. - const providerAvailable = await workspaceChatProviderAvailability( - ctx.db, - ctx.workspaceId, - ); + const providerAvailable = await workspaceChatProviderAvailability(ctx.db, ctx.workspaceId); const linkedRows = await ctx.db.apiKey.groupBy({ by: ["linkedAgentId"], where: { @@ -204,41 +198,39 @@ export const agentRouter = router({ }); }), - byId: workspaceProcedure - .input(z.object({ id: agentId })) - .query(async ({ ctx, input }) => { - const agent = await ctx.db.agent.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - include: { - _count: { select: { assignedIssues: true } }, - runtime: { - select: { - id: true, - name: true, - kind: true, - adapterKey: true, - endpoint: true, - heartbeatAt: true, - connectedAt: true, - archivedAt: true, - disabledAt: true, - lastProbeAt: true, - lastProbeAttempted: true, - lastProbeReachable: true, - lastProbeDetail: true, - config: true, - }, + byId: workspaceProcedure.input(z.object({ id: agentId })).query(async ({ ctx, input }) => { + const agent = await ctx.db.agent.findFirst({ + where: { id: input.id, workspaceId: ctx.workspaceId }, + include: { + _count: { select: { assignedIssues: true } }, + runtime: { + select: { + id: true, + name: true, + kind: true, + adapterKey: true, + endpoint: true, + heartbeatAt: true, + connectedAt: true, + archivedAt: true, + disabledAt: true, + lastProbeAt: true, + lastProbeAttempted: true, + lastProbeReachable: true, + lastProbeDetail: true, + config: true, }, }, - }); - if (!agent) throw new TRPCError({ code: "NOT_FOUND" }); - return { - ...agent, - runtime: agent.runtime - ? { ...agent.runtime, health: deriveRuntimeHealthStatus(agent.runtime) } - : null, - }; - }), + }, + }); + if (!agent) throw new TRPCError({ code: "NOT_FOUND" }); + return { + ...agent, + runtime: agent.runtime + ? { ...agent.runtime, health: deriveRuntimeHealthStatus(agent.runtime) } + : null, + }; + }), /** * Preview how a (not-yet-created) agent's chat would be served, given a @@ -261,10 +253,7 @@ export const agentRouter = router({ select: { adapterKey: true, endpoint: true, kind: true, runtimeInfo: true }, }) : null; - const providerAvailable = await workspaceChatProviderAvailability( - ctx.db, - ctx.workspaceId, - ); + const providerAvailable = await workspaceChatProviderAvailability(ctx.db, ctx.workspaceId); const r = resolveChatReadiness({ provider: input.provider, runEngine: input.runEngine ?? null, @@ -388,10 +377,7 @@ export const agentRouter = router({ }, }); if (!agent) throw new TRPCError({ code: "NOT_FOUND" }); - const providerAvailable = await workspaceChatProviderAvailability( - ctx.db, - ctx.workspaceId, - ); + const providerAvailable = await workspaceChatProviderAvailability(ctx.db, ctx.workspaceId); const daemonLinked = (await ctx.db.apiKey.count({ where: { workspaceId: ctx.workspaceId, linkedAgentId: agent.id, revokedAt: null }, @@ -429,81 +415,108 @@ export const agentRouter = router({ }; }), - byProfileKey: workspaceProcedure - .input(z.object({ profileKey })) - .query(async ({ ctx, input }) => { - const agent = await ctx.db.agent.findUnique({ - where: { - workspaceId_profileKey: { - workspaceId: ctx.workspaceId, - profileKey: input.profileKey, + byProfileKey: workspaceProcedure.input(z.object({ profileKey })).query(async ({ ctx, input }) => { + const agent = await ctx.db.agent.findUnique({ + where: { + workspaceId_profileKey: { + workspaceId: ctx.workspaceId, + profileKey: input.profileKey, + }, + }, + include: { + runtime: { + select: { + id: true, + name: true, + kind: true, + heartbeatAt: true, + providersAvailable: true, + adapterKey: true, + disabledAt: true, + endpoint: true, + runtimeInfo: true, }, }, - include: { - runtime: { - select: { - id: true, - name: true, - kind: true, - heartbeatAt: true, - providersAvailable: true, - adapterKey: true, - disabledAt: true, - endpoint: true, - runtimeInfo: true, + connections: { + orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }], + take: 25, + select: { + id: true, + kind: true, + livenessModel: true, + status: true, + confidence: true, + instanceKey: true, + displayName: true, + clientName: true, + clientVersion: true, + capabilities: true, + firstSeenAt: true, + lastSeenAt: true, + connectedAt: true, + disconnectedAt: true, + revokedAt: true, + runtime: { select: { id: true, name: true } }, + apiKey: { select: { id: true, name: true, kind: true, revokedAt: true } }, + _count: { + select: { + runs: { where: { status: { in: ["ACTIVE", "WAITING"] } } }, + ownedSessions: { + where: { status: { notIn: ["VERIFIED", "ABANDONED"] } }, + }, + }, }, }, }, - }); - if (!agent) return null; - // Resolved chat transport + an availability model so the detail page can - // show how chat is served and stop applying a heartbeat "offline" badge - // to an on-demand managed-runtime agent (e.g. Codex app server). - const providerAvailable = await workspaceChatProviderAvailability( - ctx.db, - ctx.workspaceId, - ); - const daemonLinked = - (await ctx.db.apiKey.count({ - where: { workspaceId: ctx.workspaceId, linkedAgentId: agent.id, revokedAt: null }, - })) > 0; - const r = resolveChatReadiness({ - provider: agent.provider, - runEngine: agent.runEngine, - runtime: agent.runtime - ? { - adapterKey: agent.runtime.adapterKey, - endpoint: agent.runtime.endpoint, - secret: null, - runtimeInfo: agent.runtime.runtimeInfo, - } - : null, - webhookUrl: agent.webhookUrl, - runtimeKind: agent.runtime?.kind ?? null, - daemonLinked, - providerAvailable, - }); - const resolvedEngine = resolveRunEngineWithSource({ - runEngine: agent.runEngine, - provider: agent.provider, - runtime: agent.runtime - ? { adapterKey: agent.runtime.adapterKey, endpoint: agent.runtime.endpoint, secret: null } - : null, - }); - return { - ...agent, - // Effective engine (honors an attached runs-runtime), not just the raw - // Agent.runEngine preference — drives the detail-page Engine row. - resolvedEngine, - transport: { mode: r.mode, label: r.transportLabel, ready: r.ready }, - availability: agentAvailabilityModel({ - runtimeMode: agent.runtimeMode, - lastHeartbeatAt: agent.lastHeartbeatAt, - transportMode: r.mode, - runtimeHeartbeats: agent.runtime?.adapterKey === "hermes", - }), - }; - }), + _count: { select: { connections: true } }, + }, + }); + if (!agent) return null; + // Resolved chat transport + an availability model so the detail page can + // show how chat is served and stop applying a heartbeat "offline" badge + // to an on-demand managed-runtime agent (e.g. Codex app server). + const providerAvailable = await workspaceChatProviderAvailability(ctx.db, ctx.workspaceId); + const daemonLinked = + (await ctx.db.apiKey.count({ + where: { workspaceId: ctx.workspaceId, linkedAgentId: agent.id, revokedAt: null }, + })) > 0; + const r = resolveChatReadiness({ + provider: agent.provider, + runEngine: agent.runEngine, + runtime: agent.runtime + ? { + adapterKey: agent.runtime.adapterKey, + endpoint: agent.runtime.endpoint, + secret: null, + runtimeInfo: agent.runtime.runtimeInfo, + } + : null, + webhookUrl: agent.webhookUrl, + runtimeKind: agent.runtime?.kind ?? null, + daemonLinked, + providerAvailable, + }); + const resolvedEngine = resolveRunEngineWithSource({ + runEngine: agent.runEngine, + provider: agent.provider, + runtime: agent.runtime + ? { adapterKey: agent.runtime.adapterKey, endpoint: agent.runtime.endpoint, secret: null } + : null, + }); + return { + ...agent, + // Effective engine (honors an attached runs-runtime), not just the raw + // Agent.runEngine preference — drives the detail-page Engine row. + resolvedEngine, + transport: { mode: r.mode, label: r.transportLabel, ready: r.ready }, + availability: agentAvailabilityModel({ + runtimeMode: agent.runtimeMode, + lastHeartbeatAt: agent.lastHeartbeatAt, + transportMode: r.mode, + runtimeHeartbeats: agent.runtime?.adapterKey === "hermes", + }), + }; + }), /** * Crews this agent belongs to + what it's actively working on right @@ -737,38 +750,32 @@ export const agentRouter = router({ }); }), - archive: adminProcedure - .input(z.object({ id: agentId })) - .mutation(async ({ ctx, input }) => { - const row = await ctx.db.agent.findFirstOrThrow({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - }); - return ctx.db.agent.update({ - where: { id: row.id }, - data: { archivedAt: new Date(), status: AgentStatus.OFFLINE }, - }); - }), + archive: adminProcedure.input(z.object({ id: agentId })).mutation(async ({ ctx, input }) => { + const row = await ctx.db.agent.findFirstOrThrow({ + where: { id: input.id, workspaceId: ctx.workspaceId }, + }); + return ctx.db.agent.update({ + where: { id: row.id }, + data: { archivedAt: new Date(), status: AgentStatus.OFFLINE }, + }); + }), - unarchive: adminProcedure - .input(z.object({ id: agentId })) - .mutation(async ({ ctx, input }) => { - const row = await ctx.db.agent.findFirstOrThrow({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - }); - return ctx.db.agent.update({ - where: { id: row.id }, - data: { archivedAt: null }, - }); - }), + unarchive: adminProcedure.input(z.object({ id: agentId })).mutation(async ({ ctx, input }) => { + const row = await ctx.db.agent.findFirstOrThrow({ + where: { id: input.id, workspaceId: ctx.workspaceId }, + }); + return ctx.db.agent.update({ + where: { id: row.id }, + data: { archivedAt: null }, + }); + }), - delete: adminProcedure - .input(z.object({ id: agentId })) - .mutation(async ({ ctx, input }) => { - const row = await ctx.db.agent.findFirstOrThrow({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - }); - return ctx.db.agent.delete({ where: { id: row.id } }); - }), + delete: adminProcedure.input(z.object({ id: agentId })).mutation(async ({ ctx, input }) => { + const row = await ctx.db.agent.findFirstOrThrow({ + where: { id: input.id, workspaceId: ctx.workspaceId }, + }); + return ctx.db.agent.delete({ where: { id: row.id } }); + }), /** * Smart remove — what the settings UI's "Delete" action calls. Hard-deletes a @@ -779,77 +786,73 @@ export const agentRouter = router({ * an agent with runs would silently destroy that run history. `delete`/ * `archive` above stay as the explicit raw variants. */ - remove: adminProcedure - .input(z.object({ id: agentId })) - .mutation(async ({ ctx, input }) => { - const row = await ctx.db.agent.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - select: { - id: true, - name: true, - profileKey: true, - _count: { - select: { - runs: true, - authoredComments: true, - apiKeys: true, - assignedIssues: true, - claimedIssues: true, - authoredArtifacts: true, - createdExecutionPlans: true, - createdGoals: true, - executionSteps: true, - requestedReviewGates: true, - resolvedReviewGates: true, - requestedActionRequests: true, - assignedActionRequests: true, - crewMemberships: true, - }, + remove: adminProcedure.input(z.object({ id: agentId })).mutation(async ({ ctx, input }) => { + const row = await ctx.db.agent.findFirst({ + where: { id: input.id, workspaceId: ctx.workspaceId }, + select: { + id: true, + name: true, + profileKey: true, + _count: { + select: { + runs: true, + authoredComments: true, + apiKeys: true, + assignedIssues: true, + claimedIssues: true, + authoredArtifacts: true, + createdExecutionPlans: true, + createdGoals: true, + executionSteps: true, + requestedReviewGates: true, + resolvedReviewGates: true, + requestedActionRequests: true, + assignedActionRequests: true, + crewMemberships: true, }, }, - }); - if (!row) throw new TRPCError({ code: "NOT_FOUND" }); - - const references = Object.fromEntries( - Object.entries(row._count).filter(([, n]) => n > 0), - ); - const hasHistory = Object.keys(references).length > 0; + }, + }); + if (!row) throw new TRPCError({ code: "NOT_FOUND" }); - if (hasHistory) { - await ctx.db.agent.update({ - where: { id: row.id }, - data: { archivedAt: new Date(), status: AgentStatus.OFFLINE }, - }); - } else { - await ctx.db.agent.delete({ where: { id: row.id } }); - } + const references = Object.fromEntries(Object.entries(row._count).filter(([, n]) => n > 0)); + const hasHistory = Object.keys(references).length > 0; - await recordChange(ctx.db, { - workspaceId: ctx.workspaceId, - actorId: ctx.session.user.id, - actorAgentId: ctx.apiKey?.linkedAgentId ?? null, - entity: "Agent", - entityId: row.id, - action: "delete", - before: { profileKey: row.profileKey, name: row.name }, - eventKind: EventKind.AGENT_DELETED, - subjectType: "agent", - subjectId: row.id, - payload: { - profileKey: row.profileKey, - removed: hasHistory ? "archived" : "deleted", - references, - }, - ip: ctx.ip, - userAgent: ctx.userAgent, + if (hasHistory) { + await ctx.db.agent.update({ + where: { id: row.id }, + data: { archivedAt: new Date(), status: AgentStatus.OFFLINE }, }); + } else { + await ctx.db.agent.delete({ where: { id: row.id } }); + } - return { - action: hasHistory ? ("archived" as const) : ("deleted" as const), - name: row.name, + await recordChange(ctx.db, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + entity: "Agent", + entityId: row.id, + action: "delete", + before: { profileKey: row.profileKey, name: row.name }, + eventKind: EventKind.AGENT_DELETED, + subjectType: "agent", + subjectId: row.id, + payload: { + profileKey: row.profileKey, + removed: hasHistory ? "archived" : "deleted", references, - }; - }), + }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + + return { + action: hasHistory ? ("archived" as const) : ("deleted" as const), + name: row.name, + references, + }; + }), testWebhook: adminProcedure .input( @@ -863,17 +866,15 @@ export const agentRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - let agent: - | { - id: string; - name: string; - profileKey: string; - webhookUrl: string | null; - webhookSecret: string | null; - provider: AgentProvider; - runtimeMode: AgentRuntimeMode; - } - | null = null; + let agent: { + id: string; + name: string; + profileKey: string; + webhookUrl: string | null; + webhookSecret: string | null; + provider: AgentProvider; + runtimeMode: AgentRuntimeMode; + } | null = null; if (input.id) { agent = await ctx.db.agent.findFirstOrThrow({ @@ -899,12 +900,10 @@ export const agentRouter = router({ } const provider = input.provider ?? agent?.provider ?? AgentProvider.CUSTOM; - const runtimeMode = - input.runtimeMode ?? agent?.runtimeMode ?? AgentRuntimeMode.EPHEMERAL; + const runtimeMode = input.runtimeMode ?? agent?.runtimeMode ?? AgentRuntimeMode.EPHEMERAL; const res = await deliverWebhook({ url, - secret: - input.webhookSecret || agent?.webhookSecret || "forge_connection_test", + secret: input.webhookSecret || agent?.webhookSecret || "forge_connection_test", timeoutMs: 5000, body: { id: `agent-test-${Date.now()}`, @@ -969,9 +968,7 @@ export const agentRouter = router({ .default({ recentDays: 7, laneLimit: 25, poolLimit: 50 }), ) .query(async ({ ctx, input }) => { - const recentSince = new Date( - Date.now() - input.recentDays * 86_400_000, - ); + const recentSince = new Date(Date.now() - input.recentDays * 86_400_000); const issueInclude = { status: { @@ -984,10 +981,7 @@ export const agentRouter = router({ _count: { select: { comments: true } }, } as const; - const blockedIds = await findBlockedIssueIdsForWorkspace( - ctx.db, - ctx.workspaceId, - ); + const blockedIds = await findBlockedIssueIdsForWorkspace(ctx.db, ctx.workspaceId); const [poolRows, agents] = await Promise.all([ ctx.db.issue.findMany({ @@ -1029,11 +1023,7 @@ export const agentRouter = router({ assignedAgentId: agent.id, status: { category: { in: ["IN_PROGRESS", "IN_REVIEW"] } }, }, - orderBy: [ - { priority: "desc" }, - { startedAt: "desc" }, - { createdAt: "asc" }, - ], + orderBy: [{ priority: "desc" }, { startedAt: "desc" }, { createdAt: "asc" }], take: input.laneLimit, include: issueInclude, }), @@ -1262,21 +1252,12 @@ export const agentRouter = router({ return { events: page.map((e) => { const payload = (e.payload ?? {}) as Record; - const pAgentId = - typeof payload.agentId === "string" - ? (payload.agentId as string) - : null; + const pAgentId = typeof payload.agentId === "string" ? (payload.agentId as string) : null; const subjectIssue = - e.subjectType === "issue" - ? (issueById.get(e.subjectId) ?? null) - : null; + e.subjectType === "issue" ? (issueById.get(e.subjectId) ?? null) : null; const subjectAgent = - e.subjectType === "agent" - ? (agentById.get(e.subjectId) ?? null) - : null; - const payloadAgent = pAgentId - ? (agentById.get(pAgentId) ?? null) - : null; + e.subjectType === "agent" ? (agentById.get(e.subjectId) ?? null) : null; + const payloadAgent = pAgentId ? (agentById.get(pAgentId) ?? null) : null; return { id: e.id, kind: e.kind, @@ -1322,9 +1303,7 @@ export const agentRouter = router({ }); const now = new Date(); - const windowStart = new Date( - now.getTime() - input.windowDays * 86_400_000, - ); + const windowStart = new Date(now.getTime() - input.windowDays * 86_400_000); const eventsAfter = await ctx.db.activityEvent.findMany({ where: { @@ -1383,11 +1362,8 @@ export const agentRouter = router({ const tail = now.getTime() - cursorAt.getTime(); if (tail > 0) buckets[cursorStatus] += tail; - const totalMs = - buckets.ONLINE + buckets.BUSY + buckets.OFFLINE || 1; - const uptimePct = Math.round( - ((buckets.ONLINE + buckets.BUSY) / totalMs) * 1000, - ) / 10; + const totalMs = buckets.ONLINE + buckets.BUSY + buckets.OFFLINE || 1; + const uptimePct = Math.round(((buckets.ONLINE + buckets.BUSY) / totalMs) * 1000) / 10; // currentSinceIso = createdAt of the most-recent transition; falls // back to lastHeartbeatAt, then windowStart. @@ -1441,9 +1417,7 @@ export const agentRouter = router({ select: { id: true, webhookUrl: true }, }); - const since = new Date( - Date.now() - input.windowDays * 86_400_000, - ); + const since = new Date(Date.now() - input.windowDays * 86_400_000); const perAgentUrl = agentDispatchUrlFor(agent.id); @@ -1597,9 +1571,7 @@ export const agentRouter = router({ }); if (!agent) throw new TRPCError({ code: "NOT_FOUND" }); - const beforeFilter = input.before - ? { lt: input.before } - : undefined; + const beforeFilter = input.before ? { lt: input.before } : undefined; const [comments, events, runEvents] = await Promise.all([ // (1) Agent-authored comments. @@ -1715,12 +1687,8 @@ export const agentRouter = router({ }; const merged: Row[] = [ - ...comments.map( - (c): Row => ({ kind: "comment", timestamp: c.createdAt, payload: c }), - ), - ...events.map( - (e): Row => ({ kind: "event", timestamp: e.createdAt, payload: e }), - ), + ...comments.map((c): Row => ({ kind: "comment", timestamp: c.createdAt, payload: c })), + ...events.map((e): Row => ({ kind: "event", timestamp: e.createdAt, payload: e })), ...runEvents.map( (r): Row => ({ kind: "run-event", @@ -1731,8 +1699,7 @@ export const agentRouter = router({ ].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); const page = merged.slice(0, input.limit); - const nextBefore = - page.length === input.limit ? page[page.length - 1].timestamp : null; + const nextBefore = page.length === input.limit ? page[page.length - 1].timestamp : null; return { agent, @@ -1763,90 +1730,43 @@ export const agentRouter = router({ * client doesn't need follow-up fetches. Sorted oldest-first within * each bucket so the most-stale entries surface to the top. */ - stalled: workspaceProcedure - .input(z.object({ agentId })) - .query(async ({ ctx, input }) => { - // Workspace-scope guard: confirm the agent belongs to the - // calling tenant before returning rows joined off it. - const agent = await ctx.db.agent.findFirst({ - where: { id: input.agentId, workspaceId: ctx.workspaceId }, - select: { id: true }, - }); - if (!agent) throw new TRPCError({ code: "NOT_FOUND" }); + stalled: workspaceProcedure.input(z.object({ agentId })).query(async ({ ctx, input }) => { + // Workspace-scope guard: confirm the agent belongs to the + // calling tenant before returning rows joined off it. + const agent = await ctx.db.agent.findFirst({ + where: { id: input.agentId, workspaceId: ctx.workspaceId }, + select: { id: true }, + }); + if (!agent) throw new TRPCError({ code: "NOT_FOUND" }); - const ws = await ctx.db.workspace.findUniqueOrThrow({ - where: { id: ctx.workspaceId }, - select: { stalledThresholdDays: true }, - }); + const ws = await ctx.db.workspace.findUniqueOrThrow({ + where: { id: ctx.workspaceId }, + select: { stalledThresholdDays: true }, + }); - const now = Date.now(); - const runCutoff = new Date(now - STALE_RUN_MS); + const now = Date.now(); + const runCutoff = new Date(now - STALE_RUN_MS); - const stalledRuns = await ctx.db.agentRun.findMany({ - where: { - workspaceId: ctx.workspaceId, - agentId: agent.id, - status: AgentRunStatus.ACTIVE, - lastEventAt: { lt: runCutoff }, - }, - orderBy: [{ lastEventAt: "asc" }], - take: 50, - select: { - id: true, - issueId: true, - currentStep: true, - startedAt: true, - lastEventAt: true, - issue: { - select: { - id: true, - number: true, - title: true, - status: { - select: { id: true, name: true, category: true, color: true }, - }, - project: { - select: { id: true, key: true, name: true, color: true }, - }, - workspace: { select: { key: true, slug: true } }, - }, - }, - }, - }); - - let stalledIssues: Array<{ - id: string; - number: number; - title: string; - updatedAt: Date; - status: { id: string; name: string; category: string; color: string }; - project: { id: string; key: string; name: string; color: string | null } | null; - workspace: { key: string; slug: string }; - }> = []; - if (ws.stalledThresholdDays > 0) { - const issueCutoff = new Date( - now - ws.stalledThresholdDays * 24 * 60 * 60 * 1000, - ); - const snoozeNow = new Date(); - stalledIssues = await ctx.db.issue.findMany({ - where: { - workspaceId: ctx.workspaceId, - deletedAt: null, - assignedAgentId: agent.id, - updatedAt: { lt: issueCutoff }, - status: { category: { in: ["IN_PROGRESS", "IN_REVIEW"] } }, - OR: [ - { snoozedUntil: null }, - { snoozedUntil: { lte: snoozeNow } }, - ], - }, - orderBy: [{ updatedAt: "asc" }], - take: 50, + const stalledRuns = await ctx.db.agentRun.findMany({ + where: { + workspaceId: ctx.workspaceId, + agentId: agent.id, + status: AgentRunStatus.ACTIVE, + lastEventAt: { lt: runCutoff }, + }, + orderBy: [{ lastEventAt: "asc" }], + take: 50, + select: { + id: true, + issueId: true, + currentStep: true, + startedAt: true, + lastEventAt: true, + issue: { select: { id: true, number: true, title: true, - updatedAt: true, status: { select: { id: true, name: true, category: true, color: true }, }, @@ -1855,15 +1775,55 @@ export const agentRouter = router({ }, workspace: { select: { key: true, slug: true } }, }, - }); - } + }, + }, + }); - return { - stalledRuns, - stalledIssues, - stalledThresholdDays: ws.stalledThresholdDays, - }; - }), + let stalledIssues: Array<{ + id: string; + number: number; + title: string; + updatedAt: Date; + status: { id: string; name: string; category: string; color: string }; + project: { id: string; key: string; name: string; color: string | null } | null; + workspace: { key: string; slug: string }; + }> = []; + if (ws.stalledThresholdDays > 0) { + const issueCutoff = new Date(now - ws.stalledThresholdDays * 24 * 60 * 60 * 1000); + const snoozeNow = new Date(); + stalledIssues = await ctx.db.issue.findMany({ + where: { + workspaceId: ctx.workspaceId, + deletedAt: null, + assignedAgentId: agent.id, + updatedAt: { lt: issueCutoff }, + status: { category: { in: ["IN_PROGRESS", "IN_REVIEW"] } }, + OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: snoozeNow } }], + }, + orderBy: [{ updatedAt: "asc" }], + take: 50, + select: { + id: true, + number: true, + title: true, + updatedAt: true, + status: { + select: { id: true, name: true, category: true, color: true }, + }, + project: { + select: { id: true, key: true, name: true, color: true }, + }, + workspace: { select: { key: true, slug: true } }, + }, + }); + } + + return { + stalledRuns, + stalledIssues, + stalledThresholdDays: ws.stalledThresholdDays, + }; + }), }); /** diff --git a/src/server/routers/work-session.ts b/src/server/routers/work-session.ts index c36e85c3..a52d1407 100644 --- a/src/server/routers/work-session.ts +++ b/src/server/routers/work-session.ts @@ -1,7 +1,7 @@ import { WorkSessionSource } from "@prisma/client"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { router, workspaceProcedure } from "@/server/trpc"; +import { router, workspaceProcedure, adminProcedure } from "@/server/trpc"; import { advanceWorkSession, attachPullRequest, @@ -9,6 +9,7 @@ import { listIssueWorkSessions, touchWorkSession, } from "@/server/services/work-session"; +import { handoffWorkSession, joinWorkSession } from "@/server/services/work-session-participant"; const repoSchema = z .string() @@ -72,6 +73,18 @@ export const workSessionRouter = router({ }, ownerUser: { select: { id: true, name: true, email: true, image: true } }, ownerAgent: { select: { id: true, name: true, profileKey: true, avatar: true } }, + ownerConnection: { + select: { + id: true, + kind: true, + status: true, + confidence: true, + displayName: true, + clientName: true, + lastSeenAt: true, + agent: { select: { id: true, name: true, profileKey: true } }, + }, + }, pullRequest: { select: { number: true, repoFullName: true, url: true, state: true, metadata: true }, }, @@ -83,6 +96,43 @@ export const workSessionRouter = router({ .input(z.object({ issueId: z.string().cuid() })) .query(({ ctx, input }) => listIssueWorkSessions(ctx.db, ctx.workspaceId, input.issueId)), + join: workspaceProcedure + .input( + z.object({ + sessionId: z.string().cuid(), + connectionId: z.string().cuid(), + role: z.enum(["CONTRIBUTOR", "REVIEWER"]), + }), + ) + .mutation(async ({ ctx, input }) => { + await assertSessionManager(ctx, input.sessionId); + return joinWorkSession(ctx.db, { + workspaceId: ctx.workspaceId, + sessionId: input.sessionId, + connectionId: input.connectionId, + role: input.role, + actor: { userId: ctx.session.user.id }, + }); + }), + + reconcileOwnership: adminProcedure + .input( + z.object({ + sessionId: z.string().cuid(), + targetConnectionId: z.string().cuid(), + reason: z.string().max(500).nullable().optional(), + }), + ) + .mutation(({ ctx, input }) => + handoffWorkSession(ctx.db, { + workspaceId: ctx.workspaceId, + sessionId: input.sessionId, + toConnectionId: input.targetConnectionId, + actor: { userId: ctx.session.user.id }, + reason: input.reason, + }), + ), + claim: workspaceProcedure .input( z.object({ diff --git a/src/server/services/__tests__/agent-connection.test.ts b/src/server/services/__tests__/agent-connection.test.ts new file mode 100644 index 00000000..b4555438 --- /dev/null +++ b/src/server/services/__tests__/agent-connection.test.ts @@ -0,0 +1,193 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + AgentConnectionCapability, + AgentConnectionKind, + AgentConnectionLiveness, + AgentConnectionStatus, + AgentStatus, + LivenessConfidence, +} from "@prisma/client"; +import { + sanitizeAgentConnectionMetadata, + revokeAgentConnectionsForApiKey, + touchAgentConnection, + upsertAgentConnection, +} from "@/server/services/agent-connection"; +import { + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; +import { + handoffWorkSession, + joinWorkSession, +} from "@/server/services/work-session-participant"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => disconnectPrisma()); + +describe("agent connection provenance", () => { + it("redacts credential-shaped metadata and bounds protocol values", () => { + expect( + sanitizeAgentConnectionMetadata({ + client: { name: "Codex Desktop", version: "1.2.3" }, + authorization: "Bearer secret", + accessToken: "secret", + note: "x".repeat(700), + }), + ).toEqual({ + client: { name: "Codex Desktop", version: "1.2.3" }, + note: "x".repeat(512), + }); + }); + + it("idempotently registers a stable MCP instance and refreshes liveness", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AC" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Codex", + profileKey: `codex-${fixture.workspace.id}`, + status: AgentStatus.OFFLINE, + }, + }); + const apiKey = await prisma.apiKey.create({ + data: { + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + name: "Codex MCP test key", + hashedKey: `agent-connection-${fixture.workspace.id}`, + prefix: "forge_ac_test", + scopes: [], + linkedAgentId: agent.id, + }, + }); + + const input = { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: AgentConnectionKind.MCP_CLIENT, + livenessModel: AgentConnectionLiveness.LEASE, + apiKeyId: apiKey.id, + instanceKey: "mcp-session-123", + displayName: "Codex Desktop", + clientName: "codex-desktop", + clientVersion: "1.2.3", + capabilities: [AgentConnectionCapability.TOOL_ACTIVITY], + metadata: { protocolVersion: "2025-03-26", apiToken: "do-not-store" }, + }; + + const first = await upsertAgentConnection(prisma, input); + const second = await upsertAgentConnection(prisma, input); + expect(second.id).toBe(first.id); + expect(second).toMatchObject({ + status: AgentConnectionStatus.ACTIVE, + confidence: LivenessConfidence.CONFIRMED, + metadata: { protocolVersion: "2025-03-26" }, + }); + + const seenAt = new Date("2026-07-16T12:00:00.000Z"); + const touched = await touchAgentConnection(prisma, first.id, { + seenAt, + confidence: LivenessConfidence.INFERRED, + }); + expect(touched.lastSeenAt).toEqual(seenAt); + expect(touched.confidence).toBe(LivenessConfidence.INFERRED); + + expect(await revokeAgentConnectionsForApiKey(prisma, apiKey.id, seenAt)).toBe(1); + const revoked = await prisma.agentConnection.findUniqueOrThrow({ where: { id: first.id } }); + expect(revoked).toMatchObject({ + status: AgentConnectionStatus.REVOKED, + confidence: LivenessConfidence.CONFIRMED, + revokedAt: seenAt, + disconnectedAt: seenAt, + }); + }); + + it("joins non-primary participants and atomically hands off primary ownership", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AH" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); + const firstAgent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Codex", + profileKey: `codex-${fixture.workspace.id}`, + }, + }); + const secondAgent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Victor", + profileKey: `victor-${fixture.workspace.id}`, + }, + }); + const first = await upsertAgentConnection(prisma, { + workspaceId: fixture.workspace.id, + agentId: firstAgent.id, + kind: AgentConnectionKind.MCP_CLIENT, + livenessModel: AgentConnectionLiveness.LEASE, + instanceKey: "mcp-primary", + }); + const second = await upsertAgentConnection(prisma, { + workspaceId: fixture.workspace.id, + agentId: secondAgent.id, + kind: AgentConnectionKind.MANAGED_RUNTIME, + livenessModel: AgentConnectionLiveness.HEARTBEAT, + instanceKey: "runtime-reviewer", + }); + const session = await prisma.workSession.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + ownerAgentId: firstAgent.id, + ownerConnectionId: first.id, + source: "CODEX_DESKTOP", + repoFullName: "acme/forge", + branch: "codex/connection-handoff", + participants: { + create: { + workspaceId: fixture.workspace.id, + connectionId: first.id, + agentId: firstAgent.id, + role: "PRIMARY", + }, + }, + }, + }); + + await joinWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + connectionId: second.id, + role: "REVIEWER", + actor: { userId: fixture.user.id }, + }); + const handedOff = await handoffWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + toConnectionId: second.id, + actor: { userId: fixture.user.id }, + reason: "Operator reconciled observed execution ownership.", + }); + expect(handedOff).toMatchObject({ + ownerAgentId: secondAgent.id, + ownerConnectionId: second.id, + }); + const activePrimary = await prisma.workSessionParticipant.findMany({ + where: { workSessionId: session.id, role: "PRIMARY", leftAt: null }, + }); + expect(activePrimary).toHaveLength(1); + expect(activePrimary[0]?.connectionId).toBe(second.id); + }); +}); diff --git a/src/server/services/__tests__/agent-run-stale.test.ts b/src/server/services/__tests__/agent-run-stale.test.ts index 55ee6c52..4fdd8c32 100644 --- a/src/server/services/__tests__/agent-run-stale.test.ts +++ b/src/server/services/__tests__/agent-run-stale.test.ts @@ -144,4 +144,49 @@ describe("agent-run-stale — sweepStalledRuns", () => { const after = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }); expect(after.status).toBe("WAITING"); }); + + it("marks a quiet MCP connection unconfirmed without declaring its run stalled", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "ARM" }); + fixtures.push(fixture); + const prisma = getPrisma(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { agentRunStaleMinutes: 30 }, + }); + const agent = await createAgent(fixture.workspace.id, "arm-mcp"); + const connection = await prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + status: "ACTIVE", + confidence: "CONFIRMED", + instanceKey: `test-${Date.now()}`, + }, + }); + const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + connectionId: connection.id, + lifecycleConfidence: "CONFIRMED", + status: "ACTIVE", + }, + }); + await backdateRun(run.id, new Date(Date.now() - 90 * 60_000)); + + const result = await sweepStalledRuns(); + + expect(result.stalled).not.toContain(run.id); + expect(result.quiet).toContain(run.id); + await expect( + prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + ).resolves.toMatchObject({ status: "ACTIVE", lifecycleConfidence: "UNCONFIRMED" }); + await expect( + prisma.agentConnection.findUniqueOrThrow({ where: { id: connection.id } }), + ).resolves.toMatchObject({ status: "QUIET", confidence: "UNCONFIRMED" }); + }); }); diff --git a/src/server/services/__tests__/work-session.test.ts b/src/server/services/__tests__/work-session.test.ts index 7576704a..3fd986dc 100644 --- a/src/server/services/__tests__/work-session.test.ts +++ b/src/server/services/__tests__/work-session.test.ts @@ -6,8 +6,11 @@ import { advanceWorkSession, attachPullRequest, claimWorkSession, + resolveMcpQuietRequestsForConnection, sweepStaleWorkSessions, + touchWorkSession, } from "@/server/services/work-session"; +import { handoffWorkSession, joinWorkSession } from "@/server/services/work-session-participant"; import { createIssue, createWorkspaceFixture, @@ -65,6 +68,155 @@ describe("work session coordination", () => { ).rejects.toMatchObject({ code: "CONFLICT" }); }); + it("distinguishes same-agent connections and supports explicit join and handoff", async () => { + const { fixture, prisma, issue } = await setup(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `multi-connection-${Date.now()}`, + name: "Multi-connection agent", + }, + }); + const [primary, second] = await Promise.all( + ["primary", "second"].map((instanceKey) => + prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + instanceKey: `${instanceKey}-${Date.now()}`, + }, + }), + ), + ); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/connection-primary", + source: WorkSessionSource.FORGE_AGENT, + actor: { userId: fixture.user.id, agentId: agent.id, connectionId: primary!.id }, + }); + await expect( + joinWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + connectionId: primary!.id, + role: "REVIEWER", + actor: { userId: fixture.user.id, agentId: agent.id }, + }), + ).resolves.toMatchObject({ connectionId: primary!.id, role: "PRIMARY", leftAt: null }); + await expect( + claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/connection-primary", + source: WorkSessionSource.FORGE_AGENT, + actor: { userId: fixture.user.id, agentId: agent.id, connectionId: second!.id }, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + + await joinWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + connectionId: second!.id, + role: "REVIEWER", + actor: { userId: fixture.user.id, agentId: agent.id }, + }); + await handoffWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + toConnectionId: second!.id, + actor: { userId: fixture.user.id, agentId: agent.id }, + }); + + await expect( + prisma.workSession.findUniqueOrThrow({ where: { id: session.id } }), + ).resolves.toMatchObject({ ownerAgentId: agent.id, ownerConnectionId: second!.id }); + const participants = await prisma.workSessionParticipant.findMany({ + where: { workSessionId: session.id }, + orderBy: { joinedAt: "asc" }, + }); + expect(participants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + connectionId: primary!.id, + role: "PRIMARY", + leftAt: expect.any(Date), + }), + expect.objectContaining({ connectionId: second!.id, role: "PRIMARY", leftAt: null }), + ]), + ); + }); + + it("lets the same linked agent adopt and resume a legacy connectionless lease", async () => { + const { fixture, prisma, issue } = await setup(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `legacy-connection-${Date.now()}`, + name: "Legacy connection agent", + }, + }); + const connection = await prisma.agentConnection.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: agent.id, + kind: "MCP_CLIENT", + livenessModel: "LEASE", + instanceKey: `legacy-adopter-${Date.now()}`, + }, + }); + const legacy = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/legacy-connectionless", + source: WorkSessionSource.FORGE_AGENT, + actor: { userId: fixture.user.id, agentId: agent.id }, + }); + await prisma.actionRequest.create({ + data: { + workspaceId: fixture.workspace.id, + title: "MCP status is unconfirmed", + issueId: issue.id, + dedupeKey: `work-session-mcp-quiet:${legacy.id}`, + }, + }); + + await expect( + claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/legacy-connectionless", + source: WorkSessionSource.FORGE_AGENT, + actor: { + userId: fixture.user.id, + agentId: agent.id, + connectionId: connection.id, + }, + }), + ).resolves.toMatchObject({ id: legacy.id, ownerConnectionId: connection.id }); + await expect( + prisma.workSessionParticipant.findUniqueOrThrow({ + where: { + workSessionId_connectionId: { + workSessionId: legacy.id, + connectionId: connection.id, + }, + }, + }), + ).resolves.toMatchObject({ agentId: agent.id, role: "PRIMARY", leftAt: null }); + await expect( + prisma.actionRequest.findFirstOrThrow({ + where: { dedupeKey: `work-session-mcp-quiet:${legacy.id}` }, + }), + ).resolves.toMatchObject({ status: "RESOLVED", resolution: "Work session resumed." }); + }); + it("derives merge state from a native implementation PR and gates delivery milestones", async () => { const { fixture, prisma, issue } = await setup(); const session = await claimWorkSession(prisma, { @@ -155,6 +307,67 @@ describe("work session coordination", () => { expect(verified.endedAt).not.toBeNull(); }); + it("completes a live implementation run when its linked pull request merges", async () => { + const { fixture, prisma, issue } = await setup(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `merge-reconcile-${Date.now()}`, + name: "Merge reconcile agent", + }, + }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: "ACTIVE", + engagementMode: "EXECUTE", + currentStep: "Waiting for merge", + }, + }); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/reconcile-on-merge", + source: WorkSessionSource.FORGE_AGENT, + actor: { userId: fixture.user.id, agentId: agent.id }, + }); + const mergedAt = new Date(); + const pr = await prisma.externalResource.create({ + data: { + workspaceId: fixture.workspace.id, + provider: "GITHUB", + resourceType: "PULL_REQUEST", + repoFullName: "acme/forge", + number: 75, + url: "https://github.com/acme/forge/pull/75", + title: "Reconcile lifecycle", + state: "merged", + metadata: { + mergedAt: mergedAt.toISOString(), + head: { ref: session.branch, sha: "abcdef1234567" }, + }, + }, + }); + + await attachPullRequest(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + externalResourceId: pr.id, + actor: { userId: fixture.user.id, agentId: agent.id }, + }); + + await expect( + prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + ).resolves.toMatchObject({ + status: "COMPLETED", + currentStep: null, + summary: "Implementation completed when the linked pull request merged.", + }); + }); + it("automatically posts one human-readable PR handoff when configured", async () => { const { fixture, prisma, issue } = await setup(); await prisma.workspace.update({ @@ -382,4 +595,98 @@ describe("work session coordination", () => { "CLAIMED", ); }); + + it("keeps a quiet MCP-owned lease intact and requests explicit recovery", async () => { + const { fixture, prisma, issue } = await setup(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { workSessionStaleMinutes: 1 }, + }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `quiet-mcp-${Date.now()}`, + name: "Quiet MCP 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: `quiet-${Date.now()}`, + }, + }); + const session = await claimWorkSession(prisma, { + workspaceId: fixture.workspace.id, + issueId: issue.id, + repoFullName: "acme/forge", + branch: "codex/quiet-mcp", + source: WorkSessionSource.FORGE_AGENT, + actor: { + userId: fixture.user.id, + agentId: agent.id, + connectionId: connection.id, + }, + }); + await prisma.workSession.update({ + where: { id: session.id }, + data: { lastHeartbeatAt: new Date(Date.now() - 5 * 60_000) }, + }); + + expect(await sweepStaleWorkSessions(prisma)).toBe(0); + await expect( + prisma.workSession.findUniqueOrThrow({ where: { id: session.id } }), + ).resolves.toMatchObject({ status: "CLAIMED", staleAt: null }); + await expect( + prisma.agentConnection.findUniqueOrThrow({ where: { id: connection.id } }), + ).resolves.toMatchObject({ status: "QUIET", confidence: "UNCONFIRMED" }); + expect( + await prisma.actionRequest.count({ + where: { + workspaceId: fixture.workspace.id, + dedupeKey: `work-session-mcp-quiet:${session.id}`, + }, + }), + ).toBe(1); + + // The run watchdog may mark the shared connection quiet before the + // delivery sweep runs. Recovery must still be materialized in that order. + await prisma.actionRequest.deleteMany({ + where: { dedupeKey: `work-session-mcp-quiet:${session.id}` }, + }); + expect(await sweepStaleWorkSessions(prisma)).toBe(0); + expect( + await prisma.actionRequest.count({ + where: { dedupeKey: `work-session-mcp-quiet:${session.id}` }, + }), + ).toBe(1); + + expect( + await resolveMcpQuietRequestsForConnection(prisma, fixture.workspace.id, connection.id), + ).toBe(1); + 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 }, + }); + await touchWorkSession(prisma, { + workspaceId: fixture.workspace.id, + sessionId: session.id, + actor: { userId: fixture.user.id, agentId: agent.id, connectionId: connection.id }, + }); + await expect( + prisma.actionRequest.findFirstOrThrow({ + where: { dedupeKey: `work-session-mcp-quiet:${session.id}` }, + }), + ).resolves.toMatchObject({ status: "RESOLVED", resolution: "Work session resumed." }); + }); }); diff --git a/src/server/services/agent-connection.ts b/src/server/services/agent-connection.ts new file mode 100644 index 00000000..27b93131 --- /dev/null +++ b/src/server/services/agent-connection.ts @@ -0,0 +1,191 @@ +import "server-only"; + +import { + AgentConnectionStatus, + LivenessConfidence, + Prisma, + type AgentConnection, + type AgentConnectionCapability, + type AgentConnectionKind, + type AgentConnectionLiveness, + type PrismaClient, +} from "@prisma/client"; + +type DbClient = PrismaClient | Prisma.TransactionClient; + +export type UpsertAgentConnectionInput = { + workspaceId: string; + agentId: string; + kind: AgentConnectionKind; + livenessModel: AgentConnectionLiveness; + apiKeyId?: string | null; + runtimeId?: string | null; + instanceKey?: string | null; + displayName?: string | null; + clientName?: string | null; + clientVersion?: string | null; + capabilities?: AgentConnectionCapability[]; + metadata?: unknown; +}; + +type TouchAgentConnectionInput = { + status?: AgentConnectionStatus; + confidence?: LivenessConfidence; + seenAt?: Date; + metadata?: unknown; +}; + +const MAX_METADATA_KEYS = 32; +const MAX_METADATA_DEPTH = 3; +const MAX_METADATA_STRING = 512; + +/** + * Keep connection metadata safe for operator display. Protocol implementations + * must never persist raw request headers, credentials, environment variables, + * or arbitrary unbounded initialize payloads here. + */ +export function sanitizeAgentConnectionMetadata( + value: unknown, +): Prisma.InputJsonValue | Prisma.NullTypes.JsonNull | undefined { + function visit(input: unknown, depth: number): Prisma.InputJsonValue | null | undefined { + if (input === null) return null; + if (typeof input === "string") return input.slice(0, MAX_METADATA_STRING); + if (typeof input === "boolean" || typeof input === "number") return input; + if (depth >= MAX_METADATA_DEPTH) return undefined; + if (Array.isArray(input)) { + return input + .slice(0, MAX_METADATA_KEYS) + .map((item) => visit(item, depth + 1)) + .filter((item): item is Prisma.InputJsonValue | null => item !== undefined); + } + if (typeof input !== "object") return undefined; + + const result: Record = {}; + for (const [key, item] of Object.entries(input).slice(0, MAX_METADATA_KEYS)) { + if (/token|secret|password|authorization|cookie|credential/i.test(key)) continue; + const sanitized = visit(item, depth + 1); + if (sanitized !== undefined) result[key] = sanitized; + } + return result as Prisma.InputJsonObject; + } + + const sanitized = visit(value, 0); + return sanitized === null ? Prisma.JsonNull : sanitized; +} + +/** + * Register or refresh a concrete endpoint. A supplied instanceKey is the + * durable identity and makes initialize/reconnect idempotent. Legacy callers + * without an instance key reuse the most specific matching credential/runtime + * endpoint when possible, but should migrate to a negotiated key. + */ +export async function upsertAgentConnection( + db: DbClient, + input: UpsertAgentConnectionInput, +): Promise { + const now = new Date(); + const metadata = sanitizeAgentConnectionMetadata(input.metadata); + const common = { + livenessModel: input.livenessModel, + status: AgentConnectionStatus.ACTIVE, + confidence: LivenessConfidence.CONFIRMED, + apiKeyId: input.apiKeyId ?? null, + runtimeId: input.runtimeId ?? null, + displayName: input.displayName?.slice(0, 160) ?? null, + clientName: input.clientName?.slice(0, 120) ?? null, + clientVersion: input.clientVersion?.slice(0, 80) ?? null, + capabilities: input.capabilities ?? [], + ...(metadata !== undefined ? { metadata } : {}), + lastSeenAt: now, + connectedAt: now, + disconnectedAt: null, + revokedAt: null, + } satisfies Prisma.AgentConnectionUncheckedUpdateInput; + + if (input.instanceKey) { + return db.agentConnection.upsert({ + where: { + workspaceId_agentId_kind_instanceKey: { + workspaceId: input.workspaceId, + agentId: input.agentId, + kind: input.kind, + instanceKey: input.instanceKey.slice(0, 255), + }, + }, + create: { + workspaceId: input.workspaceId, + agentId: input.agentId, + kind: input.kind, + instanceKey: input.instanceKey.slice(0, 255), + firstSeenAt: now, + ...common, + }, + update: common, + }); + } + + const existing = await db.agentConnection.findFirst({ + where: { + workspaceId: input.workspaceId, + agentId: input.agentId, + kind: input.kind, + apiKeyId: input.apiKeyId ?? null, + runtimeId: input.runtimeId ?? null, + clientName: input.clientName ?? null, + revokedAt: null, + }, + orderBy: { lastSeenAt: "desc" }, + }); + + if (existing) { + return db.agentConnection.update({ where: { id: existing.id }, data: common }); + } + + return db.agentConnection.create({ + data: { + workspaceId: input.workspaceId, + agentId: input.agentId, + kind: input.kind, + instanceKey: null, + firstSeenAt: now, + ...common, + }, + }); +} + +/** Refresh liveness for an already-registered endpoint. */ +export async function touchAgentConnection( + db: DbClient, + connectionId: string, + input: TouchAgentConnectionInput = {}, +): Promise { + const metadata = sanitizeAgentConnectionMetadata(input.metadata); + return db.agentConnection.update({ + where: { id: connectionId }, + data: { + status: input.status ?? AgentConnectionStatus.ACTIVE, + confidence: input.confidence ?? LivenessConfidence.CONFIRMED, + lastSeenAt: input.seenAt ?? new Date(), + disconnectedAt: null, + ...(metadata !== undefined ? { metadata } : {}), + }, + }); +} + +/** Revoke every concrete client registered through a credential. */ +export async function revokeAgentConnectionsForApiKey( + db: DbClient, + apiKeyId: string, + revokedAt: Date = new Date(), +): Promise { + const result = await db.agentConnection.updateMany({ + where: { apiKeyId, revokedAt: null }, + data: { + status: AgentConnectionStatus.REVOKED, + confidence: LivenessConfidence.CONFIRMED, + revokedAt, + disconnectedAt: revokedAt, + }, + }); + return result.count; +} diff --git a/src/server/services/agent-run-recovery.ts b/src/server/services/agent-run-recovery.ts index 11a2d725..fc15b674 100644 --- a/src/server/services/agent-run-recovery.ts +++ b/src/server/services/agent-run-recovery.ts @@ -58,6 +58,17 @@ const runRecoverySelect = { engagementSource: true, runEngine: true, runEngineSource: true, + connection: { + select: { + id: true, + kind: true, + status: true, + confidence: true, + displayName: true, + clientName: true, + lastSeenAt: true, + }, + }, acknowledgedAt: true, outputStartedAt: true, producedArtifactIds: true, diff --git a/src/server/services/agent-run-stale.ts b/src/server/services/agent-run-stale.ts index 7a1e3fe2..aeceedaf 100644 --- a/src/server/services/agent-run-stale.ts +++ b/src/server/services/agent-run-stale.ts @@ -29,6 +29,8 @@ import { finishRun } from "@/server/services/agent-run"; export interface StalledRunSweepResult { workspacesScanned: number; stalled: string[]; + /** Lease-backed MCP attempts whose lifecycle is unknown, not failed. */ + quiet: string[]; } export async function sweepStalledRuns( @@ -41,6 +43,7 @@ export async function sweepStalledRuns( }); const stalled: string[] = []; + const quiet: string[] = []; for (const ws of workspaces) { const cutoff = new Date(now.getTime() - ws.agentRunStaleMinutes * 60_000); @@ -59,11 +62,32 @@ export async function sweepStalledRuns( agentId: true, lastEventAt: true, currentStep: true, + connectionId: true, + connection: { select: { kind: true, status: true } }, }, }); for (const run of candidates) { try { + if (run.connection?.kind === "MCP_CLIENT") { + if (run.connection.status === "QUIET") continue; + // Streamable HTTP clients do not provide a durable process signal. + // Silence expires confidence, not the run: external Git/PR work may + // still be progressing and automatic redispatch would create a + // competing implementation. + await client.agentRun.updateMany({ + where: { id: run.id, status: AgentRunStatus.ACTIVE }, + data: { lifecycleConfidence: "UNCONFIRMED" }, + }); + if (run.connectionId) { + await client.agentConnection.updateMany({ + where: { id: run.connectionId, status: "ACTIVE" }, + data: { status: "QUIET", confidence: "UNCONFIRMED" }, + }); + } + quiet.push(run.id); + continue; + } await finishRun(client, { runId: run.id, workspaceId: ws.id, @@ -84,5 +108,5 @@ export async function sweepStalledRuns( } } - return { workspacesScanned: workspaces.length, stalled }; + return { workspacesScanned: workspaces.length, stalled, quiet }; } diff --git a/src/server/services/agent-run.ts b/src/server/services/agent-run.ts index 40a368b5..89ce5670 100644 --- a/src/server/services/agent-run.ts +++ b/src/server/services/agent-run.ts @@ -1,5 +1,11 @@ import "server-only"; -import type { PrismaClient, AgentRun, EngagementMode, RunEngine } from "@prisma/client"; +import type { + PrismaClient, + AgentRun, + EngagementMode, + RunEngine, + LivenessConfidence, +} from "@prisma/client"; import { AgentRunStatus, EventKind, Prisma } from "@prisma/client"; import { recordChange } from "@/server/audit"; import { publish } from "@/server/realtime"; @@ -159,6 +165,9 @@ export async function openOrTouchRun( agentId: string; actorId?: string | null; actorAgentId?: string | null; + /** Concrete runtime/MCP/webhook endpoint responsible for this attempt. */ + connectionId?: string | null; + lifecycleConfidence?: LivenessConfidence; assignmentEventId?: string | null; currentStep?: string | null; /** Set when the run executes a Goal-orchestration ExecutionStep (AXI-57). */ @@ -283,6 +292,17 @@ export async function openOrTouchRun( : {}), ...(params.triggerEventId !== undefined ? { triggerEventId: params.triggerEventId } : {}), ...(params.triggerKind !== undefined ? { triggerKind: params.triggerKind } : {}), + // Ownership is sticky for a live attempt. Activity from another + // endpoint may be recorded on its event, but cannot silently take over + // the run that an existing connection opened. + ...(params.connectionId && !existing.connectionId + ? { + connectionId: params.connectionId, + ...(params.lifecycleConfidence + ? { lifecycleConfidence: params.lifecycleConfidence } + : {}), + } + : {}), }, }); return { run: updated, isNew: false }; @@ -293,6 +313,10 @@ export async function openOrTouchRun( workspaceId: params.workspaceId, issueId: params.issueId, agentId: params.agentId, + connectionId: params.connectionId ?? null, + ...(params.lifecycleConfidence + ? { lifecycleConfidence: params.lifecycleConfidence } + : {}), status: AgentRunStatus.ACTIVE, assignmentEventId: params.assignmentEventId ?? null, currentStep: params.currentStep ?? null, @@ -317,6 +341,7 @@ export async function openOrTouchRun( data: { workspaceId: params.workspaceId, runId: run.id, + connectionId: params.connectionId ?? null, kind: "STARTED", payload: { assignmentEventId: params.assignmentEventId ?? null }, }, @@ -376,6 +401,8 @@ export async function appendRunEvent( currentStep?: string | null; /** Internal operator audit events may be appended without reviving terminal UI state. */ allowTerminal?: boolean; + /** Endpoint that emitted this event; may differ from the run owner. */ + connectionId?: string | null; }, ): Promise { const now = new Date(); @@ -407,6 +434,7 @@ export async function appendRunEvent( data: { workspaceId: params.workspaceId, runId: params.runId, + connectionId: params.connectionId ?? null, kind: params.kind, payload: params.payload ?? Prisma.JsonNull, }, @@ -558,6 +586,7 @@ export async function finishRun( data: { workspaceId: params.workspaceId, runId: params.runId, + connectionId: existing.connectionId, kind: params.status, payload: { summary: params.summary ?? null }, }, diff --git a/src/server/services/dispatch/run-dispatcher.ts b/src/server/services/dispatch/run-dispatcher.ts index a7a99fb9..414fe60d 100644 --- a/src/server/services/dispatch/run-dispatcher.ts +++ b/src/server/services/dispatch/run-dispatcher.ts @@ -1,5 +1,14 @@ import "server-only"; -import { AgentRunStatus, EventKind, Prisma } from "@prisma/client"; +import { + AgentConnectionCapability, + AgentConnectionKind, + AgentConnectionLiveness, + AgentRunStatus, + ActionRequestKind, + EventKind, + NotificationSeverity, + Prisma, +} from "@prisma/client"; import type { AgentProvider } from "@prisma/client"; import { db } from "@/server/db"; import { logger } from "@/server/logger"; @@ -19,6 +28,8 @@ import { loadRunOrchestrationContext, } from "@/server/services/orchestration-context"; import { markExecutionStepRunning } from "@/server/services/execution-step-runtime"; +import { upsertAgentConnection } from "@/server/services/agent-connection"; +import { createActionRequest } from "@/server/services/action-request-service"; import { captureRunApproval, resolveRunApproval, @@ -247,6 +258,7 @@ async function startNewRuns(): Promise { select: { id: true, number: true, + authorId: true, title: true, description: true, assignedAgentId: true, @@ -260,6 +272,7 @@ async function startNewRuns(): Promise { maxConcurrent: true, runtime: { select: { + id: true, adapterKey: true, endpoint: true, secret: true, @@ -299,6 +312,75 @@ async function startNewRuns(): Promise { const engagementMode = (evtPayload.engagementMode as "EXECUTE" | "RESEARCH" | "REVIEW" | "DISCUSS" | undefined) ?? "EXECUTE"; + const runtimeConnection = agent.runtime + ? await upsertAgentConnection(db, { + workspaceId: evt.workspaceId, + agentId: agent.id, + kind: AgentConnectionKind.MANAGED_RUNTIME, + livenessModel: AgentConnectionLiveness.HEARTBEAT, + runtimeId: agent.runtime.id, + instanceKey: `runtime:${agent.runtime.id}`, + displayName: agent.runtime.name, + capabilities: [ + AgentConnectionCapability.HEARTBEAT, + AgentConnectionCapability.LIFECYCLE_REPORTING, + AgentConnectionCapability.CANCELLATION, + AgentConnectionCapability.STREAMING, + ], + metadata: { signal: "dispatch" }, + }) + : null; + + // One implementation endpoint owns delivery. A managed runtime must not + // silently start while an MCP client/other endpoint owns the active lease. + if (engagementMode === "EXECUTE" && runtimeConnection) { + const competingSession = await db.workSession.findFirst({ + where: { + issueId: issue.id, + endedAt: null, + status: { + in: [ + "CLAIMED", + "IN_PROGRESS", + "PR_OPEN", + "IN_REVIEW", + "READY_TO_MERGE", + "STALE", + ], + }, + ownerConnectionId: { not: null }, + NOT: { ownerConnectionId: runtimeConnection.id }, + }, + select: { id: true, ownerConnectionId: true }, + }); + if (competingSession) { + await createActionRequest(db, { + workspaceId: evt.workspaceId, + actorId: null, + title: `${issue.workspace.key}-${issue.number} already has a primary delivery connection`, + body: + `${agent.runtime?.name ?? "Managed runtime"} was not started because another connection owns the active delivery session. ` + + "Join as a reviewer or contributor, hand off primary ownership, or cancel this dispatch.", + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + assignedUserId: issue.authorId, + assignedAgentId: agent.id, + sourceType: "work-session", + sourceId: competingSession.id, + dedupeKey: `work-session-execution-conflict:${competingSession.id}:${runtimeConnection.id}`, + issueId: issue.id, + }); + logger.warn( + { + issueId: issue.id, + runtimeConnectionId: runtimeConnection.id, + ownerConnectionId: competingSession.ownerConnectionId, + }, + "managed runtime dispatch blocked by another delivery connection", + ); + continue; + } + } const instruction = forgeRunInstruction( { mode: engagementMode, @@ -351,6 +433,8 @@ async function startNewRuns(): Promise { workspaceId: evt.workspaceId, issueId: issue.id, agentId: agent.id, + connectionId: runtimeConnection?.id ?? null, + lifecycleConfidence: runtimeConnection ? "CONFIRMED" : "UNCONFIRMED", assignmentEventId: evt.id, currentStep: "starting run", executionStepId: orchestrationContext?.step?.id ?? already?.executionStepId ?? null, diff --git a/src/server/services/heartbeat.ts b/src/server/services/heartbeat.ts index 91bf6fe4..8585db74 100644 --- a/src/server/services/heartbeat.ts +++ b/src/server/services/heartbeat.ts @@ -1,9 +1,16 @@ import "server-only"; import type { Prisma, PrismaClient } from "@prisma/client"; -import { AgentStatus, EventKind } from "@prisma/client"; +import { + AgentConnectionCapability, + AgentConnectionKind, + AgentConnectionLiveness, + AgentStatus, + EventKind, +} from "@prisma/client"; import { db } from "@/server/db"; import { logger } from "@/server/logger"; import { recordChange } from "@/server/audit"; +import { upsertAgentConnection } from "@/server/services/agent-connection"; /** * Heartbeat-driven agent auto-offline. @@ -141,21 +148,31 @@ export async function sweepIdleAgents( export async function recordAgentReachable( agentId: string, client: PrismaClient | Prisma.TransactionClient = db, -): Promise { +): Promise { try { const before = await client.agent.findUnique({ where: { id: agentId }, select: { id: true, status: true, workspaceId: true, profileKey: true }, }); - if (!before) return; + if (!before) return null; const now = new Date(); + const connection = await upsertAgentConnection(client, { + workspaceId: before.workspaceId, + agentId: before.id, + kind: AgentConnectionKind.WEBHOOK, + livenessModel: AgentConnectionLiveness.PUSH_ACK, + instanceKey: `agent-webhook:${before.id}`, + displayName: `${before.profileKey} webhook`, + capabilities: [AgentConnectionCapability.PUSH_ACK], + metadata: { signal: "delivery-success" }, + }); if (before.status === AgentStatus.ONLINE) { await client.agent.update({ where: { id: agentId }, data: { lastHeartbeatAt: now }, }); - return; + return connection.id; } // Status transition. Bump heartbeat + flip status, then audit. @@ -182,8 +199,10 @@ export async function recordAgentReachable( reason: "delivery-success", }, }); + return connection.id; } catch (err) { logger.warn({ err, agentId }, "recordAgentReachable failed"); + return null; } } @@ -221,6 +240,22 @@ export async function recordRuntimeHeartbeatPresence( }); for (const agent of agents) { + await upsertAgentConnection(client, { + workspaceId: agent.workspaceId, + agentId: agent.id, + kind: AgentConnectionKind.MANAGED_RUNTIME, + livenessModel: AgentConnectionLiveness.HEARTBEAT, + runtimeId, + instanceKey: `runtime:${runtimeId}`, + displayName: `${agent.profileKey} runtime`, + capabilities: [ + AgentConnectionCapability.HEARTBEAT, + AgentConnectionCapability.LIFECYCLE_REPORTING, + AgentConnectionCapability.CANCELLATION, + AgentConnectionCapability.STREAMING, + ], + metadata: { signal: "runtime-heartbeat" }, + }); // ONLINE / BUSY → just refresh the heartbeat (preserve a working agent's // BUSY status; the sweep treats both as live). if ( diff --git a/src/server/services/mcp-instructions.ts b/src/server/services/mcp-instructions.ts index c876f340..c9dc22d3 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, 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. 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."; diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 228d0198..a67d9cff 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -148,6 +148,8 @@ import { listIssueWorkSessions, touchWorkSession, } from "@/server/services/work-session"; +import { revokeAgentConnectionsForApiKey } from "@/server/services/agent-connection"; +import { handoffWorkSession, joinWorkSession } from "@/server/services/work-session-participant"; /** * Forge's MCP (Model Context Protocol) surface — the stable set of tools any @@ -180,6 +182,8 @@ export interface McpContext { * apply `buildKeyScopeWhere` + `assertKeyScope`. */ apiKey: ApiKeyContext | null; + /** Concrete MCP client instance negotiated by the HTTP transport. */ + connectionId?: string | null; } /** @@ -1952,6 +1956,8 @@ export const mcpTools = { agentId, actorId, actorAgentId: agentId, + connectionId: ctx.connectionId ?? null, + lifecycleConfidence: "UNCONFIRMED", currentStep: `→ ${status.name}`, resumeWaiting: true, }); @@ -1961,6 +1967,7 @@ export const mcpTools = { workspaceId: ctx.workspaceId, issueId: input.id, agentId, + connectionId: ctx.connectionId ?? null, kind: "TRANSITION", payload: { statusId: status.id, category: status.category }, }); @@ -3079,6 +3086,8 @@ export const mcpTools = { agentId: authoringAgentId, actorId: authorId, actorAgentId: authoringAgentId, + connectionId: ctx.connectionId ?? null, + lifecycleConfidence: "UNCONFIRMED", }); if (isNew) { await tx.agentRun.updateMany({ @@ -3091,6 +3100,7 @@ export const mcpTools = { workspaceId: ctx.workspaceId, issueId: input.issueId, agentId: authoringAgentId, + connectionId: ctx.connectionId ?? null, kind: "COMMENT", payload: { commentId: comment.id, preview: input.body.slice(0, 120) }, }); @@ -3372,6 +3382,8 @@ export const mcpTools = { agentId, actorId: authorId, actorAgentId: agentId, + connectionId: ctx.connectionId ?? null, + lifecycleConfidence: "UNCONFIRMED", currentStep: input.currentStep ?? null, }); if (isNew) { @@ -3429,6 +3441,7 @@ export const mcpTools = { workspaceId: ctx.workspaceId, issueId: input.issueId, agentId, + connectionId: ctx.connectionId ?? null, kind: "STATUS", payload: { commentId: comment.id, preview: input.body.slice(0, 120) }, currentStep: input.currentStep ?? null, @@ -5697,7 +5710,9 @@ export const mcpTools = { select: { id: true, externalSessionId: true, capabilities: true }, }); if (!mapping) { - throw new Error("No tenant-scoped Forge-owned Hermes session mapping matches this event."); + throw new Error( + "No tenant-scoped Forge-owned Hermes session mapping matches this event.", + ); } await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${envelope.threadId}))`; const duplicate = await tx.connectorDelivery.findUnique({ @@ -5711,7 +5726,11 @@ export const mcpTools = { select: { chatMessageId: true }, }); if (duplicate) { - return { accepted: true, duplicate: true, messageId: duplicate.chatMessageId ?? undefined }; + return { + accepted: true, + duplicate: true, + messageId: duplicate.chatMessageId ?? undefined, + }; } const prior = await tx.connectorDelivery.findFirst({ where: { @@ -5722,7 +5741,11 @@ export const mcpTools = { orderBy: { sequence: "desc" }, select: { sequence: true }, }); - if (prior?.sequence !== null && prior?.sequence !== undefined && envelope.sequence <= prior.sequence) { + if ( + prior?.sequence !== null && + prior?.sequence !== undefined && + envelope.sequence <= prior.sequence + ) { throw new Error("Connector event sequence is stale for this mapped session."); } const expectedSequence = (prior?.sequence ?? 0) + 1; @@ -5746,13 +5769,16 @@ export const mcpTools = { }, }); - const isFinal = - envelope.kind === "message.final" || envelope.kind === "message.proactive"; + const isFinal = envelope.kind === "message.final" || envelope.kind === "message.proactive"; let messageId: string | undefined; if (isFinal) { - const bodyValue = envelope.payload.body ?? envelope.payload.content ?? envelope.payload.text; + const bodyValue = + envelope.payload.body ?? envelope.payload.content ?? envelope.payload.text; const body = typeof bodyValue === "string" ? bodyValue.trim() : ""; - if (!body) throw new Error("Final/proactive connector messages require payload body/content/text."); + if (!body) + throw new Error( + "Final/proactive connector messages require payload body/content/text.", + ); if (envelope.replyToMessageId) { const target = await tx.chatMessage.findFirst({ where: { @@ -5782,8 +5808,7 @@ export const mcpTools = { body, sequence: (aggregate._max.sequence ?? 0) + 1, replyToMessageId: envelope.replyToMessageId ?? null, - externalMessageId: - envelope.attribution.hermesMessageId ?? envelope.idempotency.key, + externalMessageId: envelope.attribution.hermesMessageId ?? envelope.idempotency.key, connectorSessionId: mapping.id, contextSnapshot: { proactive: envelope.kind === "message.proactive", @@ -5860,7 +5885,10 @@ export const mcpTools = { attribution: true, platformProtocolVersion: envelope.protocolVersion, }, - lastError: envelope.kind === "delivery.error" ? String(envelope.payload.error ?? "Delivery error") : null, + lastError: + envelope.kind === "delivery.error" + ? String(envelope.payload.error ?? "Delivery error") + : null, lastErrorAt: envelope.kind === "delivery.error" ? new Date() : null, }, }); @@ -6897,6 +6925,22 @@ export const mcpTools = { select: { id: true }, }); if (!issue) throw new Error("Issue not found in this workspace."); + if (ctx.connectionId) { + const active = await db.agentRun.findFirst({ + where: { + workspaceId: ctx.workspaceId, + issueId: issue.id, + agentId: linkedAgentId, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + }, + select: { connectionId: true }, + }); + if (active?.connectionId && active.connectionId !== ctx.connectionId) { + throw new Error( + "Another connection already owns this active run. Join in a non-primary role or hand off the run before executing.", + ); + } + } const { run, isNew } = await db.$transaction((tx) => openOrTouchRun(tx, { workspaceId: ctx.workspaceId, @@ -6904,6 +6948,8 @@ export const mcpTools = { agentId: linkedAgentId, actorId: ctx.userId ?? null, actorAgentId: linkedAgentId, + connectionId: ctx.connectionId ?? null, + lifecycleConfidence: "UNCONFIRMED", engagementMode: (input.mode as EngagementMode | undefined) ?? "EXECUTE", currentStep: input.summary ?? "opened by agent", resumeWaiting: true, @@ -7163,6 +7209,7 @@ export const mcpTools = { status: true, startedAt: true, engagementMode: true, + connectionId: true, executionStepId: true, agent: { select: { profileKey: true, name: true } }, issue: { @@ -7178,6 +7225,9 @@ export const mcpTools = { if (run.agentId !== linkedAgentId) { throw new Error("AgentRun belongs to a different agent than the calling key."); } + if (ctx.connectionId && run.connectionId && ctx.connectionId !== run.connectionId) { + throw new Error("AgentRun belongs to a different execution connection."); + } if (run.status !== AgentRunStatus.ACTIVE && run.status !== AgentRunStatus.WAITING) { throw new Error(`Run is ${run.status}; only ACTIVE / WAITING runs can be completed.`); } @@ -7254,6 +7304,7 @@ export const mcpTools = { data.followUps = input.followUps as Prisma.InputJsonValue; } data.completionMeta = completionMeta as Prisma.InputJsonValue; + data.lifecycleConfidence = "CONFIRMED"; const completed = await db.$transaction(async (tx) => { const updated = await tx.agentRun.updateMany({ where: { @@ -8289,10 +8340,14 @@ export const mcpTools = { select: { id: true }, }); if (!key) throw new Error("Access key not found in selected workspace."); - return db.apiKey.update({ - where: { id: key.id }, - data: { revokedAt: new Date() }, - select: { id: true, prefix: true, kind: true, revokedAt: true }, + const revokedAt = new Date(); + return db.$transaction(async (tx) => { + await revokeAgentConnectionsForApiKey(tx, key.id, revokedAt); + return tx.apiKey.update({ + where: { id: key.id }, + data: { revokedAt }, + select: { id: true, prefix: true, kind: true, revokedAt: true }, + }); }); }, }, @@ -8345,7 +8400,7 @@ export const mcpTools = { workspaceId: ctx.workspaceId, ...input, source: WorkSessionSource.FORGE_AGENT, - actor: { userId: ctx.userId, agentId }, + actor: { userId: ctx.userId, agentId, connectionId: ctx.connectionId ?? null }, }); }, }, @@ -8370,13 +8425,73 @@ export const mcpTools = { const agentId = ctx.apiKey?.linkedAgentId; if (!agentId) throw new Error("workSessions.heartbeat requires a linked agent key."); const owned = await db.workSession.findFirst({ - where: { id: input.sessionId, workspaceId: ctx.workspaceId, ownerAgentId: agentId }, + where: { + id: input.sessionId, + workspaceId: ctx.workspaceId, + ...(ctx.connectionId + ? { ownerConnectionId: ctx.connectionId } + : { ownerAgentId: agentId, ownerConnectionId: null }), + }, select: { id: true }, }); if (!owned) throw new Error("Work session is not owned by this agent."); return touchWorkSession(db, { workspaceId: ctx.workspaceId, ...input, + 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.", + scopes: ["WRITE_ISSUES"] as const, + input: z.object({ + sessionId: z.string().cuid(), + role: z.enum(["CONTRIBUTOR", "REVIEWER"]), + }), + async run(input: { sessionId: string; role: "CONTRIBUTOR" | "REVIEWER" }, ctx: McpContext) { + const agentId = ctx.apiKey?.linkedAgentId; + if (!agentId || !ctx.connectionId) { + throw new Error("workSessions.join requires a registered agent connection."); + } + return joinWorkSession(db, { + workspaceId: ctx.workspaceId, + sessionId: input.sessionId, + connectionId: ctx.connectionId, + role: input.role, + actor: { userId: ctx.userId, agentId }, + }); + }, + }, + + "workSessions.handoff": { + description: + "Transfer primary delivery authority from the calling connection to another registered connection. The handoff is explicit and audited.", + scopes: ["WRITE_ISSUES"] as const, + input: z.object({ + sessionId: z.string().cuid(), + targetConnectionId: z.string().cuid(), + }), + async run(input: { sessionId: string; targetConnectionId: string }, ctx: McpContext) { + const agentId = ctx.apiKey?.linkedAgentId; + if (!agentId || !ctx.connectionId) { + throw new Error("workSessions.handoff requires a registered agent connection."); + } + const owned = await db.workSession.findFirst({ + where: { + id: input.sessionId, + workspaceId: ctx.workspaceId, + ownerConnectionId: ctx.connectionId, + }, + select: { id: true }, + }); + if (!owned) throw new Error("Only the primary connection can hand off delivery."); + return handoffWorkSession(db, { + workspaceId: ctx.workspaceId, + sessionId: input.sessionId, + toConnectionId: input.targetConnectionId, actor: { userId: ctx.userId, agentId }, }); }, @@ -8402,14 +8517,20 @@ export const mcpTools = { const agentId = ctx.apiKey?.linkedAgentId; if (!agentId) throw new Error("workSessions.attachPullRequest requires a linked agent key."); const owned = await db.workSession.findFirst({ - where: { id: input.sessionId, workspaceId: ctx.workspaceId, ownerAgentId: agentId }, + where: { + id: input.sessionId, + workspaceId: ctx.workspaceId, + ...(ctx.connectionId + ? { ownerConnectionId: ctx.connectionId } + : { ownerAgentId: agentId, ownerConnectionId: null }), + }, select: { id: true }, }); if (!owned) throw new Error("Work session is not owned by this agent."); return attachPullRequest(db, { workspaceId: ctx.workspaceId, ...input, - actor: { userId: ctx.userId, agentId }, + actor: { userId: ctx.userId, agentId, connectionId: ctx.connectionId ?? null }, }); }, }, @@ -15048,7 +15169,16 @@ export const MCP_TOOL_PROFILES: Record = { runtime: [ "issues", "comments", - "chat", + // Keep the agent-facing chat loop direct, but leave connector transport + // negotiation/delivery discoverable through catalog.call. Those two + // server-to-server tools are not runtime essentials and would consume the + // provider headroom this compact default exists to protect. + "chat.getThread", + "chat.kickThread", + "chat.appendMessage", + "chat.startDraft", + "chat.appendDraftChunk", + "chat.finalizeDraft", "runs", "workSessions", "actionRequests.list", diff --git a/src/server/services/work-session-participant.ts b/src/server/services/work-session-participant.ts new file mode 100644 index 00000000..36e6cf6d --- /dev/null +++ b/src/server/services/work-session-participant.ts @@ -0,0 +1,199 @@ +import "server-only"; + +import { + EventKind, + type Prisma, + type PrismaClient, + type WorkSessionParticipantRole, +} from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { recordChange } from "@/server/audit"; + +export type WorkSessionParticipantActor = { + userId: string | null; + agentId?: string | null; +}; + +async function requireConnection( + tx: Prisma.TransactionClient, + workspaceId: string, + connectionId: string, +) { + const connection = await tx.agentConnection.findFirst({ + where: { id: connectionId, workspaceId, revokedAt: null }, + select: { id: true, agentId: true }, + }); + if (!connection) { + throw new TRPCError({ code: "NOT_FOUND", message: "Agent connection not found." }); + } + return connection; +} + +/** Join delivery without acquiring primary branch/heartbeat authority. */ +export async function joinWorkSession( + db: PrismaClient, + input: { + workspaceId: string; + sessionId: string; + connectionId: string; + role: Exclude; + actor: WorkSessionParticipantActor; + }, +) { + return db.$transaction(async (tx) => { + const session = await tx.workSession.findFirst({ + where: { id: input.sessionId, workspaceId: input.workspaceId, endedAt: null }, + select: { id: true, issueId: true, ownerConnectionId: true }, + }); + if (!session) throw new TRPCError({ code: "NOT_FOUND", message: "Work session not found." }); + const connection = await requireConnection(tx, input.workspaceId, input.connectionId); + if (session.ownerConnectionId === connection.id) { + // Joining is collaboration-only. If the lease owner calls it, preserve + // (and repair, for legacy rows) the canonical PRIMARY participant rather + // than demoting the owner to the requested secondary role. + return tx.workSessionParticipant.upsert({ + where: { + workSessionId_connectionId: { + workSessionId: session.id, + connectionId: connection.id, + }, + }, + create: { + workspaceId: input.workspaceId, + workSessionId: session.id, + connectionId: connection.id, + agentId: connection.agentId, + role: "PRIMARY", + }, + update: { agentId: connection.agentId, role: "PRIMARY", leftAt: null }, + }); + } + const participant = await tx.workSessionParticipant.upsert({ + where: { + workSessionId_connectionId: { + workSessionId: session.id, + connectionId: connection.id, + }, + }, + create: { + workspaceId: input.workspaceId, + workSessionId: session.id, + connectionId: connection.id, + agentId: connection.agentId, + role: input.role, + }, + update: { agentId: connection.agentId, role: input.role, leftAt: null }, + }); + await recordChange(tx, { + workspaceId: input.workspaceId, + actorId: input.actor.userId, + actorAgentId: input.actor.agentId ?? null, + entity: "WorkSessionParticipant", + entityId: participant.id, + action: "work-session-participant-joined", + before: undefined, + after: { connectionId: connection.id, agentId: connection.agentId, role: input.role }, + eventKind: EventKind.ISSUE_UPDATED, + subjectType: "issue", + subjectId: session.issueId, + payload: { + workSessionId: session.id, + connectionId: connection.id, + agentId: connection.agentId, + role: input.role, + }, + }); + return participant; + }); +} + +/** + * Atomically transfer primary execution authority to another connection. + * The previous primary remains in history with leftAt set; it is never + * silently converted into a contributor. + */ +export async function handoffWorkSession( + db: PrismaClient, + input: { + workspaceId: string; + sessionId: string; + toConnectionId: string; + actor: WorkSessionParticipantActor; + reason?: string | null; + }, +) { + return db.$transaction(async (tx) => { + // Serialize handoffs for this lease so the partial one-primary index is a + // final invariant rather than the normal concurrency-control mechanism. + await tx.$queryRaw`SELECT "id" FROM "WorkSession" WHERE "id" = ${input.sessionId} FOR UPDATE`; + const session = await tx.workSession.findFirst({ + where: { id: input.sessionId, workspaceId: input.workspaceId, endedAt: null }, + select: { + id: true, + issueId: true, + ownerAgentId: true, + ownerConnectionId: true, + }, + }); + if (!session) throw new TRPCError({ code: "NOT_FOUND", message: "Work session not found." }); + const target = await requireConnection(tx, input.workspaceId, input.toConnectionId); + if (session.ownerConnectionId === target.id) { + return tx.workSession.findUniqueOrThrow({ where: { id: session.id } }); + } + + const now = new Date(); + await tx.workSessionParticipant.updateMany({ + where: { workSessionId: session.id, role: "PRIMARY", leftAt: null }, + data: { leftAt: now }, + }); + await tx.workSessionParticipant.upsert({ + where: { + workSessionId_connectionId: { + workSessionId: session.id, + connectionId: target.id, + }, + }, + create: { + workspaceId: input.workspaceId, + workSessionId: session.id, + connectionId: target.id, + agentId: target.agentId, + role: "PRIMARY", + joinedAt: now, + }, + update: { agentId: target.agentId, role: "PRIMARY", joinedAt: now, leftAt: null }, + }); + const updated = await tx.workSession.update({ + where: { id: session.id }, + data: { + ownerAgentId: target.agentId, + ownerConnectionId: target.id, + lastHeartbeatAt: now, + staleAt: null, + }, + }); + await recordChange(tx, { + workspaceId: input.workspaceId, + actorId: input.actor.userId, + actorAgentId: input.actor.agentId ?? null, + entity: "WorkSession", + entityId: session.id, + action: "work-session-handed-off", + before: { + ownerAgentId: session.ownerAgentId, + ownerConnectionId: session.ownerConnectionId, + }, + after: { ownerAgentId: target.agentId, ownerConnectionId: target.id }, + eventKind: EventKind.ISSUE_UPDATED, + subjectType: "issue", + subjectId: session.issueId, + payload: { + workSessionId: session.id, + fromConnectionId: session.ownerConnectionId, + toConnectionId: target.id, + reason: input.reason?.slice(0, 500) ?? null, + } as Prisma.InputJsonObject, + }); + return updated; + }); +} diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts index 3780f753..66cd32b8 100644 --- a/src/server/services/work-session.ts +++ b/src/server/services/work-session.ts @@ -14,6 +14,7 @@ import { TRPCError } from "@trpc/server"; import { recordChange } from "@/server/audit"; import { createActionRequest } from "@/server/services/action-request-service"; import { autoWatchActor } from "@/server/services/issue-watchers"; +import { finishRun } from "@/server/services/agent-run"; type DbClient = PrismaClient | Prisma.TransactionClient; @@ -47,13 +48,14 @@ const STALEABLE_WORK_SESSION_STATUSES: readonly WorkSessionStatus[] = [ export type WorkSessionActor = { userId: string | null; agentId?: string | null; + connectionId?: string | null; }; export type DeliveryTimelineUpdate = { body: string; }; -async function resolveStaleRequest( +async function resolveRecoveryRequests( db: PrismaClient, workspaceId: string, sessionId: string, @@ -62,7 +64,9 @@ async function resolveStaleRequest( await db.actionRequest.updateMany({ where: { workspaceId, - dedupeKey: `work-session-stale:${sessionId}`, + dedupeKey: { + in: [`work-session-stale:${sessionId}`, `work-session-mcp-quiet:${sessionId}`], + }, status: ActionRequestStatus.OPEN, }, data: { @@ -73,6 +77,46 @@ async function resolveStaleRequest( }); } +/** + * 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. + */ +export async function resolveMcpQuietRequestsForConnection( + db: PrismaClient, + workspaceId: string, + connectionId: string, + resolution = "MCP connection resumed.", +) { + const sessions = await db.workSession.findMany({ + where: { + workspaceId, + ownerConnectionId: connectionId, + endedAt: null, + status: { in: [...ACTIVE_WORK_SESSION_STATUSES] }, + }, + select: { id: true }, + }); + if (sessions.length === 0) return 0; + const now = new Date(); + const result = await db.actionRequest.updateMany({ + where: { + workspaceId, + status: ActionRequestStatus.OPEN, + dedupeKey: { + in: sessions.map((session) => `work-session-mcp-quiet:${session.id}`), + }, + }, + data: { + status: ActionRequestStatus.RESOLVED, + resolvedAt: now, + resolution, + }, + }); + return result.count; +} + function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -125,6 +169,42 @@ export function deriveWorkSessionPrStatus(resource: { return WorkSessionStatus.IN_REVIEW; } +/** + * A merged implementation PR is authoritative evidence that the implementation + * attempt has ended. MCP clients are not guaranteed to call `runs.complete` + * after GitHub merges, so reconcile live EXECUTE runs at this delivery + * boundary. Runs opened after the merge are deliberately excluded: those may + * represent release/deploy work and have their own lifecycle. + */ +async function reconcileMergedImplementationRuns( + db: DbClient, + input: { workspaceId: string; issueId: string; mergedAt: Date }, +): Promise { + const runs = await db.agentRun.findMany({ + where: { + workspaceId: input.workspaceId, + issueId: input.issueId, + status: { in: ["ACTIVE", "WAITING"] }, + engagementMode: "EXECUTE", + startedAt: { lte: input.mergedAt }, + }, + select: { id: true, agentId: true }, + }); + let completed = 0; + for (const run of runs) { + const result = await finishRun(db, { + runId: run.id, + workspaceId: input.workspaceId, + issueId: input.issueId, + agentId: run.agentId, + status: "COMPLETED", + summary: "Implementation completed when the linked pull request merged.", + }); + if (result?.status === "COMPLETED") completed += 1; + } + return completed; +} + async function auditSession( db: DbClient, session: { id: string; workspaceId: string; issueId: string }, @@ -180,9 +260,46 @@ export async function claimWorkSession( }, }); if (active) { - const sameOwner = input.actor.agentId - ? active.ownerAgentId === input.actor.agentId - : active.ownerUserId === input.actor.userId; + // A concrete endpoint is the delivery lease owner. Two runtimes/MCP + // clients bound to the same Agent are distinct and must explicitly hand + // off instead of silently sharing primary execution authority. + const exactConnectionOwner = Boolean( + active.ownerConnectionId && + input.actor.connectionId && + active.ownerConnectionId === input.actor.connectionId, + ); + // Legacy FORGE_AGENT sessions predate connection ownership. The first + // concrete connection for the already-linked logical agent may resume + // and atomically adopt that lease; a different agent still conflicts. + const resumableLegacyAgentOwner = Boolean( + !active.ownerConnectionId && + input.actor.connectionId && + input.actor.agentId && + active.ownerAgentId === input.actor.agentId, + ); + if (resumableLegacyAgentOwner) { + const actorConnection = await tx.agentConnection.findFirst({ + where: { + id: input.actor.connectionId!, + workspaceId: input.workspaceId, + agentId: input.actor.agentId!, + revokedAt: null, + }, + select: { id: true }, + }); + if (!actorConnection) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "The connection does not belong to the work-session agent.", + }); + } + } + const connectionScoped = Boolean(active.ownerConnectionId || input.actor.connectionId); + const sameOwner = connectionScoped + ? exactConnectionOwner || resumableLegacyAgentOwner + : input.actor.agentId + ? active.ownerAgentId === input.actor.agentId + : active.ownerUserId === input.actor.userId; if ( sameOwner && active.repoFullName === input.repoFullName && @@ -193,6 +310,32 @@ export async function claimWorkSession( data: { lastHeartbeatAt: new Date(), staleAt: null, + ...(resumableLegacyAgentOwner + ? { + ownerConnectionId: input.actor.connectionId, + participants: { + upsert: { + where: { + workSessionId_connectionId: { + workSessionId: active.id, + connectionId: input.actor.connectionId!, + }, + }, + create: { + workspaceId: input.workspaceId, + agentId: input.actor.agentId!, + connectionId: input.actor.connectionId!, + role: "PRIMARY", + }, + update: { + agentId: input.actor.agentId!, + role: "PRIMARY", + leftAt: null, + }, + }, + }, + } + : {}), status: active.status === WorkSessionStatus.STALE ? WorkSessionStatus.IN_PROGRESS @@ -218,18 +361,31 @@ export async function claimWorkSession( issueId: input.issueId, ownerUserId: input.actor.userId, ownerAgentId: input.actor.agentId ?? null, + ownerConnectionId: input.actor.connectionId ?? null, source: input.source, status: WorkSessionStatus.CLAIMED, repoFullName: input.repoFullName, branch: input.branch, baseBranch: input.baseBranch || "main", worktreePath: input.worktreePath ?? null, + ...(input.actor.connectionId && input.actor.agentId + ? { + participants: { + create: { + workspaceId: input.workspaceId, + agentId: input.actor.agentId, + connectionId: input.actor.connectionId, + role: "PRIMARY", + }, + }, + } + : {}), }, }); await auditSession(tx, created, input.actor, "work-session-claimed", undefined, created); return created; }); - await resolveStaleRequest(db, input.workspaceId, result.id, "Work session resumed."); + await resolveRecoveryRequests(db, input.workspaceId, result.id, "Work session resumed."); return result; } @@ -250,6 +406,43 @@ export async function listIssueWorkSessions( include: { ownerUser: { select: { id: true, name: true, email: true, image: true } }, ownerAgent: { select: { id: true, name: true, profileKey: true, avatar: true } }, + ownerConnection: { + select: { + id: true, + kind: true, + livenessModel: true, + status: true, + confidence: true, + displayName: true, + clientName: true, + clientVersion: true, + lastSeenAt: true, + disconnectedAt: true, + agent: { select: { id: true, name: true, profileKey: true } }, + runtime: { select: { id: true, name: true } }, + }, + }, + participants: { + where: { leftAt: null }, + orderBy: [{ role: "asc" }, { joinedAt: "asc" }], + select: { + id: true, + role: true, + joinedAt: true, + agent: { select: { id: true, name: true, profileKey: true, avatar: true } }, + connection: { + select: { + id: true, + kind: true, + status: true, + confidence: true, + displayName: true, + clientName: true, + lastSeenAt: true, + }, + }, + }, + }, pullRequest: { select: { id: true, @@ -265,6 +458,63 @@ export async function listIssueWorkSessions( }, }, }); + const [latestImplementationRun, latestAgentComment] = await Promise.all([ + db.agentRun.findFirst({ + where: { workspaceId, issueId, engagementMode: "EXECUTE" }, + orderBy: [{ lastEventAt: "desc" }, { startedAt: "desc" }], + select: { + lastEventAt: true, + agent: { + select: { + id: true, + name: true, + profileKey: true, + avatar: true, + connections: { + where: { revokedAt: null }, + orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }], + take: 1, + select: { id: true, displayName: true, clientName: true, kind: true }, + }, + }, + }, + }, + }), + db.comment.findFirst({ + where: { workspaceId, issueId, authoringAgentId: { not: null } }, + orderBy: { updatedAt: "desc" }, + select: { + updatedAt: true, + authoringAgent: { + select: { + id: true, + name: true, + profileKey: true, + avatar: true, + connections: { + where: { revokedAt: null }, + orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }], + take: 1, + select: { id: true, displayName: true, clientName: true, kind: true }, + }, + }, + }, + }, + }), + ]); + const observedImplementation = latestImplementationRun + ? { + agent: latestImplementationRun.agent, + observedAt: latestImplementationRun.lastEventAt, + source: "implementation-run" as const, + } + : latestAgentComment?.authoringAgent + ? { + agent: latestAgentComment.authoringAgent, + observedAt: latestAgentComment.updatedAt, + source: "agent-comment" as const, + } + : null; // Auto-bind an IMPLEMENTS PR whose head branch matches the session. This is // intentionally a repair path: explicit linking remains preferred. @@ -294,7 +544,7 @@ export async function listIssueWorkSessions( return listIssueWorkSessions(db, workspaceId, issueId); } } - return sessions; + return sessions.map((session) => ({ ...session, observedImplementation })); } export async function touchWorkSession( @@ -324,7 +574,7 @@ export async function touchWorkSession( ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), }, }); - await resolveStaleRequest(db, input.workspaceId, session.id, "Work session resumed."); + await resolveRecoveryRequests(db, input.workspaceId, session.id, "Work session resumed."); return updated; } @@ -473,6 +723,13 @@ export async function attachPullRequest( }, }; }); + if (result.status === WorkSessionStatus.MERGED && result.mergedAt) { + await reconcileMergedImplementationRuns(db, { + workspaceId: input.workspaceId, + issueId: result.issueId, + mergedAt: result.mergedAt, + }); + } return result; } @@ -512,6 +769,17 @@ export async function syncWorkSessionsFromPullRequest( : {}), }, }); + if (derived === WorkSessionStatus.MERGED) { + const mergedAt = + typeof meta.mergedAt === "string" + ? new Date(meta.mergedAt) + : (session.mergedAt ?? new Date()); + await reconcileMergedImplementationRuns(db, { + workspaceId: resource.workspaceId, + issueId: session.issueId, + mergedAt, + }); + } count += 1; } return count; @@ -583,7 +851,7 @@ export async function advanceWorkSession( return updated; }); if (input.status === "ABANDONED" || input.status === "VERIFIED") { - await resolveStaleRequest( + await resolveRecoveryRequests( db, input.workspaceId, input.sessionId, @@ -607,8 +875,41 @@ export async function sweepStaleWorkSessions(db: PrismaClient): Promise status: { in: [...STALEABLE_WORK_SESSION_STATUSES] }, lastHeartbeatAt: { lt: cutoff }, }, + include: { ownerConnection: { select: { id: true, kind: true, status: true } } }, }); for (const session of sessions) { + if (session.ownerConnection?.kind === "MCP_CLIENT") { + // MCP silence is an expired observation lease, not proof that the + // client process failed. Keep delivery ownership intact so the + // dispatcher cannot start a competing branch while status is unknown. + // Always materialize the idempotent action request, even when the run + // watchdog already changed the shared connection to QUIET first. + await db.agentConnection.updateMany({ + where: { id: session.ownerConnection.id, status: "ACTIVE" }, + data: { status: "QUIET", confidence: "UNCONFIRMED" }, + }); + const issue = await db.issue.findUnique({ + where: { id: session.issueId }, + select: { authorId: true, workspace: { select: { key: true } }, number: true }, + }); + await createActionRequest(db, { + workspaceId: workspace.id, + actorId: null, + title: `MCP status for ${issue ? `${issue.workspace.key}-${issue.number}` : "an issue"} is unconfirmed`, + body: + `${session.repoFullName}:${session.branch} has not sent a lifecycle signal for ` + + `${workspace.workSessionStaleMinutes} minutes. Delivery remains owned; request status, hand off, or abandon it explicitly.`, + severity: NotificationSeverity.WARNING, + kind: ActionRequestKind.FREE_FORM, + assignedUserId: session.ownerUserId ?? issue?.authorId ?? null, + assignedAgentId: session.ownerAgentId, + sourceType: "work-session", + sourceId: session.id, + dedupeKey: `work-session-mcp-quiet:${session.id}`, + issueId: session.issueId, + }); + continue; + } const markedStale = await db.$transaction(async (tx) => { const claimed = await tx.workSession.updateMany({ where: { diff --git a/src/server/worker.ts b/src/server/worker.ts index b87275d3..2ee59a6a 100644 --- a/src/server/worker.ts +++ b/src/server/worker.ts @@ -271,7 +271,19 @@ export const webhookWorker = new Worker( // `lastHeartbeatAt` and (if it was OFFLINE) flip it back to ONLINE. // Best-effort — failures here are logged but don't fail the job. if (res.ok && presenceAgentId) { - await recordAgentReachable(presenceAgentId); + const connectionId = await recordAgentReachable(presenceAgentId); + if (connectionId && delivery.event.subjectType === "issue") { + await db.agentRun.updateMany({ + where: { + workspaceId: delivery.event.workspaceId, + issueId: delivery.event.subjectId, + agentId: presenceAgentId, + status: { in: ["ACTIVE", "WAITING"] }, + connectionId: null, + }, + data: { connectionId, lifecycleConfidence: "CONFIRMED" }, + }); + } // Required-ack window: if the workspace opted in, schedule a // delayed maintenance job that checks whether the agent actually diff --git a/tests/unit/activity-actor.test.ts b/tests/unit/activity-actor.test.ts index 46f2f021..23b5c4c7 100644 --- a/tests/unit/activity-actor.test.ts +++ b/tests/unit/activity-actor.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { activityActorName, activityActorOwnerTitle } from "@/lib/activity-actor"; +import { + activityActorKind, + activityActorName, + activityActorOwnerTitle, +} from "@/lib/activity-actor"; describe("activityActorName", () => { it("shows an agent-linked event as the agent, not the key owner", () => { @@ -18,10 +22,12 @@ describe("activityActorName", () => { actorAgent: { name: "Victor", profileKey: "victor" }, }), ).toBe("API key owner: Bailey"); - expect(activityActorOwnerTitle({ actor: { name: "Bailey" }, actorAgent: null })).toBeUndefined(); + expect( + activityActorOwnerTitle({ actor: { name: "Bailey" }, actorAgent: null }), + ).toBeUndefined(); }); - it("falls back through agent handle, human, then system", () => { + it("falls back through agent handle and human before classifying automation", () => { expect( activityActorName({ actor: null, @@ -29,6 +35,26 @@ describe("activityActorName", () => { }), ).toBe("@victor"); expect(activityActorName({ actor: { name: "Bailey" }, actorAgent: null })).toBe("Bailey"); - expect(activityActorName({ actor: null, actorAgent: null })).toBe("system"); + expect(activityActorName({ actor: null, actorAgent: null })).toBe("Forge automation"); + }); + + it("distinguishes worker and connector provenance without a bare system actor", () => { + const worker = { + actor: null, + actorAgent: null, + kind: "AGENT_RUN_STALLED", + payload: { action: "watchdog-sweep" }, + }; + expect(activityActorKind(worker)).toBe("worker"); + expect(activityActorName(worker)).toBe("Forge worker"); + + const connector = { + actor: null, + actorAgent: null, + kind: "ISSUE_UPDATED", + payload: { provider: "GITHUB", deliveryId: "delivery-1" }, + }; + expect(activityActorKind(connector)).toBe("connector"); + expect(activityActorName(connector)).toBe("GitHub connector"); }); }); diff --git a/tests/unit/activity-update-summary.test.ts b/tests/unit/activity-update-summary.test.ts new file mode 100644 index 00000000..3ad72133 --- /dev/null +++ b/tests/unit/activity-update-summary.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { issueUpdateCopy } from "@/lib/activity-update-summary"; + +describe("issueUpdateCopy", () => { + it("summarizes changed issue fields instead of discarding the patch", () => { + expect( + issueUpdateCopy({ + title: "Canonical delivery evidence", + description: "updated body", + priority: "HIGH", + projectId: "cmrnproject123", + }), + ).toEqual({ + label: "Updated title, description, priority, project", + phase: "fields", + }); + expect(issueUpdateCopy({ priority: "HIGH" })).toEqual({ + label: "Priority updated", + detail: "HIGH", + phase: "priority", + }); + }); + + it("summarizes label and delivery actions without exposing opaque ids", () => { + expect(issueUpdateCopy({ add: ["cmrnlabel1", "cmrnlabel2"], remove: ["cmrnlabel3"] })).toEqual({ + label: "Labels changed", + detail: "2 added · 1 removed", + phase: "labels", + }); + expect( + issueUpdateCopy({ + action: "work-session-participant-joined", + repoFullName: "Codename-11/Forge", + branch: "codex/axi-115-agent-connections", + }), + ).toEqual({ + label: "Work session participant joined", + detail: "Codename-11/Forge · codex/axi-115-agent-connections", + phase: "delivery", + }); + }); + + it("uses the compact fallback only when the payload has no meaningful update evidence", () => { + expect(issueUpdateCopy(null)).toBeNull(); + expect(issueUpdateCopy({ issueId: "cmrnopaque123" })).toBeNull(); + }); +}); diff --git a/tests/unit/mcp-tool-profiles.test.ts b/tests/unit/mcp-tool-profiles.test.ts index cb40463c..b399ec84 100644 --- a/tests/unit/mcp-tool-profiles.test.ts +++ b/tests/unit/mcp-tool-profiles.test.ts @@ -75,6 +75,10 @@ describe("mcp tool profiles (AXI-82)", () => { expect(runtime).toContain("runs.complete"); expect(runtime).toContain("actionRequests.list"); expect(runtime).toContain("workSessions.claim"); + expect(runtime).toContain("chat.appendMessage"); + expect(runtime).toContain("chat.finalizeDraft"); + expect(runtime).not.toContain("chat.connector.negotiate"); + expect(runtime).not.toContain("chat.connector.deliver"); expect(runtime.some((n) => mcpToolNamespace(n) === "canvases")).toBe(false); });