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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13227,3 +13227,30 @@ fresh production E2E build succeeded; and both new Playwright journeys passed.
The unconfigured full Vitest command could not run integration tests because
this shell did not provide `DATABASE_URL` or `AUTH_SECRET`; its focused,
dependency-free coverage passed separately.

---

## 2026-07-15 — MCP delivery timeline contract

Closed the gap between dispatched AgentRun guidance and ordinary MCP code-work
sessions. The native Streamable HTTP handshake now teaches agents to claim and
heartbeat delivery sessions, keep mechanical traces out of comments, use
rolling status comments for meaningful phase changes, and leave one durable
implementation/PR/validation handoff on the issue.

Added a settings-driven `DeliveryTimelinePolicy` with Off, Recommend, Require
on PR, and Auto on PR modes. `workSessions.attachPullRequest` accepts an
optional `timelineUpdate`; strict workspaces reject missing updates, automatic
workspaces synthesize a concise agent-authored PR handoff, and recommended
workspaces return an explicit `comments.create` next action plus a suggested
template. PR attachment, comment creation, watcher enrollment, audit/event
fan-out, and delivery-state advancement occur atomically. Reattaching the same
PR is idempotent and cannot duplicate the automatic comment. The settings UI
explains each policy, and MCP tool descriptions put the human-facing contract
at the relevant decision points.

Verification: Prisma schema validation, typecheck, and lint passed (existing
repository warnings only); the focused work-session suite passed 8 tests; the
full Vitest gate passed 1,316 tests with one intentional live skip; the fresh
production build succeeded; and the focused settings Playwright journey
passed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TYPE "DeliveryTimelinePolicy" AS ENUM ('OFF', 'RECOMMEND', 'REQUIRE_ON_PR', 'AUTO_ON_PR');

ALTER TABLE "Workspace"
ADD COLUMN "deliveryTimelinePolicy" "DeliveryTimelinePolicy" NOT NULL DEFAULT 'RECOMMEND';
12 changes: 12 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ enum CompletionAutomation {
AUTO_WHEN_SAFE
}

/// Controls whether code-delivery sessions publish a human-readable issue
/// update when an implementation pull request is attached.
enum DeliveryTimelinePolicy {
OFF
RECOMMEND
REQUIRE_ON_PR
AUTO_ON_PR
}

enum ScheduledTaskAction {
CREATE_ISSUE
}
Expand Down Expand Up @@ -951,6 +960,9 @@ model Workspace {
/// verified completion evidence. RECOMMEND opens one shared actionable
/// card; AUTO_WHEN_SAFE transitions only when the central safety gate passes.
completionAutomation CompletionAutomation @default(RECOMMEND)
/// Human-readable issue timeline behavior when a work session attaches an
/// implementation PR. Mechanical heartbeats remain audit-only.
deliveryTimelinePolicy DeliveryTimelinePolicy @default(RECOMMEND)
/// DONE-category status used by completion recommendations/automation.
/// Null keeps completion candidates disabled until an admin chooses one.
completionStatusId String?
Expand Down
48 changes: 48 additions & 0 deletions src/app/(app)/w/[slug]/settings/workspace/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { workspaceColor } from "@/lib/workspace-color";

type DefaultIssueAssigneeMode = "NONE" | "CREATOR" | "USER";
type CompletionAutomation = "OFF" | "RECOMMEND" | "AUTO_WHEN_SAFE";
type DeliveryTimelinePolicy = "OFF" | "RECOMMEND" | "REQUIRE_ON_PR" | "AUTO_ON_PR";

export default function WorkspaceSettingsPage() {
const router = useRouter();
Expand Down Expand Up @@ -63,6 +64,8 @@ export default function WorkspaceSettingsPage() {
const [completionAutomation, setCompletionAutomation] =
useState<CompletionAutomation>("RECOMMEND");
const [completionStatusId, setCompletionStatusId] = useState<string | null>(null);
const [deliveryTimelinePolicy, setDeliveryTimelinePolicy] =
useState<DeliveryTimelinePolicy>("RECOMMEND");
const [defaultIssueAssigneeMode, setDefaultIssueAssigneeMode] =
useState<DefaultIssueAssigneeMode>("NONE");
const [defaultIssueAssigneeUserId, setDefaultIssueAssigneeUserId] = useState<string | null>(null);
Expand Down Expand Up @@ -111,6 +114,7 @@ export default function WorkspaceSettingsPage() {
setReviewStatusId(current.reviewStatusId ?? null);
setCompletionAutomation(current.completionAutomation ?? "RECOMMEND");
setCompletionStatusId(current.completionStatusId ?? null);
setDeliveryTimelinePolicy(current.deliveryTimelinePolicy ?? "RECOMMEND");
setDefaultIssueAssigneeMode(
(current.defaultIssueAssigneeMode ?? "NONE") as DefaultIssueAssigneeMode,
);
Expand Down Expand Up @@ -208,6 +212,10 @@ export default function WorkspaceSettingsPage() {
completionAutomation !== (current.completionAutomation ?? "RECOMMEND"),
],
["completionStatusId", (completionStatusId ?? null) !== (current.completionStatusId ?? null)],
[
"deliveryTimelinePolicy",
deliveryTimelinePolicy !== (current.deliveryTimelinePolicy ?? "RECOMMEND"),
],
[
"defaultIssueAssigneeMode",
defaultIssueAssigneeMode !==
Expand Down Expand Up @@ -255,6 +263,7 @@ export default function WorkspaceSettingsPage() {
reviewStatusId,
completionAutomation,
completionStatusId,
deliveryTimelinePolicy,
defaultIssueAssigneeMode,
normalizedDefaultIssueAssigneeUserId,
]);
Expand Down Expand Up @@ -302,6 +311,7 @@ export default function WorkspaceSettingsPage() {
reviewStatusId,
completionAutomation,
completionStatusId,
deliveryTimelinePolicy,
defaultIssueAssigneeMode,
defaultIssueAssigneeUserId: normalizedDefaultIssueAssigneeUserId,
});
Expand Down Expand Up @@ -340,6 +350,7 @@ export default function WorkspaceSettingsPage() {
reviewStatusId,
completionAutomation,
completionStatusId,
deliveryTimelinePolicy,
defaultIssueAssigneeMode,
defaultIssueAssigneeUserId,
normalizedDefaultIssueAssigneeUserId,
Expand Down Expand Up @@ -864,6 +875,43 @@ export default function WorkspaceSettingsPage() {
</FormCard>
</Section>

<Section
title="Code delivery timeline"
hint="Keep issue timelines understandable when agents and contributors open implementation pull requests. Mechanical heartbeats stay in the delivery record instead of becoming comments."
>
<FormCard className="space-y-4 p-5">
<Field
label="PR handoff comment"
hint="Agents can include a concise implementation, validation, and caveat summary while attaching the pull request."
>
<Combobox
ariaLabel="PR handoff comment policy"
value={deliveryTimelinePolicy}
onChange={(value) =>
setDeliveryTimelinePolicy((value ?? "RECOMMEND") as DeliveryTimelinePolicy)
}
disabled={!canEdit}
options={[
{ value: "OFF", label: "Off — no delivery prompt" },
{ value: "RECOMMEND", label: "Recommend a handoff comment" },
{ value: "REQUIRE_ON_PR", label: "Require a comment when attaching a PR" },
{ value: "AUTO_ON_PR", label: "Automatically post a concise PR comment" },
]}
/>
</Field>
<div className="text-meta rounded-md border border-border bg-background/40 px-3 py-2 text-muted-foreground">
{deliveryTimelinePolicy === "OFF" &&
"Forge records the PR and delivery state without prompting for an issue comment."}
{deliveryTimelinePolicy === "RECOMMEND" &&
"Forge returns a suggested next action and comment template after the PR is attached."}
{deliveryTimelinePolicy === "REQUIRE_ON_PR" &&
"PR attachment is rejected unless the caller supplies a human-readable timeline update in the same request."}
{deliveryTimelinePolicy === "AUTO_ON_PR" &&
"Forge posts one agent-authored PR handoff automatically when the caller does not provide a richer update."}
</div>
</FormCard>
</Section>

<Section
title="AI provider"
hint="Forge-internal AI features. Calls the chosen provider directly; no cross-system data sharing. Off by default."
Expand Down
27 changes: 19 additions & 8 deletions src/app/api/mcp/rpc/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { authenticateApiKey, ApiKeyError } from "@/server/services/api-key-auth"
import { rateLimit } from "@/server/rate-limit";
import { logger } from "@/server/logger";
import { mcpServerInfo } from "@/server/build-info";
import { FORGE_MCP_INSTRUCTIONS } from "@/server/services/mcp-instructions";

/**
* Standard MCP (Model Context Protocol) endpoint — Streamable HTTP transport
Expand Down Expand Up @@ -190,19 +191,26 @@ function decodeCursor(cursor: string): number | null {
}
}

function listCursor(params: unknown): { ok: true; offset: number } | { ok: false; message: string } {
function listCursor(
params: unknown,
): { ok: true; offset: number } | { ok: false; message: string } {
if (params == null) return { ok: true, offset: 0 };
if (typeof params !== "object" || Array.isArray(params)) {
return { ok: false, message: "tools/list params must be an object." };
}
const cursor = (params as { cursor?: unknown }).cursor;
if (cursor == null) return { ok: true, offset: 0 };
if (typeof cursor !== "string") return { ok: false, message: "tools/list cursor must be a string." };
if (typeof cursor !== "string")
return { ok: false, message: "tools/list cursor must be a string." };
const offset = decodeCursor(cursor);
return offset == null ? { ok: false, message: "Invalid tools/list cursor." } : { ok: true, offset };
return offset == null
? { ok: false, message: "Invalid tools/list cursor." }
: { ok: true, offset };
}

function allowedMcpToolNames(auth: Awaited<ReturnType<typeof authenticateApiKey>> | null): McpToolName[] {
function allowedMcpToolNames(
auth: Awaited<ReturnType<typeof authenticateApiKey>> | null,
): McpToolName[] {
return selectMcpToolNames({
profile: "full",
scopes: auth?.scopes ?? null,
Expand Down Expand Up @@ -242,7 +250,8 @@ async function handleCatalogTool(
.filter((toolName) => {
if (namespace && mcpToolNamespace(toolName) !== namespace) return false;
if (!query) return true;
const haystack = `${toolName} ${compactDescription(toolName)} ${mcpTools[toolName].scopes.join(" ")}`.toLowerCase();
const haystack =
`${toolName} ${compactDescription(toolName)} ${mcpTools[toolName].scopes.join(" ")}`.toLowerCase();
return haystack.includes(query);
})
.slice(0, input.limit)
Expand Down Expand Up @@ -308,8 +317,7 @@ async function handleRpc(
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: await mcpServerInfo(),
instructions:
"Forge - project management. Tools cover issues, projects, comments, analytics, and the agent queue.",
instructions: FORGE_MCP_INSTRUCTIONS,
});

case "notifications/initialized":
Expand Down Expand Up @@ -445,7 +453,10 @@ export async function POST(req: NextRequest) {
const listOptions: ListOptions = {
profile: profileParam,
namespaces: toolsParam
? toolsParam.split(",").map((s) => s.trim()).filter(Boolean)
? toolsParam
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null,
};

Expand Down
8 changes: 7 additions & 1 deletion src/server/routers/work-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,13 @@ export const workSessionRouter = router({
}),

attachPullRequest: workspaceProcedure
.input(z.object({ sessionId: z.string().cuid(), externalResourceId: z.string().cuid() }))
.input(
z.object({
sessionId: z.string().cuid(),
externalResourceId: z.string().cuid(),
timelineUpdate: z.object({ body: z.string().trim().min(1).max(50_000) }).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await assertSessionManager(ctx, input.sessionId);
return attachPullRequest(ctx.db, {
Expand Down
3 changes: 3 additions & 0 deletions src/server/routers/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CompletionAutomation,
CycleStatus,
DefaultIssueAssigneeMode,
DeliveryTimelinePolicy,
EngagementMode,
EventKind,
MentionEngagementPolicy,
Expand Down Expand Up @@ -138,6 +139,7 @@ export const workspaceRouter = router({
startedStatusId: true,
reviewStatusId: true,
completionAutomation: true,
deliveryTimelinePolicy: true,
completionStatusId: true,
githubSyncEnabled: true,
githubSyncStaleMinutes: true,
Expand Down Expand Up @@ -319,6 +321,7 @@ export const workspaceRouter = router({
startedStatusId: z.string().nullable().optional(),
reviewStatusId: z.string().nullable().optional(),
completionAutomation: z.nativeEnum(CompletionAutomation).optional(),
deliveryTimelinePolicy: z.nativeEnum(DeliveryTimelinePolicy).optional(),
completionStatusId: z.string().nullable().optional(),
githubSyncEnabled: z.boolean().optional(),
githubSyncStaleMinutes: z.number().int().min(0).max(10080).optional(),
Expand Down
Loading
Loading