diff --git a/CHANGELOG.md b/CHANGELOG.md
index 496ef8b1..53eb9204 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,18 @@ The dashboard's **What's New** rail reads this file at request time
entries. Keep entries terse — one line per item under each version
date, grouped by `Added` / `Changed` / `Fixed` / `Removed`.
+## [2026-07-16] — v0.27.1 · Truthful delivery evidence
+
+### Changed
+
+- **Delivery identity separates the agent from its operator.** Compact and expanded cards distinguish execution identity, invocation, connector, runtime, and human ownership while explaining connection confidence on hover or keyboard focus.
+- **Issue evidence and activity stay compact without hiding history.** Delivery and GitHub disclosures share a clear interaction pattern, repeated adjacent activity is grouped, and operators can expand capped history on demand.
+
+### Fixed
+
+- **GitHub and completion state remain internally consistent.** Native implementation, closing, related, and release-containment relations render distinctly; merged pull requests no longer retain pending or unknown merge metadata.
+- **Compact GitHub references link as one unit.** Shared Markdown surfaces recognize `owner/repository#number` before Forge issue keys instead of splitting hyphenated owners at the slash.
+
## [2026-07-16] — v0.27.0 · Personal work and canonical delivery
### Added
diff --git a/DEVLOG.md b/DEVLOG.md
index af2b8881..731fc58c 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -2,6 +2,54 @@
> Append-only session log. Read at session start. Update at session end.
+## 2026-07-16 — Truthful Delivery provenance and GitHub relations
+
+AXI-117 audited production Ready to Close, Delivery, and native GitHub cards,
+then separated actor, invocation, connector, runtime, implementation PR, and
+release-containment facts. Browser claims now persist as manual UI actions and
+MCP claims persist as Forge MCP; connector identity comes only from the MCP
+initialize `clientInfo`, while runtime attribution requires an output-producing
+managed run. Compact Delivery cards expose the full evidence on demand without
+guessing Codex Desktop or a runtime from agent identity.
+
+GitHub webhook matching now derives one native relation per issue/resource:
+`IMPLEMENTS`, `FIXES`, `RELEASES`, or `RELATES_TO`. The migration reclassifies
+release assembly PRs, removes legacy duplicate relations deterministically, and
+enforces the singular relation invariant. Generic URL recovery and repository
+identity canonicalization preserve a stronger existing relation, while explicit
+relinks remain operator-controlled. Imported `SOURCE` identity is preserved
+ahead of derived PR relations so later imports cannot duplicate the Forge issue;
+explicit downgrades dismiss stale Ready-to-Close requests when no implementation
+PR remains. Grouped references such as `Fixes AXI-1, AXI-2` retain the keyword
+across the full clause. Duplicate-resource canonicalization also preserves
+`SOURCE`, while imported source PRs retain their mapped status synchronization.
+Both service canonicalization and migration-time release reclassification
+dismiss stale completion requests when they remove the last implementation
+relation, with migration cleanup ordered after legacy relation dedupe. Bare URLs
+are consumed before compact GitHub and issue-reference tokens so paths containing
+`CODENAME-11/Forge#56` or `AXI-123` remain fully clickable. Imported `SOURCE`
+PRs participate in failed-check status rules while related/release links do not.
+Terminal PR synchronization clears
+stale mergeability and suppresses pending-check contradictions, while merged
+implementation evidence continues reconciliation until its final checks
+aggregate is trusted and still participates in completion readiness. Passing
+completion evidence and READY state now use the semantic success token.
+PR lifecycle and failed-check status automation is limited to implementation
+relations, so related mentions and release-containment links remain contextual.
+
+Verification passed lint (existing warnings only), typecheck, a production
+Next build, 1,399 Vitest tests with one intentional skip, focused relation and
+provenance coverage, and the AXI-117 Playwright issue journey. The full
+single-worker browser run passed 51 journeys before Chromium exited while
+opening an unrelated Mission Control context; both affected Mission Control
+tests passed immediately in isolation, covering all 53 journeys.
+
+Release review follow-up applied all 119 migrations to a clean isolated local
+Postgres database and passed nine focused relation/persistence regressions,
+including SOURCE preservation, grouped relation derivation, and audited stale
+completion-request dismissal. The production-snapshot development database was
+not used for migration or test execution.
+
## 2026-07-16 — v0.26.1 released, deployed, and verified
Merged release PR #60 and tagged exact `main` commit
@@ -13755,3 +13803,36 @@ activity provenance.
The immutable tag will be cut only from the merged release commit, then
deployed through the clean-checkout production path and verified live.
+
+---
+
+## 2026-07-16 — Delivery agent and operator identity
+
+Refined the issue Delivery card so execution identity and human ownership are
+not collapsed into one ambiguous Actor label. Registered agents now remain the
+primary identity with their operator shown as supporting context. Manual work
+shows the operator explicitly, while legacy Codex Desktop, MCP, dispatch, and
+scheduled sessions without a registered agent connection are labeled as
+unverified instead of being falsely attributed to the credential owner.
+
+Expanded Delivery evidence now lists Agent and Operator independently alongside
+invocation, connector, and runtime facts. The agent transport documentation and
+the issue-flow Playwright contract were updated to preserve the distinction.
+Connection state now uses a semantic status badge with a hover/focus explanation,
+and the evidence disclosure uses a bordered control and rotating chevron so its
+interactivity is visible without adding density to the collapsed card.
+GitHub PR state now follows the same semantic badge and explanatory tooltip
+pattern, while GitHub evidence uses the same recognizable disclosure control.
+The issue claim card now separates owner identity, active state, expiry, and the
+release action into a clearer compact hierarchy.
+The issue Activity rail now groups only adjacent equivalent events, preserving
+audit order while reducing repeated metadata noise. It shows eight recent
+groups by default and provides an explicit event-counted show-more/show-less
+control for the remaining history.
+The shared Markdown renderer now recognizes compact GitHub
+`owner/repository#number` references before Forge issue keys, preventing
+hyphenated owners from becoming partial links in issue comments, Command
+Center decisions, and every other shared rich-text surface.
+Prepared the reviewed change as patch release `v0.27.1`; the immutable tag is
+cut only after the release-ready commit lands on `main` with all required CI
+checks green.
diff --git a/docs/agents/providers-and-transports.md b/docs/agents/providers-and-transports.md
index ac79f6c1..cc6d7601 100644
--- a/docs/agents/providers-and-transports.md
+++ b/docs/agents/providers-and-transports.md
@@ -13,8 +13,9 @@ 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:
+There are also five deliberately separate identities in the execution record:
+- **Operator** — the human who owns or manages the delivery lease or credential.
- **Agent profile / binding** — who is acting and what it may do in the
workspace.
- **Agent connection** — the concrete managed runtime, MCP client, webhook, or
@@ -24,7 +25,10 @@ There are also four deliberately separate identities in the execution record:
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.
+profile relinking cannot rewrite historical attribution. Delivery surfaces
+therefore show Agent and Operator separately; legacy clients without a
+registered agent connection are labeled unverified instead of attributing the
+agent's work to the credential owner.
## Connection-aware liveness
diff --git a/docs/engineering/work-management.md b/docs/engineering/work-management.md
index 1544e9c6..3a9c25ac 100644
--- a/docs/engineering/work-management.md
+++ b/docs/engineering/work-management.md
@@ -29,21 +29,28 @@ 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.
+Delivery provenance records separate facts: actor/profile, invocation source,
+connector/transport, and execution runtime. MCP client identity comes from the
+negotiated MCP `clientInfo`; missing identity is displayed as an unidentified
+client rather than guessed as Codex Desktop or CLI. A runtime is shown only
+when an output-producing `AgentRun` carries both a runtime connection and an
+external run id. Direct MCP or UI work does not imply runtime execution.
+
## Project branch contract
Branch topology is project configuration, not an Axiom-wide constant. Every
maintained repository declares these seven facts in `AGENTS.md` or
`RELEASE.md`:
-| Fact | Meaning |
-| --- | --- |
-| Integration branch | Normal feature/fix PR target |
-| Release branch | Durable released-code history |
-| Tag source | Branch/commit from which immutable release tags are cut |
-| Staging source | Exact branch SHA or tag deployed to staging |
-| Production source | Exact tag/SHA accepted by production |
-| Hotfix base | Ref used to begin an emergency fix |
-| Back-merge target | Branches that must receive the hotfix afterward |
+| Fact | Meaning |
+| ------------------ | ------------------------------------------------------- |
+| Integration branch | Normal feature/fix PR target |
+| Release branch | Durable released-code history |
+| Tag source | Branch/commit from which immutable release tags are cut |
+| Staging source | Exact branch SHA or tag deployed to staging |
+| Production source | Exact tag/SHA accepted by production |
+| Hotfix base | Ref used to begin an emergency fix |
+| Back-merge target | Branches that must receive the hotfix afterward |
Supported shapes include trunk-based (`feature → main → tag`), an integration
train (`feature → dev → release PR → main → tag`), upstream-tracking forks, and
@@ -57,10 +64,14 @@ integration, scheduled stabilization, or an upstream convention.
- Refresh the work-session heartbeat at meaningful phase changes and after
commits. Mechanical tool traces do not replace a heartbeat or semantic status.
- Push early and open a PR early when overlap is likely.
-- Link implementation PRs with Forge's native GitHub relation and kind
- `IMPLEMENTS`; never use a generic link attachment for a PR.
+- Link implementation PRs with Forge's native GitHub relation: `IMPLEMENTS`
+ for delivered behavior or `FIXES` for closing/fixing semantics. Bare issue
+ references are `RELATES_TO`; release assembly PRs use `RELEASES` to mean the
+ release contains the implementation. Never use a generic link attachment.
- GitHub owns PR state, reviews, checks, mergeability, and merge state. Forge
mirrors those facts into the delivery lifecycle.
+- One issue/resource pair has one native relation. Re-linking reclassifies the
+ relation instead of duplicating status cards.
- 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
diff --git a/docs/plans/github-support-integration.md b/docs/plans/github-support-integration.md
index 4c2f8414..cc5f8c99 100644
--- a/docs/plans/github-support-integration.md
+++ b/docs/plans/github-support-integration.md
@@ -135,7 +135,7 @@ model ExternalResourceLink {
workspaceId String
issueId String
externalResourceId String
- kind String // SOURCE | IMPLEMENTS | REVIEWS | RELATES_TO
+ kind String // SOURCE | IMPLEMENTS | FIXES | RELEASES | REVIEWS | RELATES_TO
createdById String?
createdAt DateTime @default(now())
@@ -154,6 +154,9 @@ Link semantics:
- `SOURCE` - Forge issue was created from this GitHub issue.
- `IMPLEMENTS` - PR is intended to resolve or implement the Forge issue.
+- `FIXES` - PR uses explicit fix/close/resolve semantics for the Forge issue.
+- `RELEASES` - release assembly PR contains an already-separate implementation.
+- `RELATES_TO` - contextual reference without implementation semantics.
- `REVIEWS` - PR/review context requires attention for the Forge issue.
- `RELATES_TO` - soft context link.
@@ -354,14 +357,14 @@ subscription category.
Expose the same core operator/agent actions through `github.*` MCP tools:
-| Tool | Scope | Notes |
-| --- | --- | --- |
-| `github.parseUrl` | `READ_ISSUES` | Normalize a GitHub URL into repo/type/number. |
-| `github.listLinked` | `READ_ISSUES` | Return linked resources for a Forge issue. |
-| `github.link` | `WRITE_ISSUES` | Link issue/PR URL to a Forge issue with a relation kind. |
+| Tool | Scope | Notes |
+| -------------------- | -------------- | ------------------------------------------------------------- |
+| `github.parseUrl` | `READ_ISSUES` | Normalize a GitHub URL into repo/type/number. |
+| `github.listLinked` | `READ_ISSUES` | Return linked resources for a Forge issue. |
+| `github.link` | `WRITE_ISSUES` | Link issue/PR URL to a Forge issue with a relation kind. |
| `github.importIssue` | `WRITE_ISSUES` | Create or return the Forge issue sourced from a GitHub issue. |
-| `github.sync` | `WRITE_ISSUES` | Refresh one linked resource and apply mapping policy. |
-| `github.search` | `READ_ISSUES` | Search mapped repo issues/PRs for linking/import. |
+| `github.sync` | `WRITE_ISSUES` | Refresh one linked resource and apply mapping policy. |
+| `github.search` | `READ_ISSUES` | Search mapped repo issues/PRs for linking/import. |
Add linked GitHub resources to `agent.context.bundle({ issueId })`:
diff --git a/package.json b/package.json
index de7ffae7..9b479311 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "forge",
- "version": "0.27.0",
+ "version": "0.27.1",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
diff --git a/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql
new file mode 100644
index 00000000..e93524d8
--- /dev/null
+++ b/prisma/migrations/20260716200000_delivery_github_relation_truth/migration.sql
@@ -0,0 +1,69 @@
+-- AXI-117: make GitHub relations singular/truthful and expand delivery source
+-- vocabulary without conflating invocation, transport, and runtime.
+
+ALTER TYPE "WorkSessionSource" ADD VALUE IF NOT EXISTS 'MCP';
+ALTER TYPE "WorkSessionSource" ADD VALUE IF NOT EXISTS 'NATIVE_SESSION';
+ALTER TYPE "WorkSessionSource" ADD VALUE IF NOT EXISTS 'ISSUE_DISPATCH';
+ALTER TYPE "WorkSessionSource" ADD VALUE IF NOT EXISTS 'SCHEDULED';
+
+-- Release assembly PRs are containment evidence, not implementation PRs.
+UPDATE "ExternalResourceLink" link
+SET "kind" = 'RELEASES'
+FROM "ExternalResource" resource
+WHERE link."externalResourceId" = resource."id"
+ AND link."kind" = 'IMPLEMENTS'
+ AND resource."resourceType" = 'PULL_REQUEST'
+ AND (
+ resource."title" ~* '^\s*release\s+v?[0-9]'
+ OR COALESCE(resource."metadata" #>> '{head,ref}', '') ~* '(^|/)release([-\/]|$)'
+ );
+
+-- Older rows could carry multiple semantic kinds for the same issue/resource.
+-- Keep one deterministic relation before enforcing the native invariant.
+WITH ranked AS (
+ SELECT "id",
+ ROW_NUMBER() OVER (
+ PARTITION BY "issueId", "externalResourceId"
+ ORDER BY
+ CASE "kind"
+ -- SOURCE is the durable identity used to de-duplicate imported
+ -- GitHub issues/PRs. Derived relations must not erase it.
+ WHEN 'SOURCE' THEN 0
+ WHEN 'FIXES' THEN 1
+ WHEN 'IMPLEMENTS' THEN 2
+ WHEN 'RELEASES' THEN 3
+ WHEN 'REVIEWS' THEN 4
+ ELSE 5
+ END,
+ "createdAt" ASC,
+ "id" ASC
+ ) AS rn
+ FROM "ExternalResourceLink"
+)
+DELETE FROM "ExternalResourceLink" link
+USING ranked
+WHERE link."id" = ranked."id" AND ranked.rn > 1;
+
+-- Reclassification and dedupe can both remove the last implementation link.
+-- Any completion request left without implementation PR evidence is stale.
+UPDATE "ActionRequest" request
+SET
+ "status" = 'DISMISSED',
+ "resolvedAt" = NOW(),
+ "resolution" = 'No linked pull request remains as implementation evidence.'
+WHERE request."status" = 'OPEN'
+ AND request."sourceType" = 'completion-candidate'
+ AND request."issueId" IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM "ExternalResourceLink" implementation_link
+ JOIN "ExternalResource" implementation_resource
+ ON implementation_resource."id" = implementation_link."externalResourceId"
+ WHERE implementation_link."issueId" = request."issueId"
+ AND implementation_link."kind" IN ('IMPLEMENTS', 'FIXES')
+ AND implementation_resource."resourceType" = 'PULL_REQUEST'
+ );
+
+DROP INDEX "ExternalResourceLink_issueId_externalResourceId_kind_key";
+CREATE UNIQUE INDEX "ExternalResourceLink_issueId_externalResourceId_key"
+ ON "ExternalResourceLink"("issueId", "externalResourceId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 97853638..0d3baf26 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -290,6 +290,10 @@ enum WorkSessionSource {
CODEX_DESKTOP
CONTRIBUTOR
MANUAL
+ MCP
+ NATIVE_SESSION
+ ISSUE_DISPATCH
+ SCHEDULED
}
/// The transport or execution endpoint through which an agent binding acts.
@@ -2597,7 +2601,7 @@ model ExternalResourceLink {
workspaceId String
issueId String
externalResourceId String
- /// SOURCE | IMPLEMENTS | REVIEWS | RELATES_TO.
+ /// SOURCE | IMPLEMENTS | FIXES | RELEASES | REVIEWS | RELATES_TO.
kind String
createdById String?
createdAt DateTime @default(now())
@@ -2607,7 +2611,7 @@ model ExternalResourceLink {
externalResource ExternalResource @relation(fields: [externalResourceId], references: [id], onDelete: Cascade)
createdBy User? @relation("ExternalResourceLinkCreator", fields: [createdById], references: [id], onDelete: SetNull)
- @@unique([issueId, externalResourceId, kind])
+ @@unique([issueId, externalResourceId])
@@index([workspaceId, issueId])
@@index([workspaceId, externalResourceId])
@@index([createdById])
diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx
index c1ccd268..39991f1b 100644
--- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx
+++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx
@@ -3,7 +3,17 @@ import { use, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import type { AgentStatus, WorkItemKind } from "@prisma/client";
-import { Archive, ArchiveRestore, Bot, Paperclip, Plus, Timer, Workflow } from "lucide-react";
+import {
+ Archive,
+ ArchiveRestore,
+ Bot,
+ Clock3,
+ LogOut,
+ Paperclip,
+ Plus,
+ Timer,
+ Workflow,
+} from "lucide-react";
import { Topbar } from "@/components/topbar";
import { Badge } from "@/components/ui/badge";
import { Avatar } from "@/components/ui/avatar";
@@ -951,26 +961,45 @@ function ClaimHolderCard({
? shortId
: null;
return (
-
-
+
+
{claimedByAgent ? (
) : (
)}
-
{primary}
- {sub &&
{sub}
}
+
+
{primary}
+
+ Active
+
+
+
+ {sub && {sub} }
+ {claimedByAgent ? "Agent claim" : "Operator claim"}
+
- {claimExpiresAt && (
-
- expires {relativeTime(claimExpiresAt)}
+
+
+ {claimExpiresAt ? (
+
+
+ Expires {relativeTime(claimExpiresAt)}
+ ) : (
+ No expiry recorded
)}
+
+
+ Release
+
-
- Release claim
-
);
}
diff --git a/src/components/action-requests/action-request-card.tsx b/src/components/action-requests/action-request-card.tsx
index 30aabad0..8df7a5a0 100644
--- a/src/components/action-requests/action-request-card.tsx
+++ b/src/components/action-requests/action-request-card.tsx
@@ -952,7 +952,7 @@ function CompletionEvidenceSummary({
onLinked(true)}
/>
) : (
- onLinked(false)} />
+ onLinked(false)}
+ />
)}
@@ -235,13 +242,16 @@ function ByUrlTab({
// repo would fire wasted GitHub installation probes.
{ enabled: !!repoFullName && parsed?.number != null, staleTime: 15_000, retry: false },
);
- const ready =
- linkability.data?.status === "ready" ? linkability.data : null;
+ const ready = linkability.data?.status === "ready" ? linkability.data : null;
const preview = trpc.github.preview.useQuery(
parsed?.url
? { url: parsed.url, mappingId: ready?.mappingId }
- : { repoFullName: repoFullName ?? "", number: parsed?.number ?? 0, mappingId: ready?.mappingId },
+ : {
+ repoFullName: repoFullName ?? "",
+ number: parsed?.number ?? 0,
+ mappingId: ready?.mappingId,
+ },
{ enabled: !!ready && !!parsed?.number, staleTime: 15_000, retry: false },
);
@@ -282,8 +292,8 @@ function ByUrlTab({
{parsed && !parsed.number && (
- Add an issue/PR number — {parsed.repoFullName}#123 —
- or use Browse a repo to pick one.
+ Add an issue/PR number — {parsed.repoFullName}#123 — or
+ use Browse a repo to pick one.
)}
@@ -319,7 +329,11 @@ function ByUrlTab({
linkUrl && linkM.mutate({ issueId, url: linkUrl, kind, mappingId: ready.mappingId })
}
>
- {linkM.isPending ?
:
}
+ {linkM.isPending ? (
+
+ ) : (
+
+ )}
Link as {kindLabel(kind)}
@@ -380,7 +394,7 @@ function Remediation({
});
const [connectionId, setConnectionId] = useState
(
- state.status === "mappable" ? state.connections[0]?.connectionId ?? "" : "",
+ state.status === "mappable" ? (state.connections[0]?.connectionId ?? "") : "",
);
if (state.status === "app_available") {
@@ -416,7 +430,9 @@ function Remediation({
Use {app?.name ?? "GitHub App"}
) : (
- connect the workspace GitHub App for linking in Settings → Connections
+
+ connect the workspace GitHub App for linking in Settings → Connections
+
)}
);
@@ -486,7 +502,11 @@ function Remediation({
disabled={mapM.isPending || !connectionId}
onClick={() => mapM.mutate({ connectionId, repoFullName: state.repoFullName })}
>
- {mapM.isPending ? : }
+ {mapM.isPending ? (
+
+ ) : (
+
+ )}
Connect repository
@@ -581,11 +601,7 @@ function Remediation({
}
function AskAdmin({ children }: { children: React.ReactNode }) {
- return (
-
- Ask a workspace admin to {children}.
-
- );
+ return Ask a workspace admin to {children}.
;
}
function RemediationCard({
@@ -610,7 +626,8 @@ function RemediationCard({
@@ -677,7 +694,9 @@ function BrowseTab({
}, [current?.repoFullName, current?.mapped, debouncedQuery, isAdmin]);
const connecting =
- !!current && !current.mapped && (connectRepoM.isPending || autoConnected.has(current.repoFullName));
+ !!current &&
+ !current.mapped &&
+ (connectRepoM.isPending || autoConnected.has(current.repoFullName));
const search = trpc.github.search.useQuery(
{ mappingId, query: debouncedQuery, type: type || undefined },
@@ -696,7 +715,11 @@ function BrowseTab({
});
if (repos.isLoading) {
- return
Loading repositories… ;
+ return (
+
+ Loading repositories…
+
+ );
}
if (!repos.data || repos.data.length === 0) {
return (
@@ -777,10 +800,14 @@ function BrowseTab({
)}
{search.isLoading && debouncedQuery.trim() && (
-
Searching…
+
+ Searching…
+
)}
{search.isError && (
-
{search.error.message}
+
+ {search.error.message}
+
)}
{search.data && search.data.length === 0 && debouncedQuery.trim() && (
No matching open issues or PRs.
@@ -806,13 +833,13 @@ function BrowseTab({
{item.title}
-
+
#{item.number}
{stateLabel(item.state)}
{isLinked ? (
-
+
linked
@@ -856,12 +883,23 @@ function ResourcePreview({
}: {
loading: boolean;
error: string | null;
- snapshot: { resourceType: string; title: string; state: string; url: string; repoFullName: string; number: number } | null;
+ snapshot: {
+ resourceType: string;
+ title: string;
+ state: string;
+ url: string;
+ repoFullName: string;
+ number: number;
+ } | null;
fallbackRepo: string | null;
fallbackNumber: number;
}) {
if (loading) {
- return Loading {fallbackRepo}#{fallbackNumber}… ;
+ return (
+
+ Loading {fallbackRepo}#{fallbackNumber}…
+
+ );
}
if (error) {
return (
@@ -889,7 +927,7 @@ function ResourcePreview({
>
{snapshot.title}
-
+
{snapshot.repoFullName}#{snapshot.number}
@@ -914,7 +952,7 @@ function StatusLine({
return (
diff --git a/src/components/issue-detail/github-links-panel.tsx b/src/components/issue-detail/github-links-panel.tsx
index f929b0a2..c89a9a6d 100644
--- a/src/components/issue-detail/github-links-panel.tsx
+++ b/src/components/issue-detail/github-links-panel.tsx
@@ -1,9 +1,18 @@
"use client";
import { useState } from "react";
-import { AlertTriangle, ExternalLink, GitPullRequest, Github, Plus, RefreshCw } from "lucide-react";
+import {
+ AlertTriangle,
+ ChevronDown,
+ ExternalLink,
+ GitPullRequest,
+ Github,
+ Plus,
+ RefreshCw,
+} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { Tooltip } from "@/components/ui/tooltip";
import { trpc } from "@/lib/trpc";
import { formatDate, relativeTime } from "@/lib/utils";
import { GitHubLinkModal } from "@/components/issue-detail/github-link-modal";
@@ -11,6 +20,8 @@ import { GitHubLinkModal } from "@/components/issue-detail/github-link-modal";
function kindLabel(kind: string): string {
if (kind === "SOURCE") return "source";
if (kind === "IMPLEMENTS") return "implements";
+ if (kind === "FIXES") return "fixes / closes";
+ if (kind === "RELEASES") return "release contains implementation";
if (kind === "REVIEWS") return "reviews";
return "related";
}
@@ -21,6 +32,21 @@ function stateLabel(state: string): string {
return state || "unknown";
}
+function stateDescription(state: string): string {
+ if (state === "merged") return "GitHub reports that this pull request was merged.";
+ if (state === "draft") return "This pull request is still marked as a draft in GitHub.";
+ if (state === "open") return "This pull request is open and has not been merged.";
+ if (state === "closed") return "This pull request was closed without being merged.";
+ return "Forge has not received a recognized GitHub state for this item.";
+}
+
+function stateTone(state: string): string {
+ if (state === "merged") return "border-success/30 bg-success/10 text-success";
+ if (state === "draft") return "border-warning/30 bg-warning/10 text-warning";
+ if (state === "closed") return "border-danger/30 bg-danger/10 text-danger";
+ return "border-border/70 bg-subtle/50 text-foreground";
+}
+
function metadataRecord(value: unknown): Record {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record)
@@ -73,6 +99,8 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) {
{links.map((link) => {
const resource = link.externalResource;
const isPr = resource.resourceType === "PULL_REQUEST";
+ const terminalPr =
+ isPr && (resource.state === "merged" || resource.state === "closed");
const metadata = metadataRecord(resource.metadata);
const head = metadataRecord(metadata.head);
const checks = metadataRecord(metadata.checks);
@@ -83,8 +111,9 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) {
: typeof review.decision === "string"
? review.decision
: null;
- const checksLabel =
- checks.partial === true
+ const checksLabel = terminalPr
+ ? null
+ : checks.partial === true
? "checks partial"
: typeof checks.conclusion === "string"
? `checks ${checks.conclusion}`
@@ -113,27 +142,46 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) {
{resource.repoFullName}#{resource.number}
- {kindLabel(link.kind)}
- {stateLabel(resource.state)}
+ {kindLabel(link.kind)}
+
+
+ {stateLabel(resource.state)}
+
+
{resource.lastSyncedAt && (
synced {relativeTime(resource.lastSyncedAt)}
)}
{isPr && (
-
- {typeof head.ref === "string" && (
-
- {head.ref}
-
- )}
- {reviewDecision && (
- {reviewDecision.toLowerCase().replaceAll("_", " ")}
- )}
- {checksLabel && {checksLabel} }
- {typeof metadata.mergeableState === "string" && (
- merge {metadata.mergeableState}
- )}
-
+
+
+ GitHub evidence
+
+
+
+ {typeof head.ref === "string" && (
+
+ {head.ref}
+
+ )}
+ {terminalPr && typeof metadata.mergedAt === "string" ? (
+ merged {relativeTime(metadata.mergedAt)}
+ ) : (
+ <>
+ {reviewDecision && (
+ {reviewDecision.toLowerCase().replaceAll("_", " ")}
+ )}
+ {checksLabel && {checksLabel} }
+ {typeof metadata.mergeableState === "string" && (
+ merge {metadata.mergeableState}
+ )}
+ >
+ )}
+
+
)}
{resource.syncLastError && (
{
+ const copy = activityCopy(event.kind, event.payload, event.actorAgent?.profileKey ?? null);
+ const actorKey = event.actorAgent?.id ?? event.actor?.id ?? "system";
+ return {
+ event,
+ copy,
+ groupKey: JSON.stringify([actorKey, event.kind, copy.label, copy.detail, copy.phase]),
+ };
+ });
+ const groups = groupConsecutive(preparedRows, (row) => row.groupKey);
+ const visibleGroups = showAll ? groups : groups.slice(0, COLLAPSED_GROUP_LIMIT);
+ const hiddenGroups = groups.slice(COLLAPSED_GROUP_LIMIT);
+ const hiddenEventCount = hiddenGroups.reduce((total, group) => total + group.count, 0);
return (
@@ -305,60 +323,97 @@ export function IssueActivityPanel({ issueId }: { issueId: string }) {
No activity yet. Status changes, assignments, and comments will show up here.
) : (
-
- {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 (
-
- {agent ? (
-
- ) : (
-
- )}
-
-
-
- {actorLabel}
-
- {actorKind !== "human" && (
-
- {actorKind}
-
- )}
- {copy.phase && (
-
- {copy.phase}
+ <>
+
+ {visibleGroups.map((group) => {
+ const { event: e, copy } = group.newest;
+ const newestTime = relativeTime(e.createdAt);
+ const oldestTime = relativeTime(group.oldest.event.createdAt);
+ const agent = e.actorAgent;
+ const actorLabel = activityActorName(e);
+ const actorKind = activityActorKind(e);
+ const actorOwnerTitle = activityActorOwnerTitle(e);
+ return (
+
+ {agent ? (
+
+ ) : (
+
+ )}
+
+
+
+ {actorLabel}
+ {actorKind !== "human" && (
+
+ {actorKind}
+
+ )}
+ {copy.phase && (
+
+ {copy.phase}
+
+ )}
+ {group.count > 1 && (
+
+ ×{group.count}
+
+ )}
+ {copy.label}
+
+ {copy.detail && (
+
+ {copy.detail}
+
)}
-
{copy.label}
-
- {copy.detail && (
-
- {copy.detail}
+
+ {group.count > 1 && oldestTime !== newestTime
+ ? `${oldestTime} – ${newestTime}`
+ : newestTime}
- )}
-
- {relativeTime(e.createdAt)}
-
-
- );
- })}
-
+
+ );
+ })}
+
+ {groups.length > COLLAPSED_GROUP_LIMIT && (
+ setShowAll((current) => !current)}
+ aria-expanded={showAll}
+ >
+ {showAll ? (
+ <>
+
+ Show recent only
+ >
+ ) : (
+ <>
+
+ Show {hiddenGroups.length} more group{hiddenGroups.length === 1 ? "" : "s"}
+
+ · {hiddenEventCount} event{hiddenEventCount === 1 ? "" : "s"}
+
+ >
+ )}
+
+ )}
+ >
)}
);
diff --git a/src/components/issue-detail/work-coordination-panel.tsx b/src/components/issue-detail/work-coordination-panel.tsx
index cb757767..b698461c 100644
--- a/src/components/issue-detail/work-coordination-panel.tsx
+++ b/src/components/issue-detail/work-coordination-panel.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
+ ChevronDown,
GitBranch,
CheckCircle2,
CircleDot,
@@ -17,8 +18,10 @@ 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 { Tooltip } from "@/components/ui/tooltip";
import { trpc } from "@/lib/trpc";
import { cn, relativeTime } from "@/lib/utils";
+import { resolveDeliveryIdentity } from "@/lib/delivery-identity";
import { useWorkspace } from "@/hooks/use-workspace";
import type { AppRouter } from "@/server/routers/_app";
@@ -101,9 +104,7 @@ export function WorkCoordinationPanel({
onError: (error) => toast.error(error.message),
});
- const owner = latest?.ownerAgent
- ? `${latest.ownerAgent.name} · @${latest.ownerAgent.profileKey}`
- : (latest?.ownerUser?.name ?? latest?.ownerUser?.email ?? "Unassigned");
+ const identity = latest ? resolveDeliveryIdentity(latest) : null;
const status = latest ? (STATUS_COPY[latest.status] ?? STATUS_COPY.CLAIMED) : null;
const provenance = latest ? deliveryProvenance(latest) : null;
const connectionMismatch = Boolean(
@@ -117,7 +118,7 @@ export function WorkCoordinationPanel({
latest.observedImplementation.agent.id !== latest.ownerAgent.id,
);
const ownershipMismatch = connectionMismatch || observedMismatch;
- const observedConnection = latest?.observedImplementation?.agent.connections[0] ?? null;
+ const observedConnection = latest?.observedImplementation?.run.connection ?? null;
return (
)}
-
{owner}
+
+ {identity?.primaryLabel ?? "Unassigned"}
+
+ {identity?.summary && (
+
+ {identity.summary}
+
+ )}
{provenance && (
- {provenance.connectionLabel}
-
-
- {provenance.statusLabel}
+ {provenance.invocationLabel}
+ {provenance.connectorLabel && (
+
+ {provenance.connectorLabel}
+
+ )}
+
+
+ {provenance.statusLabel}
+
+
)}
{latest.repoFullName}:{latest.branch}
-
- base {latest.baseBranch} · activity {relativeTime(latest.lastHeartbeatAt)}
-
- {latest.worktreePath && (
-
- {latest.worktreePath}
-
- )}
+
+
+ Delivery evidence
+
+
+
+ Agent
+
+ {identity?.agentLabel ?? "none recorded"}
+
+ Operator
+
+ {identity?.operatorLabel ?? "none recorded"}
+
+ Invocation
+ {provenance?.invocationLabel ?? "unknown"}
+ Connector
+ {provenance?.connectorLabel ?? "none recorded"}
+ Runtime
+ {provenance?.runtimeLabel ?? "no dispatched run recorded"}
+ Base
+ {latest.baseBranch}
+ Activity
+ {relativeTime(latest.lastHeartbeatAt)}
+ {latest.worktreePath && (
+ <>
+ Worktree
+
+ {latest.worktreePath}
+
+ >
+ )}
+
+
@@ -367,7 +411,6 @@ export function WorkCoordinationPanel({
branch,
baseBranch,
worktreePath: worktreePath || null,
- source: "CODEX_DESKTOP",
});
}}
>
@@ -466,24 +509,46 @@ function connectionKindLabel(
}
function deliveryProvenance(session: DeliverySession): {
- connectionLabel: string;
+ invocationLabel: string;
+ connectorLabel: string | null;
+ runtimeLabel: string | null;
statusLabel: string;
- tone: string;
+ statusDescription: string;
+ badgeTone: string;
unconfirmedMcp: boolean;
} {
const connection = session.ownerConnection;
+ const invocationLabel =
+ session.source === "MCP"
+ ? "Forge MCP"
+ : session.source === "NATIVE_SESSION"
+ ? "Native session"
+ : session.source === "ISSUE_DISPATCH"
+ ? "Issue dispatch"
+ : session.source === "SCHEDULED"
+ ? "Scheduled"
+ : session.source === "MANUAL"
+ ? "Manual UI"
+ : session.source === "CONTRIBUTOR"
+ ? "Contributor"
+ : session.source === "CODEX_DESKTOP"
+ ? "Legacy desktop claim"
+ : "Legacy Forge agent";
+ const run = session.observedImplementation?.run;
+ const runtimeLabel =
+ run?.externalRunId && run.connection?.kind === "MANAGED_RUNTIME" && run.connection.runtime
+ ? `${run.connection.runtime.name}${run.runEngine ? ` · ${run.runEngine.toLowerCase()}` : ""}`
+ : null;
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,
+ invocationLabel,
+ connectorLabel: null,
+ runtimeLabel,
statusLabel: "provenance not registered",
- tone: "text-muted-foreground",
- unconfirmedMcp: session.source === "CODEX_DESKTOP",
+ statusDescription:
+ "No concrete client or runtime connection is attached to this delivery session.",
+ badgeTone: "border-border bg-subtle/60 text-muted-foreground",
+ unconfirmedMcp: session.source === "MCP",
};
}
const unconfirmedMcp = connection.kind === "MCP_CLIENT" && connection.confidence !== "CONFIRMED";
@@ -495,18 +560,35 @@ function deliveryProvenance(session: DeliverySession): {
: connection.status === "QUIET" && unconfirmedMcp
? "quiet · status unconfirmed"
: connection.status.toLowerCase();
- const tone =
+ const badgeTone =
connection.status === "DISCONNECTED" || connection.status === "REVOKED"
- ? "text-danger"
+ ? "border-danger/30 bg-danger/10 text-danger"
: connection.status === "QUIET"
- ? "text-warning"
+ ? "border-warning/30 bg-warning/10 text-warning"
: connection.status === "ACTIVE" && connection.confidence === "CONFIRMED"
- ? "text-success"
- : "text-muted-foreground";
+ ? "border-success/30 bg-success/10 text-success"
+ : "border-border bg-subtle/60 text-muted-foreground";
+ const statusDescription =
+ connection.status === "ACTIVE" && connection.confidence === "CONFIRMED"
+ ? "Forge has direct, current evidence that this delivery connection is active."
+ : connection.status === "ACTIVE"
+ ? "Recent activity was observed, but live connection presence is not fully confirmed."
+ : connection.status === "QUIET" && unconfirmedMcp
+ ? "The MCP client has not sent a recent signal. Silence does not prove that it is offline."
+ : connection.status === "QUIET"
+ ? "This connection has not produced a recent activity signal."
+ : connection.status === "DISCONNECTED"
+ ? "The client or runtime explicitly disconnected from Forge."
+ : connection.status === "REVOKED"
+ ? "This delivery connection was revoked and can no longer act."
+ : "Forge has recorded this connection state from the latest available evidence.";
return {
- connectionLabel: connectionKindLabel(connection.kind, connection),
+ invocationLabel,
+ connectorLabel: connectionKindLabel(connection.kind, connection),
+ runtimeLabel,
statusLabel,
- tone,
+ statusDescription,
+ badgeTone,
unconfirmedMcp,
};
}
diff --git a/src/components/markdown/attachment-renderer.tsx b/src/components/markdown/attachment-renderer.tsx
index 7e181647..ea0154a8 100644
--- a/src/components/markdown/attachment-renderer.tsx
+++ b/src/components/markdown/attachment-renderer.tsx
@@ -73,6 +73,10 @@ import { isSafeExternalUrl, toRenderableHref } from "@/lib/url-safety";
const FORGE_ATTACHMENT_RE = /(!?)\[([^\]]*)\]\(forge-attachment:([a-z0-9]{20,})\)/gi;
const FORGE_LINK_RE = /\[([^\]]*)\]\(forge-link:(https?:\/\/[^\s)]+)\)/gi;
+// GitHub's compact owner/repo#number form must be consumed before Forge issue
+// keys. Otherwise an owner such as `CODENAME-11` is incorrectly linked as a
+// local issue and the remainder starts at the slash.
+const GITHUB_REF_RE = /\b([a-z0-9](?:[a-z0-9-]{0,38})\/[a-z0-9._-]+)#(\d+)\b/gi;
// Issue refs (KEY-NN) and agent mentions (@profileKey). The lookbehind on
// `@` keeps `email@host` from accidentally matching as a mention.
const REF_RE = /(\b[A-Z][A-Z0-9]{1,9}-\d+\b)|(?<=^|[\s(,.;:!?])@([a-z0-9][a-z0-9_-]*)/gi;
@@ -88,6 +92,7 @@ type InlineSegment =
| { type: "image"; alt: string; attachmentId: string }
| { type: "attachmentLink"; label: string; attachmentId: string }
| { type: "linkChip"; label: string; url: string }
+ | { type: "githubRef"; repoFullName: string; number: number }
| { type: "issueRef"; key: string }
| { type: "mention"; profileKey: string }
| { type: "mdLink"; label: string; url: string }
@@ -177,7 +182,8 @@ function tokenizeInline(text: string): InlineSegment[] {
if (lo < t.length) passC.push({ type: "text", value: t.slice(lo) });
}
- // Pass D: issue refs + agent mentions.
+ // Pass D: bare URLs. Consume complete URLs before compact GitHub or Forge
+ // issue references so path and fragment tokens remain part of the URL.
const passD: InlineSegment[] = [];
for (const seg of passC) {
if (seg.type !== "text") {
@@ -186,18 +192,17 @@ function tokenizeInline(text: string): InlineSegment[] {
}
const t = seg.value;
let lo = 0;
- REF_RE.lastIndex = 0;
- for (const m of t.matchAll(REF_RE)) {
+ URL_RE.lastIndex = 0;
+ for (const m of t.matchAll(URL_RE)) {
const idx = m.index ?? 0;
if (idx > lo) passD.push({ type: "text", value: t.slice(lo, idx) });
- if (m[1]) passD.push({ type: "issueRef", key: m[1].toUpperCase() });
- else if (m[2]) passD.push({ type: "mention", profileKey: m[2].toLowerCase() });
+ passD.push({ type: "url", url: m[0] });
lo = idx + m[0].length;
}
if (lo < t.length) passD.push({ type: "text", value: t.slice(lo) });
}
- // Pass E: bare URLs on what's left.
+ // Pass E: compact GitHub owner/repo#number references on remaining text.
const passE: InlineSegment[] = [];
for (const seg of passD) {
if (seg.type !== "text") {
@@ -206,22 +211,46 @@ function tokenizeInline(text: string): InlineSegment[] {
}
const t = seg.value;
let lo = 0;
- URL_RE.lastIndex = 0;
- for (const m of t.matchAll(URL_RE)) {
+ GITHUB_REF_RE.lastIndex = 0;
+ for (const m of t.matchAll(GITHUB_REF_RE)) {
const idx = m.index ?? 0;
if (idx > lo) passE.push({ type: "text", value: t.slice(lo, idx) });
- passE.push({ type: "url", url: m[0] });
+ passE.push({
+ type: "githubRef",
+ repoFullName: m[1],
+ number: Number(m[2]),
+ });
lo = idx + m[0].length;
}
if (lo < t.length) passE.push({ type: "text", value: t.slice(lo) });
}
- // Pass F: inline emphasis (bold, italic, code, strikethrough) on
+ // Pass F: issue refs + agent mentions on what's left.
+ const passF: InlineSegment[] = [];
+ for (const seg of passE) {
+ if (seg.type !== "text") {
+ passF.push(seg);
+ continue;
+ }
+ const t = seg.value;
+ let lo = 0;
+ REF_RE.lastIndex = 0;
+ for (const m of t.matchAll(REF_RE)) {
+ const idx = m.index ?? 0;
+ if (idx > lo) passF.push({ type: "text", value: t.slice(lo, idx) });
+ if (m[1]) passF.push({ type: "issueRef", key: m[1].toUpperCase() });
+ else if (m[2]) passF.push({ type: "mention", profileKey: m[2].toLowerCase() });
+ lo = idx + m[0].length;
+ }
+ if (lo < t.length) passF.push({ type: "text", value: t.slice(lo) });
+ }
+
+ // Pass G: inline emphasis (bold, italic, code, strikethrough) on
// remaining text spans. We do this last so emphasis can't "swallow"
// the URL of an inline link (`*foo [bar](url)*` still highlights the
// link), and so an attachment chip wrapped in `*…*` works naturally.
const out: InlineSegment[] = [];
- for (const seg of passE) {
+ for (const seg of passF) {
if (seg.type !== "text") {
out.push(seg);
continue;
@@ -727,10 +756,22 @@ function renderInlineSegs(
}
return ;
}
- if (s.type === "issueRef")
- return ;
- if (s.type === "mention")
- return ;
+ if (s.type === "issueRef") return ;
+ if (s.type === "githubRef") {
+ const label = `${s.repoFullName}#${s.number}`;
+ return (
+
+ {label}
+
+ );
+ }
+ if (s.type === "mention") return ;
if (s.type === "mdLink") {
// Explicit allowlist: http(s) links open in a new tab, and internal
// app paths (e.g. `/w/foo/issues/abc`) get an in-app Link. Everything
diff --git a/src/lib/activity-grouping.ts b/src/lib/activity-grouping.ts
new file mode 100644
index 00000000..b2020890
--- /dev/null
+++ b/src/lib/activity-grouping.ts
@@ -0,0 +1,28 @@
+export type ConsecutiveGroup = {
+ newest: T;
+ oldest: T;
+ count: number;
+};
+
+/**
+ * Collapse only adjacent equivalent rows. This keeps audit order intact and
+ * avoids merging distinct work that merely happens to share the same label.
+ */
+export function groupConsecutive(
+ items: readonly T[],
+ keyFor: (item: T) => string,
+): Array> {
+ const groups: Array> = [];
+
+ for (const item of items) {
+ const previous = groups.at(-1);
+ if (previous && keyFor(previous.newest) === keyFor(item)) {
+ previous.oldest = item;
+ previous.count += 1;
+ } else {
+ groups.push({ newest: item, oldest: item, count: 1 });
+ }
+ }
+
+ return groups;
+}
diff --git a/src/lib/delivery-identity.ts b/src/lib/delivery-identity.ts
new file mode 100644
index 00000000..125c2a7e
--- /dev/null
+++ b/src/lib/delivery-identity.ts
@@ -0,0 +1,62 @@
+import type { WorkSessionSource } from "@prisma/client";
+
+type DeliveryPerson = {
+ name: string | null;
+ email?: string | null;
+ profileKey?: string | null;
+};
+
+export type DeliveryIdentityInput = {
+ source: WorkSessionSource;
+ ownerUser?: DeliveryPerson | null;
+ ownerAgent?: DeliveryPerson | null;
+ ownerConnection?: { agent?: DeliveryPerson | null } | null;
+};
+
+export type DeliveryIdentity = {
+ agentLabel: string | null;
+ operatorLabel: string | null;
+ primaryLabel: string;
+ summary: string | null;
+};
+
+export function resolveDeliveryIdentity(session: DeliveryIdentityInput): DeliveryIdentity {
+ const registeredAgent = session.ownerAgent ?? session.ownerConnection?.agent ?? null;
+ const registeredAgentLabel =
+ registeredAgent?.name && registeredAgent.profileKey
+ ? `${registeredAgent.name} · @${registeredAgent.profileKey}`
+ : null;
+ const unregisteredAgentLabel =
+ session.source === "CODEX_DESKTOP"
+ ? "Unregistered Codex Desktop client"
+ : session.source === "MCP"
+ ? "Unregistered MCP client"
+ : session.source === "FORGE_AGENT"
+ ? "Unregistered Forge agent"
+ : session.source === "ISSUE_DISPATCH"
+ ? "Unregistered dispatched agent"
+ : session.source === "SCHEDULED"
+ ? "Unregistered scheduled agent"
+ : session.source === "NATIVE_SESSION"
+ ? "Unregistered native session agent"
+ : null;
+ const agentLabel = registeredAgentLabel ?? unregisteredAgentLabel;
+ const operatorLabel = session.ownerUser?.name ?? session.ownerUser?.email ?? null;
+
+ if (agentLabel) {
+ const agentState = registeredAgentLabel ? "Agent" : "Agent identity unverified";
+ return {
+ agentLabel,
+ operatorLabel,
+ primaryLabel: agentLabel,
+ summary: operatorLabel ? `${agentState} · Operator ${operatorLabel}` : agentState,
+ };
+ }
+
+ return {
+ agentLabel: null,
+ operatorLabel,
+ primaryLabel: operatorLabel ?? "Unassigned",
+ summary: operatorLabel ? "Operator" : null,
+ };
+}
diff --git a/src/server/routers/action-request.ts b/src/server/routers/action-request.ts
index 80055b08..4cbad409 100644
--- a/src/server/routers/action-request.ts
+++ b/src/server/routers/action-request.ts
@@ -13,6 +13,7 @@ import {
voteOnActionRequest,
} from "@/server/services/action-request-service";
import { enqueueGitHubResourceReconciliation } from "@/server/services/github/reconciliation-queue";
+import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types";
/**
* Zod-side mirror of `ActionRequestKind` so the router can accept the
@@ -124,7 +125,7 @@ export const actionRequestRouter = router({
select: {
externalLinks: {
where: {
- kind: "IMPLEMENTS",
+ kind: { in: [...IMPLEMENTATION_LINK_KINDS] },
externalResource: { provider: "GITHUB", resourceType: "PULL_REQUEST" },
},
select: { externalResourceId: true },
diff --git a/src/server/routers/attachment.ts b/src/server/routers/attachment.ts
index 537a7275..e1342bdc 100644
--- a/src/server/routers/attachment.ts
+++ b/src/server/routers/attachment.ts
@@ -192,6 +192,7 @@ export const attachmentRouter = router({
issueId: input.targetId,
url: safeUrl,
kind: "RELATES_TO",
+ preserveExistingRelation: true,
actor: {
actorId: ctx.session.user.id,
actorAgentId: ctx.apiKey?.linkedAgentId ?? null,
diff --git a/src/server/routers/work-session.ts b/src/server/routers/work-session.ts
index a52d1407..16f78943 100644
--- a/src/server/routers/work-session.ts
+++ b/src/server/routers/work-session.ts
@@ -141,13 +141,13 @@ export const workSessionRouter = router({
branch: branchSchema,
baseBranch: branchSchema.default("main"),
worktreePath: z.string().max(1_000).nullable().optional(),
- source: z.nativeEnum(WorkSessionSource).default(WorkSessionSource.CODEX_DESKTOP),
}),
)
.mutation(({ ctx, input }) =>
claimWorkSession(ctx.db, {
workspaceId: ctx.workspaceId,
...input,
+ source: WorkSessionSource.MANUAL,
actor: { userId: ctx.session.user.id, agentId: ctx.apiKey?.linkedAgentId ?? null },
}),
),
diff --git a/src/server/services/__tests__/completion-candidate.test.ts b/src/server/services/__tests__/completion-candidate.test.ts
index 3e33882d..f11b524a 100644
--- a/src/server/services/__tests__/completion-candidate.test.ts
+++ b/src/server/services/__tests__/completion-candidate.test.ts
@@ -49,6 +49,7 @@ async function linkedPullRequest(
issueId: string,
state: "open" | "closed" | "merged",
conclusion: string | null = "success",
+ kind: "IMPLEMENTS" | "FIXES" = "IMPLEMENTS",
) {
const prisma = getPrisma();
const resource = await prisma.externalResource.create({
@@ -71,7 +72,7 @@ async function linkedPullRequest(
workspaceId: fixture.workspace.id,
issueId,
externalResourceId: resource.id,
- kind: "IMPLEMENTS",
+ kind,
createdById: fixture.user.id,
},
});
@@ -79,6 +80,27 @@ async function linkedPullRequest(
}
describe("completion candidate policy", () => {
+ it("treats an explicit fixes/closes PR as implementation evidence", async () => {
+ const { fixture, prisma } = await setup();
+ const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" });
+ const pullRequest = await linkedPullRequest(fixture, issue.id, "merged", "success", "FIXES");
+
+ const result = await evaluateIssueCompletionCandidate(prisma, {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ actorId: fixture.user.id,
+ sourceType: "github-pull-request",
+ sourceId: pullRequest.id,
+ sourceLabel: "Fixes AXI issue",
+ });
+
+ expect(result.outcome).toBe("RECOMMENDED");
+ const request = await prisma.actionRequest.findUniqueOrThrow({
+ where: { id: "requestId" in result ? result.requestId : "" },
+ });
+ expect(request.payload).toMatchObject({ assessment: { state: "READY" } });
+ });
+
it("refreshes one durable recommendation for repeated completion signals", async () => {
const { fixture, prisma } = await setup();
const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" });
diff --git a/src/server/services/__tests__/github-reconciliation.test.ts b/src/server/services/__tests__/github-reconciliation.test.ts
index 14aab91a..abf04ddd 100644
--- a/src/server/services/__tests__/github-reconciliation.test.ts
+++ b/src/server/services/__tests__/github-reconciliation.test.ts
@@ -190,7 +190,7 @@ describe("GitHub status reconciliation", () => {
expect(calls).toBe(1);
});
- it("backs off merged PRs with no checks and re-enables terminal rows when reopened", async () => {
+ it("backs off merged PRs with stale checks and re-enables terminal rows when reopened", async () => {
const { fixture, prisma, resource } = await setupResource();
const now = new Date("2026-07-14T12:00:00.000Z");
const syncResource = async () =>
@@ -213,6 +213,7 @@ describe("GitHub status reconciliation", () => {
const held = await prisma.externalResource.findUniqueOrThrow({ where: { id: resource.id } });
expect(held.syncFailureCount).toBe(1);
expect(held.syncRetryAt).toEqual(new Date("2026-07-14T12:05:00.000Z"));
+ expect(held.syncTerminalAt).toBeNull();
await prisma.externalResource.update({
where: { id: resource.id },
@@ -272,6 +273,60 @@ describe("GitHub status reconciliation", () => {
expect(newHead.metadata).not.toHaveProperty("checks");
});
+ it("normalizes merged facts while keeping unknown checks eligible for reconciliation", async () => {
+ const { fixture, prisma, resource } = await setupResource();
+ const merged = await upsertExternalResource(prisma, {
+ workspaceId: fixture.workspace.id,
+ snapshot: {
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 42,
+ url: resource.url,
+ title: resource.title,
+ state: "merged",
+ labels: [],
+ assignees: [],
+ metadata: {
+ mergeableState: "unknown",
+ checks: { status: "unknown", partial: true, diagnostic: "Checks unavailable" },
+ },
+ },
+ });
+ const metadata = merged.metadata as Record;
+ expect(merged.syncTerminalAt).toBeNull();
+ expect(merged.syncRetryAt).toBeNull();
+ expect(merged.syncFailureCount).toBe(1);
+ expect(merged.syncLastError).toBe("Checks unavailable");
+ expect(metadata.mergeableState).toBeNull();
+
+ const trusted = await upsertExternalResource(prisma, {
+ workspaceId: fixture.workspace.id,
+ snapshot: {
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 42,
+ url: resource.url,
+ title: resource.title,
+ state: "merged",
+ labels: [],
+ assignees: [],
+ metadata: {
+ checks: {
+ source: "api-aggregate",
+ status: "completed",
+ conclusion: "success",
+ partial: false,
+ },
+ },
+ },
+ });
+ expect(trusted.syncTerminalAt).toBeInstanceOf(Date);
+ expect(trusted.syncFailureCount).toBe(0);
+ expect(trusted.syncLastError).toBeNull();
+ });
+
it("does not inspect disabled workspaces, terminal PRs, or an active retry lease", async () => {
const { fixture, prisma, resource } = await setupResource();
const now = new Date("2026-07-14T12:00:00.000Z");
diff --git a/src/server/services/__tests__/github-relation-persistence.test.ts b/src/server/services/__tests__/github-relation-persistence.test.ts
new file mode 100644
index 00000000..a62c35ee
--- /dev/null
+++ b/src/server/services/__tests__/github-relation-persistence.test.ts
@@ -0,0 +1,401 @@
+import { afterAll, afterEach, describe, expect, it } from "vitest";
+import {
+ applyGitHubSnapshotToLinkedIssues,
+ canonicalizeGitHubResourceIdentity,
+ linkExternalResourceToIssue,
+} from "@/server/services/github/resource-sync";
+import {
+ createIssue,
+ createWorkspaceFixture,
+ disconnectPrisma,
+ getPrisma,
+ type TestFixture,
+} from "@/server/routers/__tests__/helpers";
+
+const fixtures: TestFixture[] = [];
+
+afterEach(async () => {
+ while (fixtures.length) await fixtures.pop()!.cleanup();
+});
+
+afterAll(async () => {
+ await disconnectPrisma();
+});
+
+describe("native GitHub relation persistence", () => {
+ it("reclassifies one issue/resource relation instead of duplicating cards", async () => {
+ const fixture = await createWorkspaceFixture({ keyPrefix: "GR" });
+ fixtures.push(fixture);
+ const prisma = getPrisma();
+ const issue = await createIssue(fixture);
+ const resource = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 117,
+ url: "https://github.com/acme/forge/pull/117",
+ title: "Release v0.28.0",
+ state: "merged",
+ },
+ });
+
+ const actor = { actorId: fixture.user.id };
+ await linkExternalResourceToIssue(prisma, {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ kind: "IMPLEMENTS",
+ actor,
+ });
+ await linkExternalResourceToIssue(prisma, {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ kind: "RELEASES",
+ actor,
+ });
+ const activityAfterReclassification = await prisma.activityEvent.count({
+ where: { workspaceId: fixture.workspace.id, subjectId: issue.id },
+ });
+ await linkExternalResourceToIssue(prisma, {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ kind: "RELEASES",
+ actor,
+ });
+
+ await expect(
+ prisma.externalResourceLink.findMany({
+ where: { issueId: issue.id, externalResourceId: resource.id },
+ }),
+ ).resolves.toMatchObject([{ kind: "RELEASES" }]);
+ expect(
+ await prisma.activityEvent.count({
+ where: { workspaceId: fixture.workspace.id, subjectId: issue.id },
+ }),
+ ).toBe(activityAfterReclassification);
+ });
+
+ it("rejects a cross-workspace relation even when both ids are valid", async () => {
+ const fixture = await createWorkspaceFixture({ keyPrefix: "GA" });
+ const other = await createWorkspaceFixture({ keyPrefix: "GB" });
+ fixtures.push(fixture, other);
+ const prisma = getPrisma();
+ const issue = await createIssue(fixture);
+ const resource = await prisma.externalResource.create({
+ data: {
+ workspaceId: other.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/other",
+ number: 1,
+ url: "https://github.com/acme/other/pull/1",
+ title: "Other tenant",
+ state: "open",
+ },
+ });
+
+ await expect(
+ linkExternalResourceToIssue(prisma, {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ kind: "RELATES_TO",
+ actor: { actorId: fixture.user.id },
+ }),
+ ).rejects.toThrow("External resource not found");
+ });
+
+ it("does not let a generic related replay downgrade implementation evidence", async () => {
+ const fixture = await createWorkspaceFixture({ keyPrefix: "GP" });
+ fixtures.push(fixture);
+ const prisma = getPrisma();
+ const issue = await createIssue(fixture);
+ const resource = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 118,
+ url: "https://github.com/acme/forge/pull/118",
+ title: "Fixes GP-1",
+ state: "open",
+ },
+ });
+ const relation = {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ actor: { actorId: fixture.user.id },
+ };
+
+ await linkExternalResourceToIssue(prisma, { ...relation, kind: "FIXES" });
+ const activityAfterFixes = await prisma.activityEvent.count({
+ where: { workspaceId: fixture.workspace.id, subjectId: issue.id },
+ });
+ await linkExternalResourceToIssue(prisma, {
+ ...relation,
+ kind: "RELATES_TO",
+ preserveExistingRelation: true,
+ });
+
+ await expect(
+ prisma.externalResourceLink.findUniqueOrThrow({
+ where: {
+ issueId_externalResourceId: {
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ },
+ },
+ }),
+ ).resolves.toMatchObject({ kind: "FIXES" });
+ expect(
+ await prisma.activityEvent.count({
+ where: { workspaceId: fixture.workspace.id, subjectId: issue.id },
+ }),
+ ).toBe(activityAfterFixes);
+
+ await linkExternalResourceToIssue(prisma, { ...relation, kind: "RELATES_TO" });
+ await expect(
+ prisma.externalResourceLink.findUniqueOrThrow({
+ where: {
+ issueId_externalResourceId: {
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ },
+ },
+ }),
+ ).resolves.toMatchObject({ kind: "RELATES_TO" });
+ });
+
+ it("preserves imported SOURCE identity when derived relations are replayed", async () => {
+ const fixture = await createWorkspaceFixture({ keyPrefix: "GS" });
+ fixtures.push(fixture);
+ const prisma = getPrisma();
+ const issue = await createIssue(fixture);
+ const resource = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 119,
+ url: "https://github.com/acme/forge/pull/119",
+ title: "Imported pull request",
+ state: "open",
+ },
+ });
+ const relation = {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ actor: { actorId: fixture.user.id },
+ };
+
+ await linkExternalResourceToIssue(prisma, { ...relation, kind: "SOURCE" });
+ await linkExternalResourceToIssue(prisma, { ...relation, kind: "FIXES" });
+
+ await expect(
+ prisma.externalResourceLink.findUniqueOrThrow({
+ where: {
+ issueId_externalResourceId: {
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ },
+ },
+ }),
+ ).resolves.toMatchObject({ kind: "SOURCE" });
+ });
+
+ it("dismisses stale completion requests when the last implementation PR is reclassified", async () => {
+ const fixture = await createWorkspaceFixture({ keyPrefix: "GD" });
+ fixtures.push(fixture);
+ const prisma = getPrisma();
+ const issue = await createIssue(fixture);
+ const resource = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 120,
+ url: "https://github.com/acme/forge/pull/120",
+ title: "Implementation pull request",
+ state: "merged",
+ },
+ });
+ const relation = {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ actor: { actorId: fixture.user.id },
+ };
+
+ await linkExternalResourceToIssue(prisma, { ...relation, kind: "IMPLEMENTS" });
+ const request = await prisma.actionRequest.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ title: "Ready to close",
+ sourceType: "completion-candidate",
+ sourceId: resource.id,
+ dedupeKey: `issue-completion:${issue.id}`,
+ },
+ });
+
+ await linkExternalResourceToIssue(prisma, { ...relation, kind: "RELEASES" });
+
+ await expect(
+ prisma.actionRequest.findUniqueOrThrow({ where: { id: request.id } }),
+ ).resolves.toMatchObject({
+ status: "DISMISSED",
+ resolution: "The linked pull request is no longer implementation evidence.",
+ });
+ });
+
+ it("keeps SOURCE identity when duplicate GitHub resources are canonicalized", async () => {
+ const fixture = await createWorkspaceFixture({ keyPrefix: "GC" });
+ fixtures.push(fixture);
+ const prisma = getPrisma();
+ const issue = await createIssue(fixture);
+ const source = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 121,
+ url: "https://github.com/acme/forge/pull/121",
+ title: "Canonical source",
+ state: "open",
+ externalUpdatedAt: new Date("2026-07-16T12:00:00Z"),
+ },
+ });
+ const duplicate = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "ACME/FORGE",
+ number: 121,
+ url: "https://github.com/ACME/FORGE/pull/121",
+ title: "Duplicate relation",
+ state: "open",
+ externalUpdatedAt: new Date("2026-07-16T11:00:00Z"),
+ },
+ });
+ await prisma.externalResourceLink.createMany({
+ data: [
+ {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: source.id,
+ kind: "SOURCE",
+ },
+ {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: duplicate.id,
+ kind: "FIXES",
+ },
+ ],
+ });
+ const request = await prisma.actionRequest.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ title: "Stale canonicalization candidate",
+ sourceType: "completion-candidate",
+ sourceId: duplicate.id,
+ dedupeKey: `issue-completion:${issue.id}`,
+ },
+ });
+
+ await canonicalizeGitHubResourceIdentity(prisma, {
+ workspaceId: fixture.workspace.id,
+ snapshot: {
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 121,
+ url: source.url,
+ title: source.title,
+ state: "open",
+ },
+ });
+
+ await expect(
+ prisma.externalResourceLink.findUniqueOrThrow({
+ where: {
+ issueId_externalResourceId: { issueId: issue.id, externalResourceId: source.id },
+ },
+ }),
+ ).resolves.toMatchObject({ kind: "SOURCE" });
+ await expect(
+ prisma.externalResource.findUnique({ where: { id: duplicate.id } }),
+ ).resolves.toBeNull();
+ await expect(
+ prisma.actionRequest.findUniqueOrThrow({ where: { id: request.id } }),
+ ).resolves.toMatchObject({
+ status: "DISMISSED",
+ resolution: "The canonical GitHub source is no longer implementation evidence.",
+ });
+ });
+
+ it("keeps imported SOURCE pull requests eligible for mapped status synchronization", async () => {
+ const fixture = await createWorkspaceFixture({ keyPrefix: "GY" });
+ fixtures.push(fixture);
+ const prisma = getPrisma();
+ const issue = await createIssue(fixture);
+ const targetStatus = await prisma.status.findFirstOrThrow({
+ where: { workspaceId: fixture.workspace.id, category: "IN_PROGRESS" },
+ });
+ const resource = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 122,
+ url: "https://github.com/acme/forge/pull/122",
+ title: "Imported source",
+ state: "open",
+ },
+ });
+ await linkExternalResourceToIssue(prisma, {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ kind: "SOURCE",
+ actor: { actorId: fixture.user.id },
+ });
+
+ await applyGitHubSnapshotToLinkedIssues({
+ tx: prisma,
+ workspaceId: fixture.workspace.id,
+ resourceId: resource.id,
+ mapping: { config: null },
+ snapshot: {
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: resource.repoFullName,
+ number: resource.number,
+ url: resource.url,
+ title: resource.title,
+ state: resource.state,
+ },
+ actor: { actorId: fixture.user.id },
+ statusRuleId: targetStatus.id,
+ });
+
+ await expect(
+ prisma.issue.findUniqueOrThrow({ where: { id: issue.id } }),
+ ).resolves.toMatchObject({
+ statusId: targetStatus.id,
+ });
+ });
+});
diff --git a/src/server/services/__tests__/github-webhook-hardening.test.ts b/src/server/services/__tests__/github-webhook-hardening.test.ts
index d1f0d8c5..700c71e4 100644
--- a/src/server/services/__tests__/github-webhook-hardening.test.ts
+++ b/src/server/services/__tests__/github-webhook-hardening.test.ts
@@ -239,6 +239,85 @@ describe("GitHub webhook hardening", () => {
).resolves.toBe(1);
});
+ it("preserves the stronger relation when canonical rows collide", async () => {
+ const { fixture, prisma, issue, mapping } = await setup();
+ const canonical = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ connectionMappingId: mapping.id,
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 42,
+ url: "https://github.com/acme/forge/pull/42",
+ title: "Canonical casing",
+ state: "open",
+ externalUpdatedAt: new Date("2026-07-14T13:00:00Z"),
+ },
+ });
+ const duplicate = await prisma.externalResource.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ connectionMappingId: mapping.id,
+ resourceType: "PULL_REQUEST",
+ repoFullName: "Acme/Forge",
+ number: 42,
+ url: "https://github.com/Acme/Forge/pull/42",
+ title: "Legacy casing",
+ state: "open",
+ externalUpdatedAt: new Date("2026-07-14T12:00:00Z"),
+ },
+ });
+ await prisma.externalResourceLink.createMany({
+ data: [
+ {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: canonical.id,
+ kind: "RELATES_TO",
+ },
+ {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ externalResourceId: duplicate.id,
+ kind: "FIXES",
+ },
+ ],
+ });
+
+ await upsertExternalResource(prisma, {
+ workspaceId: fixture.workspace.id,
+ connectionMappingId: mapping.id,
+ snapshot: {
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ repoFullName: "acme/forge",
+ number: 42,
+ url: "https://github.com/acme/forge/pull/42",
+ title: "Canonical casing",
+ state: "open",
+ externalUpdatedAt: new Date("2026-07-14T14:00:00Z"),
+ },
+ });
+
+ await expect(
+ prisma.externalResourceLink.findMany({
+ where: { workspaceId: fixture.workspace.id, issueId: issue.id },
+ }),
+ ).resolves.toMatchObject([{ externalResourceId: canonical.id, kind: "FIXES" }]);
+ await expect(
+ prisma.externalResource.count({
+ where: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ number: 42,
+ },
+ }),
+ ).resolves.toBe(1);
+ });
+
it("applies webhook freshness guards across repository casing", async () => {
const { fixture, prisma, issue, mapping } = await setup();
const merged = await prisma.externalResource.create({
@@ -712,6 +791,95 @@ describe("GitHub webhook hardening", () => {
});
});
+ it("does not apply PR lifecycle or failed-check status rules to a related mention", async () => {
+ const { fixture, prisma, issue, mapping } = await setup();
+ const automatedStatus = await prisma.status.findFirstOrThrow({
+ where: { workspaceId: fixture.workspace.id, category: "CANCELED" },
+ });
+ await prisma.connectionMapping.update({
+ where: { id: mapping.id },
+ data: {
+ config: {
+ github: {
+ statusRules: {
+ prOpenedStatusId: automatedStatus.id,
+ checksFailedStatusId: automatedStatus.id,
+ },
+ },
+ },
+ },
+ });
+
+ await processGitHubWebhook({
+ db: prisma,
+ deliveryId: delivery("related-pr-opened"),
+ event: "pull_request",
+ payload: {
+ action: "opened",
+ installation: { id: 101 },
+ repository: { full_name: "acme/forge" },
+ pull_request: pullRequest({
+ body: `Related ${fixture.workspace.key}-${issue.number}`,
+ }),
+ },
+ });
+
+ const resource = await prisma.externalResource.findFirstOrThrow({
+ where: {
+ workspaceId: fixture.workspace.id,
+ provider: "GITHUB",
+ resourceType: "PULL_REQUEST",
+ number: 42,
+ },
+ });
+ await expect(
+ prisma.externalResourceLink.findUniqueOrThrow({
+ where: {
+ issueId_externalResourceId: {
+ issueId: issue.id,
+ externalResourceId: resource.id,
+ },
+ },
+ }),
+ ).resolves.toMatchObject({ kind: "RELATES_TO" });
+ const sourceIssue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" });
+ await prisma.externalResourceLink.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ issueId: sourceIssue.id,
+ externalResourceId: resource.id,
+ kind: "SOURCE",
+ },
+ });
+
+ await processGitHubWebhook({
+ db: prisma,
+ deliveryId: delivery("related-pr-checks-failed"),
+ event: "check_run",
+ payload: {
+ action: "completed",
+ installation: { id: 101 },
+ repository: { full_name: "acme/forge" },
+ check_run: {
+ head_sha: "head-new",
+ status: "completed",
+ conclusion: "failure",
+ pull_requests: [{ number: 42 }],
+ },
+ },
+ });
+
+ await expect(
+ prisma.issue.findUniqueOrThrow({ where: { id: issue.id }, include: { status: true } }),
+ ).resolves.toMatchObject({ status: { category: "IN_PROGRESS" } });
+ await expect(
+ prisma.issue.findUniqueOrThrow({
+ where: { id: sourceIssue.id },
+ include: { status: true },
+ }),
+ ).resolves.toMatchObject({ status: { category: "CANCELED" } });
+ });
+
it("ignores an older review hint instead of replacing a newer decision", async () => {
const { fixture, prisma, mapping } = await setup();
const resource = await upsertExternalResource(prisma, {
@@ -1573,14 +1741,13 @@ describe("GitHub webhook hardening", () => {
await expect(
prisma.externalResourceLink.findUnique({
where: {
- issueId_externalResourceId_kind: {
+ issueId_externalResourceId: {
issueId: issue.id,
externalResourceId: resource.id,
- kind: "RELATES_TO",
},
},
}),
- ).resolves.toBeTruthy();
+ ).resolves.toMatchObject({ kind: "RELATES_TO" });
await expect(
migrateGenericGitHubAttachments(prisma, {
workspaceId: fixture.workspace.id,
diff --git a/src/server/services/__tests__/work-session.test.ts b/src/server/services/__tests__/work-session.test.ts
index 3fd986dc..cbdef2a0 100644
--- a/src/server/services/__tests__/work-session.test.ts
+++ b/src/server/services/__tests__/work-session.test.ts
@@ -6,6 +6,7 @@ import {
advanceWorkSession,
attachPullRequest,
claimWorkSession,
+ listIssueWorkSessions,
resolveMcpQuietRequestsForConnection,
sweepStaleWorkSessions,
touchWorkSession,
@@ -36,6 +37,67 @@ async function setup() {
}
describe("work session coordination", () => {
+ it("reports implementation provenance only from a run that produced output", async () => {
+ const { fixture, prisma, issue } = await setup();
+ const agent = await prisma.agent.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ profileKey: `run-backed-${Date.now()}`,
+ name: "Run-backed agent",
+ },
+ });
+ const connection = await prisma.agentConnection.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ agentId: agent.id,
+ kind: "MCP_CLIENT",
+ livenessModel: "LEASE",
+ instanceKey: `run-backed-${Date.now()}`,
+ displayName: "Codex CLI",
+ clientName: "codex-cli",
+ },
+ });
+ await claimWorkSession(prisma, {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ repoFullName: "acme/forge",
+ branch: "codex/run-backed",
+ source: WorkSessionSource.MCP,
+ actor: { userId: fixture.user.id, agentId: agent.id, connectionId: connection.id },
+ });
+ const run = await prisma.agentRun.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ issueId: issue.id,
+ agentId: agent.id,
+ connectionId: connection.id,
+ engagementMode: "EXECUTE",
+ externalRunId: null,
+ },
+ });
+
+ expect(
+ (await listIssueWorkSessions(prisma, fixture.workspace.id, issue.id))[0]
+ ?.observedImplementation,
+ ).toBeNull();
+ await prisma.agentRun.update({ where: { id: run.id }, data: { outputStartedAt: new Date() } });
+ await expect(
+ listIssueWorkSessions(prisma, fixture.workspace.id, issue.id),
+ ).resolves.toMatchObject([
+ {
+ source: "MCP",
+ observedImplementation: {
+ agent: { id: agent.id },
+ run: {
+ id: run.id,
+ externalRunId: null,
+ connection: { id: connection.id, kind: "MCP_CLIENT", clientName: "codex-cli" },
+ },
+ },
+ },
+ ]);
+ });
+
it("returns the owned lease idempotently and rejects competing work", async () => {
const { fixture, prisma, issue } = await setup();
const first = await claimWorkSession(prisma, {
diff --git a/src/server/services/action-request-service.ts b/src/server/services/action-request-service.ts
index 336d4ff4..84be5cd9 100644
--- a/src/server/services/action-request-service.ts
+++ b/src/server/services/action-request-service.ts
@@ -641,7 +641,7 @@ export async function createActionRequest(
/** Resolve / dismiss / snooze an action request. */
export async function transitionActionRequest(
- db: PrismaClient,
+ db: PrismaClient | Prisma.TransactionClient,
params: {
workspaceId: string;
actorId: string | null;
@@ -657,7 +657,7 @@ export async function transitionActionRequest(
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Action request not found." });
}
- await db.$transaction(async (tx) => {
+ const apply = async (tx: Prisma.TransactionClient) => {
try {
await tx.actionRequest.update({
where: { id: row.id },
@@ -693,7 +693,12 @@ export async function transitionActionRequest(
subjectType: "action-request",
subjectId: row.id,
});
- });
+ };
+ if ("$transaction" in db) {
+ await db.$transaction(apply);
+ } else {
+ await apply(db);
+ }
}
/**
diff --git a/src/server/services/completion-candidate.ts b/src/server/services/completion-candidate.ts
index 8e16f0e0..0c2a127a 100644
--- a/src/server/services/completion-candidate.ts
+++ b/src/server/services/completion-candidate.ts
@@ -6,12 +6,14 @@ import {
EventKind,
NotificationSeverity,
type PrismaClient,
+ type Prisma,
} from "@prisma/client";
import { recordChange } from "@/server/audit";
import {
createActionRequest,
transitionActionRequest,
} from "@/server/services/action-request-service";
+import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types";
const COMPLETION_SOURCE = "completion-candidate";
const RECOVERY_SOURCE = "github-pr-recovery";
@@ -102,7 +104,7 @@ function canonicalJson(value: unknown): unknown {
}
async function dismissOpenRequests(
- db: PrismaClient,
+ db: PrismaClient | Prisma.TransactionClient,
params: {
workspaceId: string;
actorId: string | null;
@@ -133,6 +135,18 @@ async function dismissOpenRequests(
}
}
+export async function dismissIssueCompletionCandidate(
+ db: PrismaClient | Prisma.TransactionClient,
+ params: { workspaceId: string; issueId: string; actorId: string | null; resolution: string },
+): Promise {
+ await dismissOpenRequests(db, {
+ workspaceId: params.workspaceId,
+ actorId: params.actorId,
+ dedupeKey: completionDedupeKey(params.issueId),
+ resolution: params.resolution,
+ });
+}
+
async function completionContext(db: PrismaClient, workspaceId: string, issueId: string) {
const issue = await db.issue.findFirst({
where: { id: issueId, workspaceId, deletedAt: null },
@@ -157,7 +171,10 @@ async function completionContext(db: PrismaClient, workspaceId: string, issueId:
executionPlans: { select: { id: true } },
executionSteps: { select: { id: true } },
externalLinks: {
- where: { kind: "IMPLEMENTS", externalResource: { resourceType: "PULL_REQUEST" } },
+ where: {
+ kind: { in: [...IMPLEMENTATION_LINK_KINDS] },
+ externalResource: { resourceType: "PULL_REQUEST" },
+ },
select: {
externalResource: {
select: {
@@ -637,7 +654,7 @@ export async function reconcileGitHubPullRequestCompletion(
url: true,
metadata: true,
links: {
- where: { kind: "IMPLEMENTS" },
+ where: { kind: { in: [...IMPLEMENTATION_LINK_KINDS] } },
select: {
issueId: true,
issue: {
diff --git a/src/server/services/github/client.ts b/src/server/services/github/client.ts
index 6be03d8e..ab42cf12 100644
--- a/src/server/services/github/client.ts
+++ b/src/server/services/github/client.ts
@@ -598,6 +598,7 @@ export function pullRequestSnapshot(
pr: GitHubPullResponse,
): GitHubResourceSnapshot {
const state = pr.merged ? "merged" : pr.draft ? "draft" : pr.state;
+ const terminal = state === "merged" || state === "closed";
return {
provider: "GITHUB",
resourceType: "PULL_REQUEST",
@@ -618,7 +619,10 @@ export function pullRequestSnapshot(
merged: pr.merged ?? false,
mergedAt: pr.merged_at ?? null,
closedAt: pr.closed_at ?? null,
- mergeableState: pr.mergeable_state ?? null,
+ // GitHub mergeability is a pre-merge planning fact. Retaining an old
+ // "unknown"/"blocked" value after terminal state creates contradictory
+ // cards, so terminal snapshots deliberately clear it.
+ mergeableState: terminal ? null : (pr.mergeable_state ?? null),
requestedReviewers: (pr.requested_reviewers ?? [])
.map((reviewer) => reviewer.login ?? "")
.filter(Boolean),
diff --git a/src/server/services/github/reconciliation.ts b/src/server/services/github/reconciliation.ts
index 481ef4d4..99904941 100644
--- a/src/server/services/github/reconciliation.ts
+++ b/src/server/services/github/reconciliation.ts
@@ -3,6 +3,7 @@ import type { PrismaClient } from "@prisma/client";
import { logger } from "@/server/logger";
import { GitHubRequestError } from "@/server/services/github/client";
import { syncGitHubExternalResource } from "@/server/services/github/resource-sync";
+import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types";
const PROVIDER = "GITHUB";
const RESOURCE_TYPE = "PULL_REQUEST";
@@ -186,7 +187,7 @@ export async function sweepGitHubStatusReconciliation(
links: {
some: {
workspaceId: workspace.id,
- kind: "IMPLEMENTS",
+ kind: { in: [...IMPLEMENTATION_LINK_KINDS] },
issue: { workspaceId: workspace.id, deletedAt: null },
},
},
@@ -242,8 +243,9 @@ export async function sweepGitHubStatusReconciliation(
checks.source !== "api-aggregate" ||
checks.status === "unknown" ||
checks.partial === true;
- // Unknown/partial checks on any PR state use exponential/provider
- // backoff. This includes open/draft resources with missing permissions.
+ // Merged implementation PRs still require trusted completion evidence;
+ // keep retrying partial/unknown aggregates even though merge state is a
+ // separate terminal GitHub fact. Closed, unmerged PRs do not.
if (untrustedChecks && resource.state !== "closed") {
const failureCount = candidate.syncFailureCount + 1;
const exponential = retryAtForFailure({
diff --git a/src/server/services/github/relation.ts b/src/server/services/github/relation.ts
new file mode 100644
index 00000000..b3422803
--- /dev/null
+++ b/src/server/services/github/relation.ts
@@ -0,0 +1,68 @@
+import type { ExternalLinkKind } from "@/server/services/github/types";
+
+const IMPLEMENTS_PATTERN = /\bimplements?\s*[:#-]?\s*$/i;
+const FIXES_PATTERN = /\b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\s*[:#-]?\s*$/i;
+const RELEASE_TITLE_PATTERN = /^\s*release\s+v?\d/i;
+const RELEASE_BRANCH_PATTERN = /(?:^|\/)release(?:[-/]|$)/i;
+const GROUPED_REFERENCE_SEPARATOR = /^\s*(?:,\s*(?:(?:and|&)\s*)?|(?:and|&)\s*)$/i;
+
+function relationRank(kind: ExternalLinkKind): number {
+ if (kind === "FIXES") return 3;
+ if (kind === "IMPLEMENTS") return 2;
+ return 1;
+}
+
+export function isReleasePullRequest(input: { title: string; headRef?: string | null }): boolean {
+ return (
+ RELEASE_TITLE_PATTERN.test(input.title) || RELEASE_BRANCH_PATTERN.test(input.headRef ?? "")
+ );
+}
+
+/**
+ * Derive native issue-to-PR relations from explicit Forge issue references.
+ * Bare mentions are related work; implementation and closing keywords upgrade
+ * that relation, while release assembly PRs remain release containment.
+ */
+export function derivePullRequestIssueRelations(input: {
+ workspaceKey: string;
+ title: string;
+ body?: string | null;
+ headRef?: string | null;
+}): Map {
+ const escaped = input.workspaceKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const issuePattern = new RegExp(`\\b${escaped}-(\\d+)\\b`, "gi");
+ const release = isReleasePullRequest(input);
+ const relations = new Map();
+
+ for (const text of [input.title, input.body ?? ""]) {
+ let match: RegExpExecArray | null;
+ let previousMatchEnd: number | null = null;
+ let previousKind: ExternalLinkKind | null = null;
+ while ((match = issuePattern.exec(text))) {
+ const number = Number(match[1]);
+ if (!Number.isInteger(number) || number <= 0) continue;
+ const prefix = text.slice(Math.max(0, match.index - 48), match.index);
+ const directKind: ExternalLinkKind | null = FIXES_PATTERN.test(prefix)
+ ? "FIXES"
+ : IMPLEMENTS_PATTERN.test(prefix)
+ ? "IMPLEMENTS"
+ : null;
+ const groupedKind: ExternalLinkKind | null =
+ previousMatchEnd !== null &&
+ previousKind !== null &&
+ GROUPED_REFERENCE_SEPARATOR.test(text.slice(previousMatchEnd, match.index))
+ ? previousKind
+ : null;
+ const kind: ExternalLinkKind = release
+ ? "RELEASES"
+ : (directKind ?? groupedKind ?? "RELATES_TO");
+ const current = relations.get(number);
+ if (!current || release || relationRank(kind) > relationRank(current)) {
+ relations.set(number, kind);
+ }
+ previousMatchEnd = issuePattern.lastIndex;
+ previousKind = kind;
+ }
+ }
+ return relations;
+}
diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts
index ba071457..ce1fb584 100644
--- a/src/server/services/github/resource-sync.ts
+++ b/src/server/services/github/resource-sync.ts
@@ -13,7 +13,10 @@ import {
import { recordChange } from "@/server/audit";
import { syncWorkSessionsFromPullRequest } from "@/server/services/work-session";
import { createIssueWithSideEffects } from "@/server/services/issue-create";
-import { reconcileGitHubPullRequestCompletion } from "@/server/services/completion-candidate";
+import {
+ dismissIssueCompletionCandidate,
+ reconcileGitHubPullRequestCompletion,
+} from "@/server/services/completion-candidate";
import {
getGitHubIssue,
getGitHubPullRequest,
@@ -32,6 +35,7 @@ import {
import {
EXTERNAL_LINK_KINDS,
GITHUB_PROVIDER,
+ IMPLEMENTATION_LINK_KINDS,
type ExternalLinkKind,
type GitHubResourceSnapshot,
type GitHubResourceType,
@@ -65,6 +69,15 @@ function assertLinkKind(kind: string): asserts kind is ExternalLinkKind {
}
}
+function externalLinkKindRank(kind: string): number {
+ if (kind === "SOURCE") return 6;
+ if (kind === "FIXES") return 5;
+ if (kind === "IMPLEMENTS") return 4;
+ if (kind === "RELEASES") return 3;
+ if (kind === "REVIEWS") return 1;
+ return 0;
+}
+
function publicResource(row: ExternalResource) {
return row;
}
@@ -541,21 +554,39 @@ export async function canonicalizeGitHubResourceIdentity(
// Older builds could create one row per repository spelling. Preserve every
// issue relation on the canonical row, keeping the original link id unless
- // the same issue/kind relation already exists there.
+ // the same issue/resource relation already exists there.
+ const downgradedIssueIds = new Set();
for (const duplicate of equivalent) {
if (duplicate.id === canonical.id) continue;
for (const link of duplicate.links) {
const collision = await db.externalResourceLink.findUnique({
where: {
- issueId_externalResourceId_kind: {
+ issueId_externalResourceId: {
issueId: link.issueId,
externalResourceId: canonical.id,
- kind: link.kind,
},
},
- select: { id: true },
+ select: { id: true, kind: true },
});
if (collision) {
+ const winningKind =
+ externalLinkKindRank(link.kind) > externalLinkKindRank(collision.kind)
+ ? link.kind
+ : collision.kind;
+ if (
+ [link.kind, collision.kind].some((candidate) =>
+ IMPLEMENTATION_LINK_KINDS.some((kind) => kind === candidate),
+ ) &&
+ !IMPLEMENTATION_LINK_KINDS.some((kind) => kind === winningKind)
+ ) {
+ downgradedIssueIds.add(link.issueId);
+ }
+ if (externalLinkKindRank(link.kind) > externalLinkKindRank(collision.kind)) {
+ await db.externalResourceLink.update({
+ where: { id: collision.id },
+ data: { kind: link.kind, createdById: link.createdById },
+ });
+ }
await db.externalResourceLink.delete({ where: { id: link.id } });
} else {
await db.externalResourceLink.update({
@@ -572,6 +603,24 @@ export async function canonicalizeGitHubResourceIdentity(
data: { repoFullName: args.snapshot.repoFullName },
});
}
+ for (const issueId of downgradedIssueIds) {
+ const remainingImplementationLinks = await db.externalResourceLink.count({
+ where: {
+ workspaceId: args.workspaceId,
+ issueId,
+ kind: { in: [...IMPLEMENTATION_LINK_KINDS] },
+ externalResource: { resourceType: "PULL_REQUEST" },
+ },
+ });
+ if (remainingImplementationLinks === 0) {
+ await dismissIssueCompletionCandidate(db, {
+ workspaceId: args.workspaceId,
+ issueId,
+ actorId: null,
+ resolution: "The canonical GitHub source is no longer implementation evidence.",
+ });
+ }
+ }
}
async function upsertExternalResourceInTransaction(
@@ -616,6 +665,10 @@ async function upsertExternalResourceInTransaction(
...existingMetadata,
...snapshotMetadata,
};
+ const terminalState =
+ args.snapshot.resourceType === "PULL_REQUEST" &&
+ (args.snapshot.state === "closed" || args.snapshot.state === "merged");
+ if (terminalState) metadata.mergeableState = null;
const existingHead =
existingMetadata.head &&
typeof existingMetadata.head === "object" &&
@@ -640,22 +693,20 @@ async function upsertExternalResourceInTransaction(
metadata.checks && typeof metadata.checks === "object" && !Array.isArray(metadata.checks)
? (metadata.checks as Record)
: {};
- const passingChecks =
- checks.source === "api-aggregate" &&
- checks.status === "completed" &&
- typeof checks.conclusion === "string" &&
- ["success", "neutral", "skipped"].includes(checks.conclusion);
+ const trustedCompletedChecks =
+ checks.source === "api-aggregate" && checks.status === "completed" && checks.partial !== true;
+ // A merged PR is a terminal GitHub fact, but its synchronization row must
+ // stay eligible for reconciliation until the final checks aggregate is
+ // trusted and complete. Closed, unmerged PRs need no completion evidence.
+ const terminal = terminalState && (args.snapshot.state === "closed" || trustedCompletedChecks);
const checksDiagnostic =
- checks.partial === true && typeof checks.diagnostic === "string"
+ !terminal && checks.partial === true && typeof checks.diagnostic === "string"
? checks.diagnostic.slice(0, 2_000)
: null;
const checksRetryAt =
typeof checks.retryAt === "string" && Number.isFinite(Date.parse(checks.retryAt))
? new Date(checks.retryAt)
: null;
- const terminal =
- args.snapshot.resourceType === "PULL_REQUEST" &&
- (args.snapshot.state === "closed" || (args.snapshot.state === "merged" && passingChecks));
const row = await db.externalResource.upsert({
where: {
workspaceId_provider_repoFullName_resourceType_number: {
@@ -740,6 +791,7 @@ export async function linkExternalResourceToIssue(
kind: ExternalLinkKind;
actor: ActorMeta;
recordActivity?: boolean;
+ preserveExistingRelation?: boolean;
},
): Promise {
const [issue, resource] = await Promise.all([
@@ -766,21 +818,28 @@ export async function linkExternalResourceToIssue(
const existingLink = await db.externalResourceLink.findUnique({
where: {
- issueId_externalResourceId_kind: {
+ issueId_externalResourceId: {
issueId: args.issueId,
externalResourceId: args.externalResourceId,
- kind: args.kind,
},
},
});
- if (existingLink) return existingLink;
+ if (existingLink?.kind === args.kind) return existingLink;
+ // SOURCE is the durable identity for a Forge issue imported from GitHub.
+ // Derived PR mentions must not erase the link used by future import de-dupe
+ // and source-only title/status synchronization.
+ if (existingLink?.kind === "SOURCE" && args.kind !== "SOURCE") return existingLink;
+ // Generic GitHub URL attachment and recovery paths use RELATES_TO. Replaying
+ // those paths must not erase a previously established native semantic link.
+ if (args.preserveExistingRelation && args.kind === "RELATES_TO" && existingLink) {
+ return existingLink;
+ }
const link = await db.externalResourceLink.upsert({
where: {
- issueId_externalResourceId_kind: {
+ issueId_externalResourceId: {
issueId: args.issueId,
externalResourceId: args.externalResourceId,
- kind: args.kind,
},
},
create: {
@@ -790,9 +849,35 @@ export async function linkExternalResourceToIssue(
kind: args.kind,
createdById: args.actor.actorId ?? undefined,
},
- update: {},
+ update: {
+ kind: args.kind,
+ ...(args.actor.actorId ? { createdById: args.actor.actorId } : {}),
+ },
});
+ const reclassifiedAwayFromImplementation =
+ existingLink !== null &&
+ IMPLEMENTATION_LINK_KINDS.some((kind) => kind === existingLink.kind) &&
+ !IMPLEMENTATION_LINK_KINDS.some((kind) => kind === link.kind);
+ if (reclassifiedAwayFromImplementation) {
+ const remainingImplementationLinks = await db.externalResourceLink.count({
+ where: {
+ workspaceId: args.workspaceId,
+ issueId: args.issueId,
+ kind: { in: [...IMPLEMENTATION_LINK_KINDS] },
+ externalResource: { resourceType: "PULL_REQUEST" },
+ },
+ });
+ if (remainingImplementationLinks === 0) {
+ await dismissIssueCompletionCandidate(db, {
+ workspaceId: args.workspaceId,
+ issueId: args.issueId,
+ actorId: args.actor.actorId,
+ resolution: "The linked pull request is no longer implementation evidence.",
+ });
+ }
+ }
+
if (args.recordActivity ?? true) {
await recordChange(db, {
workspaceId: args.workspaceId,
@@ -800,14 +885,15 @@ export async function linkExternalResourceToIssue(
actorAgentId: args.actor.actorAgentId ?? null,
entity: "Issue",
entityId: args.issueId,
- action: "external-resource.link",
+ action: existingLink ? "external-resource.relate" : "external-resource.link",
+ before: existingLink ?? undefined,
after: link,
eventKind: EventKind.ISSUE_UPDATED,
subjectType: "issue",
subjectId: args.issueId,
payload: {
source: "github",
- change: "external-resource-linked",
+ change: existingLink ? "external-resource-relation-updated" : "external-resource-linked",
externalResourceId: args.externalResourceId,
linkKind: args.kind,
resourceType: resource.resourceType,
@@ -831,6 +917,7 @@ export async function linkGitHubUrlToIssue(args: {
kind: string;
actor: ActorMeta;
mappingId?: string | null;
+ preserveExistingRelation?: boolean;
}): Promise<{ resource: ExternalResource; link: ExternalResourceLink }> {
const kind = args.kind;
assertLinkKind(kind);
@@ -860,6 +947,7 @@ export async function linkGitHubUrlToIssue(args: {
externalResourceId: resource.id,
kind,
actor: args.actor,
+ preserveExistingRelation: args.preserveExistingRelation,
});
return { resource, link };
});
@@ -958,6 +1046,7 @@ export async function recoverGenericGitHubAttachments(
externalResourceId: attachment.resourceId,
kind: "RELATES_TO",
actor: { actorId: null },
+ preserveExistingRelation: true,
});
await tx.attachment.deleteMany({
where: { id: attachment.id, workspaceId: attachment.workspaceId, kind: "LINK" },
@@ -1034,6 +1123,7 @@ export async function migrateGenericGitHubAttachments(
url: row.url,
kind: "RELATES_TO",
actor: { actorId: null },
+ preserveExistingRelation: true,
});
const deleted = await db.attachment.deleteMany({
where: {
@@ -1236,12 +1326,18 @@ export async function applyGitHubSnapshotToLinkedIssues(args: {
});
const changedIssueIds = new Set();
for (const link of links) {
+ const statusId =
+ args.snapshot.resourceType === "PULL_REQUEST" &&
+ link.kind !== "SOURCE" &&
+ !IMPLEMENTATION_LINK_KINDS.some((kind) => kind === link.kind)
+ ? null
+ : (args.statusRuleId ?? null);
const changed = await applyIssuePatchFromGitHub({
tx: args.tx,
workspaceId: args.workspaceId,
issueId: link.issueId,
title: config.syncTitle && link.kind === "SOURCE" ? args.snapshot.title : undefined,
- statusId: args.statusRuleId ?? null,
+ statusId,
actor: args.actor,
payload: {
source: "github",
diff --git a/src/server/services/github/types.ts b/src/server/services/github/types.ts
index df5c817f..d4de954d 100644
--- a/src/server/services/github/types.ts
+++ b/src/server/services/github/types.ts
@@ -5,9 +5,21 @@ export const GITHUB_PROVIDER = "GITHUB" as const;
export const GITHUB_RESOURCE_TYPES = ["ISSUE", "PULL_REQUEST"] as const;
export type GitHubResourceType = (typeof GITHUB_RESOURCE_TYPES)[number];
-export const EXTERNAL_LINK_KINDS = ["SOURCE", "IMPLEMENTS", "REVIEWS", "RELATES_TO"] as const;
+export const EXTERNAL_LINK_KINDS = [
+ "SOURCE",
+ "IMPLEMENTS",
+ "FIXES",
+ "RELEASES",
+ "REVIEWS",
+ "RELATES_TO",
+] as const;
export type ExternalLinkKind = (typeof EXTERNAL_LINK_KINDS)[number];
+export const IMPLEMENTATION_LINK_KINDS = [
+ "IMPLEMENTS",
+ "FIXES",
+] as const satisfies readonly ExternalLinkKind[];
+
export type GitHubRepoRef = {
owner: string;
repo: string;
diff --git a/src/server/services/github/webhook.ts b/src/server/services/github/webhook.ts
index 7b89671a..0c3e95fe 100644
--- a/src/server/services/github/webhook.ts
+++ b/src/server/services/github/webhook.ts
@@ -28,7 +28,12 @@ import {
upsertExternalResourceFromWebhook,
type ActorMeta,
} from "@/server/services/github/resource-sync";
-import { GITHUB_PROVIDER, type GitHubResourceSnapshot } from "@/server/services/github/types";
+import {
+ GITHUB_PROVIDER,
+ IMPLEMENTATION_LINK_KINDS,
+ type GitHubResourceSnapshot,
+} from "@/server/services/github/types";
+import { derivePullRequestIssueRelations } from "@/server/services/github/relation";
type GitHubWebhookRepository = {
full_name?: string;
@@ -239,19 +244,6 @@ export async function finishGitHubWebhookDelivery(args: {
return updated.count === 1;
}
-function forgeIssueKeys(text: string | null | undefined, workspaceKey: string): number[] {
- if (!text) return [];
- const escaped = workspaceKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const re = new RegExp(`\\b${escaped}-(\\d+)\\b`, "gi");
- const out = new Set();
- let match: RegExpExecArray | null;
- while ((match = re.exec(text))) {
- const n = Number(match[1]);
- if (Number.isInteger(n) && n > 0) out.add(n);
- }
- return [...out];
-}
-
async function ensureSourceIssueForSnapshot(args: {
db: PrismaClient;
workspaceId: string;
@@ -415,15 +407,18 @@ async function processPullRequestEvent(args: {
if (!persisted.applied) return 0;
const resource = persisted.resource;
- const keyNumbers = [
- ...forgeIssueKeys(args.payload.pull_request.title, args.mapping.workspace.key),
- ...forgeIssueKeys(args.payload.pull_request.body, args.mapping.workspace.key),
- ];
+ const relations = derivePullRequestIssueRelations({
+ workspaceKey: args.mapping.workspace.key,
+ title: args.payload.pull_request.title,
+ body: args.payload.pull_request.body,
+ headRef: args.payload.pull_request.head?.ref,
+ });
+ const keyNumbers = [...relations.keys()];
const issues =
keyNumbers.length > 0
? await args.db.issue.findMany({
where: { workspaceId, number: { in: keyNumbers }, deletedAt: null },
- select: { id: true },
+ select: { id: true, number: true },
})
: [];
@@ -450,7 +445,7 @@ async function processPullRequestEvent(args: {
workspaceId,
issueId: issue.id,
externalResourceId: resource.id,
- kind: "IMPLEMENTS",
+ kind: relations.get(issue.number) ?? "RELATES_TO",
actor: args.actor,
});
}
@@ -802,7 +797,11 @@ async function processCheckEvent(args: {
const changedIssueIds = new Set();
if (status) {
const links = await tx.externalResourceLink.findMany({
- where: { workspaceId, externalResourceId: resource.id },
+ where: {
+ workspaceId,
+ externalResourceId: resource.id,
+ kind: { in: ["SOURCE", ...IMPLEMENTATION_LINK_KINDS] },
+ },
select: { issueId: true },
});
for (const link of links) {
diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts
index a67d9cff..750fb311 100644
--- a/src/server/services/mcp.ts
+++ b/src/server/services/mcp.ts
@@ -4843,6 +4843,7 @@ export const mcpTools = {
issueId: input.targetId,
url: input.url,
kind: "RELATES_TO",
+ preserveExistingRelation: true,
actor: {
actorId,
actorAgentId: ctx.apiKey?.linkedAgentId ?? null,
@@ -8399,7 +8400,7 @@ export const mcpTools = {
return claimWorkSession(db, {
workspaceId: ctx.workspaceId,
...input,
- source: WorkSessionSource.FORGE_AGENT,
+ source: WorkSessionSource.MCP,
actor: { userId: ctx.userId, agentId, connectionId: ctx.connectionId ?? null },
});
},
diff --git a/src/server/services/work-session.ts b/src/server/services/work-session.ts
index 66cd32b8..446ecfae 100644
--- a/src/server/services/work-session.ts
+++ b/src/server/services/work-session.ts
@@ -15,6 +15,7 @@ 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";
+import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types";
type DbClient = PrismaClient | Prisma.TransactionClient;
@@ -458,63 +459,55 @@ 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 latestImplementationRun = await db.agentRun.findFirst({
+ where: {
+ workspaceId,
+ issueId,
+ engagementMode: "EXECUTE",
+ outputStartedAt: { not: null },
+ },
+ orderBy: [{ lastEventAt: "desc" }, { startedAt: "desc" }],
+ select: {
+ id: true,
+ status: true,
+ startedAt: true,
+ lastEventAt: true,
+ externalRunId: true,
+ triggerKind: true,
+ runEngine: true,
+ runEngineSource: true,
+ runtimePolicy: true,
+ agent: { select: { id: true, name: true, profileKey: true, avatar: true } },
+ connection: {
+ select: {
+ id: true,
+ kind: true,
+ displayName: true,
+ clientName: true,
+ clientVersion: true,
+ runtime: { select: { id: true, name: true, adapterKey: true } },
},
},
- }),
- ]);
+ },
+ });
const observedImplementation = latestImplementationRun
? {
agent: latestImplementationRun.agent,
observedAt: latestImplementationRun.lastEventAt,
source: "implementation-run" as const,
+ run: {
+ id: latestImplementationRun.id,
+ status: latestImplementationRun.status,
+ startedAt: latestImplementationRun.startedAt,
+ externalRunId: latestImplementationRun.externalRunId,
+ triggerKind: latestImplementationRun.triggerKind,
+ runEngine: latestImplementationRun.runEngine,
+ runEngineSource: latestImplementationRun.runEngineSource,
+ runtimePolicy: latestImplementationRun.runtimePolicy,
+ connection: latestImplementationRun.connection,
+ },
}
- : latestAgentComment?.authoringAgent
- ? {
- agent: latestAgentComment.authoringAgent,
- observedAt: latestAgentComment.updatedAt,
- source: "agent-comment" as const,
- }
- : null;
+ : null;
// Auto-bind an IMPLEMENTS PR whose head branch matches the session. This is
// intentionally a repair path: explicit linking remains preferred.
@@ -526,7 +519,7 @@ export async function listIssueWorkSessions(
where: {
workspaceId,
issueId,
- kind: "IMPLEMENTS",
+ kind: { in: [...IMPLEMENTATION_LINK_KINDS] },
externalResource: { resourceType: "PULL_REQUEST" },
},
include: { externalResource: true },
diff --git a/tests/e2e/issue-flow.spec.ts b/tests/e2e/issue-flow.spec.ts
index a8b56cdb..98acd711 100644
--- a/tests/e2e/issue-flow.spec.ts
+++ b/tests/e2e/issue-flow.spec.ts
@@ -55,6 +55,27 @@ test.describe("Issue flow", () => {
await page.goto(new URL(page.url()).pathname);
await expect(activityTab).toHaveAttribute("aria-selected", "true");
+ // A browser claim is a manual UI invocation. It must not be guessed as a
+ // Codex Desktop/MCP connection or as runtime execution.
+ const delivery = page.getByRole("region", { name: "Code work coordination" });
+ await delivery.getByRole("button", { name: "Start isolated work" }).click();
+ await delivery.getByLabel("Repository").fill("Codename-11/Forge");
+ await delivery
+ .getByRole("textbox", { name: "Branch" })
+ .fill(`codex/e2e-delivery-${Date.now()}`);
+ await delivery.getByRole("button", { name: "Claim work" }).click();
+ await expect(delivery.locator("span").filter({ hasText: /^Manual UI$/ })).toBeVisible();
+ await expect(delivery.getByText("MCP · Codex Desktop", { exact: true })).toHaveCount(0);
+ await expect(delivery.getByText("provenance not registered", { exact: true })).toHaveAttribute(
+ "title",
+ "No concrete client or runtime connection is attached to this delivery session.",
+ );
+ await delivery.getByText("Delivery evidence", { exact: true }).click();
+ await expect(delivery.locator("dt").filter({ hasText: /^Agent$/ })).toBeVisible();
+ await expect(delivery.locator("dt").filter({ hasText: /^Operator$/ })).toBeVisible();
+ await expect(delivery.locator("dt").filter({ hasText: /^Actor$/ })).toHaveCount(0);
+ await expect(delivery.getByText("no dispatched run recorded", { exact: true })).toBeVisible();
+
// Move status on the detail page and confirm it sticks.
const status = page.getByRole("combobox", { name: "Status" });
await status.click();
diff --git a/tests/unit/activity-grouping.test.ts b/tests/unit/activity-grouping.test.ts
new file mode 100644
index 00000000..90ce8ada
--- /dev/null
+++ b/tests/unit/activity-grouping.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { groupConsecutive } from "@/lib/activity-grouping";
+
+describe("groupConsecutive", () => {
+ it("collapses adjacent equivalent rows while retaining their bounds", () => {
+ const rows = [
+ { id: "newest", key: "title" },
+ { id: "middle", key: "title" },
+ { id: "oldest", key: "title" },
+ ];
+
+ expect(groupConsecutive(rows, (row) => row.key)).toEqual([
+ { newest: rows[0], oldest: rows[2], count: 3 },
+ ]);
+ });
+
+ it("does not merge matching rows separated by a different event", () => {
+ const rows = [
+ { id: "one", key: "title" },
+ { id: "two", key: "status" },
+ { id: "three", key: "title" },
+ ];
+
+ expect(groupConsecutive(rows, (row) => row.key).map((group) => group.count)).toEqual([1, 1, 1]);
+ });
+});
diff --git a/tests/unit/delivery-identity.test.ts b/tests/unit/delivery-identity.test.ts
new file mode 100644
index 00000000..ecc2edd2
--- /dev/null
+++ b/tests/unit/delivery-identity.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import { resolveDeliveryIdentity } from "@/lib/delivery-identity";
+
+describe("resolveDeliveryIdentity", () => {
+ it("leads with the registered agent and keeps the operator as context", () => {
+ expect(
+ resolveDeliveryIdentity({
+ source: "FORGE_AGENT",
+ ownerUser: { name: "Bailey" },
+ ownerAgent: { name: "Codex", profileKey: "codex" },
+ }),
+ ).toEqual({
+ agentLabel: "Codex · @codex",
+ operatorLabel: "Bailey",
+ primaryLabel: "Codex · @codex",
+ summary: "Agent · Operator Bailey",
+ });
+ });
+
+ it("uses a registered connection agent when the legacy owner field is absent", () => {
+ expect(
+ resolveDeliveryIdentity({
+ source: "MCP",
+ ownerUser: { name: "Bailey" },
+ ownerConnection: { agent: { name: "Codex", profileKey: "codex" } },
+ }).agentLabel,
+ ).toBe("Codex · @codex");
+ });
+
+ it("does not attribute an unregistered desktop client to its operator", () => {
+ expect(
+ resolveDeliveryIdentity({
+ source: "CODEX_DESKTOP",
+ ownerUser: { name: "Bailey" },
+ }),
+ ).toEqual({
+ agentLabel: "Unregistered Codex Desktop client",
+ operatorLabel: "Bailey",
+ primaryLabel: "Unregistered Codex Desktop client",
+ summary: "Agent identity unverified · Operator Bailey",
+ });
+ });
+
+ it("marks an unregistered MCP client without inventing a profile", () => {
+ expect(resolveDeliveryIdentity({ source: "MCP" })).toMatchObject({
+ agentLabel: "Unregistered MCP client",
+ operatorLabel: null,
+ summary: "Agent identity unverified",
+ });
+ });
+
+ it("shows a manual human claim as operator-owned work", () => {
+ expect(resolveDeliveryIdentity({ source: "MANUAL", ownerUser: { name: "Bailey" } })).toEqual({
+ agentLabel: null,
+ operatorLabel: "Bailey",
+ primaryLabel: "Bailey",
+ summary: "Operator",
+ });
+ });
+
+ it("keeps an unidentified contributor session unassigned", () => {
+ expect(resolveDeliveryIdentity({ source: "CONTRIBUTOR" })).toEqual({
+ agentLabel: null,
+ operatorLabel: null,
+ primaryLabel: "Unassigned",
+ summary: null,
+ });
+ });
+});
diff --git a/tests/unit/github-relation.test.ts b/tests/unit/github-relation.test.ts
new file mode 100644
index 00000000..5cb1afaf
--- /dev/null
+++ b/tests/unit/github-relation.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import {
+ derivePullRequestIssueRelations,
+ isReleasePullRequest,
+} from "@/server/services/github/relation";
+
+describe("GitHub native relation derivation", () => {
+ it("distinguishes implementation, closing, and bare references", () => {
+ const relations = derivePullRequestIssueRelations({
+ workspaceKey: "AXI",
+ title: "Improve delivery cards (AXI-117)",
+ body: "Related context AXI-63. Implements AXI-117. Closes AXI-118. Close AXI-119. Resolve AXI-120.",
+ });
+ expect(Object.fromEntries(relations)).toEqual({
+ 63: "RELATES_TO",
+ 117: "IMPLEMENTS",
+ 118: "FIXES",
+ 119: "FIXES",
+ 120: "FIXES",
+ });
+ });
+
+ it("treats every issue reference in release assembly as containment", () => {
+ const relations = derivePullRequestIssueRelations({
+ workspaceKey: "AXI",
+ title: "Release v0.28.0 — delivery truth",
+ body: "Includes #72 for AXI-117 and fixes from AXI-118.",
+ headRef: "codex/release-v0.28.0",
+ });
+ expect([...relations.entries()]).toEqual([
+ [117, "RELEASES"],
+ [118, "RELEASES"],
+ ]);
+ });
+
+ it("applies one relation keyword to grouped issue references", () => {
+ const relations = derivePullRequestIssueRelations({
+ workspaceKey: "AXI",
+ title: "Fixes AXI-1, AXI-2 and AXI-3",
+ body: "Implements AXI-4, AXI-5 & AXI-6. Related AXI-7, context for AXI-8.",
+ });
+ expect(Object.fromEntries(relations)).toEqual({
+ 1: "FIXES",
+ 2: "FIXES",
+ 3: "FIXES",
+ 4: "IMPLEMENTS",
+ 5: "IMPLEMENTS",
+ 6: "IMPLEMENTS",
+ 7: "RELATES_TO",
+ 8: "RELATES_TO",
+ });
+ });
+
+ it("recognizes release branches even when the title is customized", () => {
+ expect(isReleasePullRequest({ title: "July delivery", headRef: "release/v0.28.0" })).toBe(true);
+ });
+});
diff --git a/tests/unit/markdown-url-safety.test.ts b/tests/unit/markdown-url-safety.test.ts
index b629dde3..608d8888 100644
--- a/tests/unit/markdown-url-safety.test.ts
+++ b/tests/unit/markdown-url-safety.test.ts
@@ -26,6 +26,32 @@ describe("MarkdownWithAttachments URL safety", () => {
expect(html).not.toContain('target="_blank"');
});
+ it("renders the complete compact GitHub reference instead of splitting at the slash", () => {
+ const html = renderMarkdown("Merged PR CODENAME-11/Forge#56 recommends completion.");
+
+ expect(html).toContain('href="https://github.com/CODENAME-11/Forge/issues/56"');
+ expect(html).toContain(">CODENAME-11/Forge#56");
+ expect(html).not.toContain("/issues/CODENAME-11");
+ });
+
+ it("keeps issue-looking tokens inside bare URLs clickable as one URL", () => {
+ const url = "https://forge.example/w/acme/issues/AXI-123";
+ const html = renderMarkdown(`Inspect ${url} before closing.`);
+
+ expect(html).toContain(`href="${url}"`);
+ expect(html).toContain(`>${url}`);
+ expect(html).not.toContain('href="/issues/AXI-123"');
+ });
+
+ it("keeps compact GitHub-looking fragments inside bare URLs as one URL", () => {
+ const url = "https://example.com/CODENAME-11/Forge#56";
+ const html = renderMarkdown(`Inspect ${url} before closing.`);
+
+ expect(html).toContain(`href="${url}"`);
+ expect(html).toContain(`>${url}`);
+ expect(html).not.toContain('href="https://github.com/CODENAME-11/Forge/issues/56"');
+ });
+
it("does not render javascript or data markdown links as clickable hrefs", () => {
const jsHtml = renderMarkdown("[bad](javascript:alert(1))");
const dataHtml = renderMarkdown("[bad](data:text/html,x )");