From 7418af7aa52aad3dd1e8ac3b188a4ff79f5a94ab Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 14 Jul 2026 15:46:48 -0400 Subject: [PATCH] feat: add proactive completion handoffs --- CHANGELOG.md | 16 + DEVLOG.md | 37 ++ package.json | 2 +- .../0101_completion_candidates/migration.sql | 35 ++ prisma/schema.prisma | 68 +- .../(app)/w/[slug]/command-center/page.tsx | 34 +- src/app/(app)/w/[slug]/projects/[id]/page.tsx | 535 +++++++--------- .../w/[slug]/settings/workspace/page.tsx | 288 +++++---- .../action-requests/action-request-card.tsx | 293 +++++++-- src/components/dashboard/needs-you-tile.tsx | 60 +- .../issue-detail/github-links-panel.tsx | 2 +- src/server/routers/project.ts | 11 +- src/server/routers/workspace.ts | 74 ++- .../__tests__/completion-candidate.test.ts | 232 +++++++ src/server/services/__tests__/mcp.test.ts | 58 ++ src/server/services/action-request-service.ts | 153 ++++- src/server/services/agent-dispatch-inbox.ts | 11 +- src/server/services/completion-candidate.ts | 582 ++++++++++++++++++ src/server/services/engagement-mode.ts | 2 + src/server/services/github/resource-sync.ts | 74 ++- src/server/services/github/webhook.ts | 154 ++++- src/server/services/mcp.ts | 55 +- src/server/worker.ts | 23 + tests/unit/github-check-conclusion.test.ts | 41 ++ 24 files changed, 2198 insertions(+), 642 deletions(-) create mode 100644 prisma/migrations/0101_completion_candidates/migration.sql create mode 100644 src/server/services/__tests__/completion-candidate.test.ts create mode 100644 src/server/services/completion-candidate.ts create mode 100644 tests/unit/github-check-conclusion.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be7b459..46772935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. ## [Unreleased] +## [2026-07-14] — v0.14.0 · Proactive completion handoff + +### Added + +- **Verified work can now become one actionable close-out decision.** EXECUTE agents can explicitly recommend issue completion, while merged implementation PRs and completed GitHub checks feed the same idempotent evidence card in issue detail, Command Center, Dashboard, Inbox, and notifications. +- **Completion behavior is policy-driven.** Workspaces can disable, recommend, or safely automate completion against a chosen DONE status, and projects can inherit or override that policy. + +### Changed + +- **Automatic completion is guarded centrally.** Live runs, pending review gates, unresolved decisions, blocking issues, unmerged implementation PRs, and unconfirmed checks hold automation and remain visible as evidence instead of silently closing work. +- **Closed, unmerged PRs produce a recovery decision.** Review-stage work can return to the configured active status or link a replacement PR from the native GitHub panel. + +### Fixed + +- **Repeated lifecycle signals no longer stack cards or notifications.** Producer-owned dedupe keys refresh one durable request, identical refreshes are no-ops, and a periodic reconciler repairs missed transitions and completes newly-unblocked safe candidates. + ## [2026-07-14] — v0.13.1 · Reliable agent wake boundaries ### Changed diff --git a/DEVLOG.md b/DEVLOG.md index fabbd5a6..63ff2145 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -12764,3 +12764,40 @@ build passed; and all 38 Playwright journeys passed serially. The canonical gate first encountered the known cross-suite stale-work redispatch race; that 9-test file passed immediately in isolation and the full suite then passed cleanly. + +--- + +## 2026-07-14 — Proactive issue completion and PR recovery + +Added a settings-driven completion policy with workspace OFF, RECOMMEND, and +AUTO_WHEN_SAFE modes, a configurable DONE status, and optional project-level +overrides. Verified EXECUTE runs may now explicitly recommend the issue itself +for completion. Native GitHub implementation links, manual sync, PR webhooks, +and completed check-suite outcomes converge on the same evaluator. + +The evaluator blocks automatic completion while live runs, review gates, +unresolved decisions, blockers, unmerged PRs, or unconfirmed checks remain. +It emits one durable TRANSITION ActionRequest with compact evidence and clear +Mark done / Keep in review actions, or transitions atomically when the safety +gate is clear. Closed unmerged PRs create a matching recovery decision to +return review-stage work to the configured active status or link a replacement +PR. Dashboard and shared action-request surfaces show animated completion or +recovery state without expanding cards by default. + +Added producer-owned ActionRequest dedupe keys with a database partial unique +index. Changed repeated identical refreshes into true no-ops so duplicate +webhook deliveries cannot create activity or notification spam. A five-minute +worker reconciliation pass repairs missed event handoffs and re-evaluates +AUTO_WHEN_SAFE cards after held dependencies clear. + +CI also exposed a same-millisecond chat ordering edge case: an agent reply +could remain in the durable inbox when its timestamp tied the preceding user +message. Inbox suppression now uses the canonical timestamp-plus-id ordering, +with a deterministic regression test. + +Verification: focused completion and MCP suites passed 133 tests; the full +Vitest suite passed 1,244 tests with 1 intentional live-connector skip; +typecheck and lint passed with only pre-existing repository warnings; and a +fresh production build passed. The full serial Playwright run passed 36 of 38 +journeys; its shared-state chat and Command Center failures both passed against +a clean disposable E2E database immediately afterward. diff --git a/package.json b/package.json index da419985..c43a6f4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.13.1", + "version": "0.14.0", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", diff --git a/prisma/migrations/0101_completion_candidates/migration.sql b/prisma/migrations/0101_completion_candidates/migration.sql new file mode 100644 index 00000000..ef5cdeab --- /dev/null +++ b/prisma/migrations/0101_completion_candidates/migration.sql @@ -0,0 +1,35 @@ +-- Settings-driven completion automation and durable shared candidate dedupe. +CREATE TYPE "CompletionAutomation" AS ENUM ('OFF', 'RECOMMEND', 'AUTO_WHEN_SAFE'); + +ALTER TABLE "Workspace" + ADD COLUMN "completionAutomation" "CompletionAutomation" NOT NULL DEFAULT 'RECOMMEND', + ADD COLUMN "completionStatusId" TEXT; + +ALTER TABLE "Project" + ADD COLUMN "completionAutomation" "CompletionAutomation"; + +ALTER TABLE "ActionRequest" + ADD COLUMN "dedupeKey" TEXT; + +ALTER TABLE "Workspace" + ADD CONSTRAINT "Workspace_completionStatusId_fkey" + FOREIGN KEY ("completionStatusId") REFERENCES "Status"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "ActionRequest_workspaceId_dedupeKey_idx" + ON "ActionRequest"("workspaceId", "dedupeKey"); + +CREATE UNIQUE INDEX "ActionRequest_open_generic_dedupe" + ON "ActionRequest"("workspaceId", "dedupeKey") + WHERE "status" = 'OPEN' AND "dedupeKey" IS NOT NULL; + +-- Existing workspaces already have at least one DONE status. Choose the first +-- configured position as the safe default; admins can change or clear it. +UPDATE "Workspace" w +SET "completionStatusId" = ( + SELECT s."id" + FROM "Status" s + WHERE s."workspaceId" = w."id" AND s."category" = 'DONE' + ORDER BY s."position" ASC, s."id" ASC + LIMIT 1 +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0aa08cf..98d0c570 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -203,6 +203,14 @@ enum RunBudgetAction { STOP } +/// Controls how verified completion evidence affects an open issue. +/// Project-level null inherits the workspace value. +enum CompletionAutomation { + OFF + RECOMMEND + AUTO_WHEN_SAFE +} + enum ChatRole { USER AGENT @@ -874,6 +882,13 @@ model Workspace { /// work should surface for review regardless of prior state. Null /// disables — no auto-transition on completion. reviewStatusId String? + /// What Forge should do when an agent or linked implementation PR provides + /// verified completion evidence. RECOMMEND opens one shared actionable + /// card; AUTO_WHEN_SAFE transitions only when the central safety gate passes. + completionAutomation CompletionAutomation @default(RECOMMEND) + /// DONE-category status used by completion recommendations/automation. + /// Null keeps completion candidates disabled until an admin chooses one. + completionStatusId String? /// Issues with `updatedAt` older than this threshold (in days) are /// considered "stalled" by the dashboard suggestions bucket. Settings- /// driven so admins can tune the workspace's idle definition without @@ -968,6 +983,7 @@ model Workspace { canvasStyles CanvasStyle[] startedStatus Status? @relation("WorkspaceStartedStatus", fields: [startedStatusId], references: [id], onDelete: SetNull) reviewStatus Status? @relation("WorkspaceReviewStatus", fields: [reviewStatusId], references: [id], onDelete: SetNull) + completionStatus Status? @relation("WorkspaceCompletionStatus", fields: [completionStatusId], references: [id], onDelete: SetNull) @@index([slug]) @@index([defaultIssueAssigneeUserId]) @@ -997,22 +1013,22 @@ model Membership { // ---------------------------------------------------------------------------- model Project { - id String @id @default(cuid()) - workspaceId String - key String - name String - description String? @db.Text - icon String? - color String? - archived Boolean @default(false) - startDate DateTime? - targetDate DateTime? + id String @id @default(cuid()) + workspaceId String + key String + name String + description String? @db.Text + icon String? + color String? + archived Boolean @default(false) + startDate DateTime? + targetDate DateTime? /// Optional parent initiative that groups related projects. - initiativeId String? - createdById String + initiativeId String? + createdById String /// Backlink for a project created via `notes.promote`. Mirrors /// `Issue.sourceNoteId`. - sourceNoteId String? + sourceNoteId String? /// Git repository this project's work lives in. When set, a runtime /// provisions (clone-or-pulls) it so an agent dispatched to one of this /// project's issues lands in the right checkout — letting one runtime serve @@ -1020,11 +1036,14 @@ model Project { /// `repoBranch` null = the remote's default branch. The clone path is /// derived from the repo name. Auth comes from the runtime's GitHub App or /// secrets (see `docs/agents/runtime-credentials.md`). - repoUrl String? - repoBranch String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + repoUrl String? + repoBranch String? + /// Optional override for issue completion automation. Null inherits the + /// workspace policy so projects can opt into stricter or quieter behavior. + completionAutomation CompletionAutomation? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) createdBy User @relation("ProjectCreator", fields: [createdById], references: [id]) @@ -1051,10 +1070,11 @@ model Status { position Int isDefault Boolean @default(false) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - issues Issue[] - workspaceStartedFor Workspace[] @relation("WorkspaceStartedStatus") - workspaceReviewFor Workspace[] @relation("WorkspaceReviewStatus") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + issues Issue[] + workspaceStartedFor Workspace[] @relation("WorkspaceStartedStatus") + workspaceReviewFor Workspace[] @relation("WorkspaceReviewStatus") + workspaceCompletionFor Workspace[] @relation("WorkspaceCompletionStatus") @@unique([workspaceId, name]) @@index([workspaceId, category]) @@ -3475,6 +3495,9 @@ model ActionRequest { /// Polymorphic source — chat-message, comment, agent-run, etc. sourceType String? sourceId String? + /// Stable producer-owned key for idempotent shared recommendations. A + /// partial unique index permits at most one OPEN request per workspace/key. + dedupeKey String? /// Optional pointer at the issue this request relates to. Lets the /// inbox surface group requests under their parent issue without /// re-deriving from sourceType. @@ -3507,6 +3530,7 @@ model ActionRequest { @@index([assignedAgentId, status]) @@index([issueId]) @@index([sourceType, sourceId]) + @@index([workspaceId, dedupeKey]) @@index([supersededById]) } diff --git a/src/app/(app)/w/[slug]/command-center/page.tsx b/src/app/(app)/w/[slug]/command-center/page.tsx index b49d7258..e446659b 100644 --- a/src/app/(app)/w/[slug]/command-center/page.tsx +++ b/src/app/(app)/w/[slug]/command-center/page.tsx @@ -29,6 +29,7 @@ import { Picker, useConfirm } from "@/components/ui/modal"; import { EmptyState, SkeletonList } from "@/components/ui"; import { AgentAttentionPanel } from "@/components/agent-attention-panel"; import { RunApprovalCard } from "@/components/agents/run-approval-card"; +import { ActionRequestCard } from "@/components/action-requests/action-request-card"; import { WorkspaceActivityTimeline } from "@/components/workspace-activity-timeline"; import { trpc } from "@/lib/trpc"; import { useWorkspace } from "@/hooks/use-workspace"; @@ -224,14 +225,24 @@ export default function CommandCenterPage() { count={data.counts.actionRequests} empty="No agent asks." > - {data.actionRequests.map((row) => ( - dropActionRequest(row.id)} - /> - ))} + {data.actionRequests.map((row) => + completionIntent(row.payload) && row.issue ? ( + dropActionRequest(row.id)} + /> + ) : ( + dropActionRequest(row.id)} + /> + ), + )} ) : null} {data.runtimeApprovals.length > 0 ? ( @@ -526,6 +537,7 @@ type CCActionRequest = { severity: string; /** ActionRequestKind — FREE_FORM asks need an answer, not just Accept. */ kind: string; + payload: unknown; issue: { id: string; number: number; @@ -540,6 +552,12 @@ type CCActionRequest = { requestedByUser: { name: string | null } | null; }; +function completionIntent(payload: unknown): "COMPLETE" | "RECOVER" | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const intent = (payload as Record).intent; + return intent === "COMPLETE" || intent === "RECOVER" ? intent : null; +} + type CCRuntimeApproval = { id: string; currentStep: string | null; diff --git a/src/app/(app)/w/[slug]/projects/[id]/page.tsx b/src/app/(app)/w/[slug]/projects/[id]/page.tsx index 86c5b2b0..b7f071a5 100644 --- a/src/app/(app)/w/[slug]/projects/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/projects/[id]/page.tsx @@ -22,6 +22,7 @@ import { Avatar } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Combobox } from "@/components/ui/combobox"; import { Confirm, QuickForm } from "@/components/ui/modal"; import { EmptyState, Skeleton } from "@/components/ui"; import { InitiativeChip } from "@/components/initiative-chip"; @@ -46,8 +47,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st const { data: ws } = trpc.workspace.current.useQuery(); const tabParam = (searchParams?.get("view") ?? "overview") as Tab; - const tab: Tab = - tabParam === "list" || tabParam === "board" ? tabParam : "overview"; + const tab: Tab = tabParam === "list" || tabParam === "board" ? tabParam : "overview"; const setTab = (next: Tab) => { const params = new URLSearchParams(searchParams?.toString() ?? ""); @@ -99,11 +99,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st title="Project not found" description={error.message} action={ - } @@ -137,11 +133,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st subtitle={project.key} actions={
- +
)} - {tab === "board" && ( - - )} + {tab === "board" && } @@ -260,13 +250,7 @@ function ProjectTabButton({ // Overview tab // --------------------------------------------------------------------------- -function OverviewTab({ - projectId, - workspaceSlug, -}: { - projectId: string; - workspaceSlug: string; -}) { +function OverviewTab({ projectId, workspaceSlug }: { projectId: string; workspaceSlug: string }) { const { data, isLoading, refetch } = trpc.project.overview.useQuery({ id: projectId, }); @@ -293,8 +277,7 @@ function OverviewTab({ ); } - const { project, counts, linkedInitiative, currentCycleSlice, members, recentActivity } = - data; + const { project, counts, linkedInitiative, currentCycleSlice, members, recentActivity } = data; const headerStats: Array<{ label: string; value: number; tone?: "warn" }> = [ { label: "Total", value: counts.total }, @@ -308,15 +291,19 @@ function OverviewTab({ }, ]; - const completionPct = - counts.total > 0 ? Math.round((counts.done / counts.total) * 100) : 0; + const completionPct = counts.total > 0 ? Math.round((counts.done / counts.total) * 100) : 0; // Per-bucket breakdown for the Progress card. The overview payload only // exposes category-bucket counts (not per-Status rows), so the breakdown // mirrors those buckets. StatusDot is category-aware; colors are token // driven via `hsl(var(--…))`. const breakdown: Array<{ label: string; category: string; color: string; count: number }> = [ - { label: "In progress", category: "IN_PROGRESS", color: "hsl(var(--ember))", count: counts.inProgress }, + { + label: "In progress", + category: "IN_PROGRESS", + color: "hsl(var(--ember))", + count: counts.inProgress, + }, { label: "Open", category: "TODO", @@ -339,189 +326,179 @@ function OverviewTab({
{/* Left column — primary content (preserved live sections). */}
- {/* Header — name + description (inline edit). Edit/avatar/etc. live + {/* Header — name + description (inline edit). Edit/avatar/etc. live on the page Topbar so they're consistent across tabs. */} -
-
-

- {project.icon && {project.icon}} - {project.name} -

- {project.key} -
- {editingDesc ? ( -
-