From 24e7271336fa7c6fc3bbb380cd36a7096a8f5003 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 13 Jul 2026 18:46:15 -0400 Subject: [PATCH 1/4] feat: make agent work realtime on issues --- DEVLOG.md | 33 + docs/agents/engagement-modes.md | 18 + docs/agents/hermes.md | 4 +- .../migration.sql | 9 + prisma/schema.prisma | 11 + src/app/(app)/w/[slug]/issues/[id]/page.tsx | 20 +- src/app/(app)/w/[slug]/layout.tsx | 4 + .../w/[slug]/settings/workspace/page.tsx | 47 +- src/app/api/realtime/route.ts | 133 ++- .../issue-detail/agent-run-strip.tsx | 985 +++++++++++++----- src/components/issue-detail/issue-main.tsx | 39 +- src/components/issue-detail/issue-rail.tsx | 40 +- src/components/realtime-provider.tsx | 73 +- src/hooks/use-realtime.ts | 57 +- src/hooks/use-workspace.ts | 4 + src/lib/agent-stale.ts | 43 +- src/server/realtime-catchup.ts | 157 +++ src/server/realtime.ts | 34 +- .../__tests__/workspace-members.test.ts | 20 + src/server/routers/agent-run.ts | 34 +- src/server/routers/workspace.ts | 4 + .../__tests__/engagement-mode.test.ts | 70 +- .../__tests__/realtime-catchup.test.ts | 157 +++ .../run-protocol-diagnostics.test.ts | 155 +++ src/server/services/agent-presence.ts | 8 +- src/server/services/agent-run.ts | 10 +- .../services/dispatch/run-dispatcher.ts | 45 +- src/server/services/engagement-mode.ts | 64 +- src/server/services/mcp.ts | 6 +- .../services/run-protocol-diagnostics.ts | 152 ++- tests/unit/agent-stale.test.ts | 33 + 31 files changed, 2088 insertions(+), 381 deletions(-) create mode 100644 prisma/migrations/0100_agent_progress_observability/migration.sql create mode 100644 src/server/realtime-catchup.ts create mode 100644 src/server/services/__tests__/realtime-catchup.test.ts create mode 100644 src/server/services/__tests__/run-protocol-diagnostics.test.ts create mode 100644 tests/unit/agent-stale.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index a99d7603..1682af59 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,39 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-13 — Issue Workstream + durable realtime progress + +Consolidated the issue's repeated agent-status surfaces into one first-class +Workstream beneath the issue author metadata. It now keeps agent presence, +operational state, engagement mode, execution runtime, effective tool policy, +elapsed/last-signal timing, rolling semantic status, approval handling, +operator controls, and an expandable ten-event trace together. Active rolling +STATUS comments no longer jump through the conversation timeline; terminal +summaries remain in history. Activity now leads the issue-rail navigation and +shows live work, while bare issue URLs preserve the historical Attachments +default. + +Kept the browser transport on SSE and closed its reliability gap with durable +cursor replay across `ActivityEvent` and `AgentRunEvent`, subscription-before- +replay buffering, bounded reconciliation, persisted per-workspace cursors, and +visible connecting/live/reconnecting/offline health. Granular run publishes now +use their durable row id/timestamp instead of an unrelated transient id. + +Added workspace settings and migration 0100 for semantic progress cadence and +the non-terminal quiet threshold. The versioned agent protocol now separates +mechanical trace events from human-facing STATUS checkpoints, asks for concise +phase/result updates at the configured cadence, and explicitly rejects +chain-of-thought/tool-log narration. Run diagnostics expose acknowledgement, +output, progress, and completion signals independently, and UI copy keeps Quiet +distinct from the canonical persisted STALLED state. + +Verification: `pnpm lint` passed with existing repository warnings; `pnpm +typecheck` passed; the full Vitest suite passed (**1,187 passed; 1 skipped**); +`git diff --check` passed; and a fresh production build completed. The full +Playwright run passed **36/37** before Chromium itself segfaulted while creating +one workspace-switcher context; that test passed in an isolated single-worker +rerun (**1/1**). No deployment or push performed. + ## 2026-07-13 — AXI-102 expired runtime approval recovery Diagnosed AXI-102's failed approval against Hermes: Forge retained WAITING run diff --git a/docs/agents/engagement-modes.md b/docs/agents/engagement-modes.md index 19223b60..e996d7ed 100644 --- a/docs/agents/engagement-modes.md +++ b/docs/agents/engagement-modes.md @@ -121,6 +121,24 @@ ack the inbox item, mark output started when real work begins, use rolling status comments only for meaningful operator-facing checkpoints, call `runs.setWaiting` when blocked, and finish with `runs.complete`. +The run trace and the rolling status serve different audiences: + +- **Mechanical trace** — runtime lifecycle, tool starts/completions, tests, and + other adapter events are appended automatically as `AgentRunEvent` rows. + Agents should not duplicate this telemetry in comments. +- **Semantic status** — `comments.upsertStatus` is a compact operator update at + a phase change (for example Inspecting → Implementing → Verifying), after a + material finding changes the plan, or before continuing a long phase. It + states the outcome so far and the next action. It must not contain internal + chain-of-thought or raw tool logs. +- **Conversation** — agents use a normal comment for a conclusion, question, + approval request, or decision that belongs in the durable human thread. + +An active run with fresh mechanical events but an old semantic checkpoint is +**quiet**, not `STALLED`: the runtime may still be healthy, but the operator is +missing a useful update. `STALLED` is reserved for the canonical terminal +`AgentRun.status` set by the workspace-configured watchdog or an operator. + Mode is enforced in layers. Forge MCP rejects issue-state mutations from active **Research**, **Review**, and **Discuss** runs; those runs can still comment, post status, record a verdict, set themselves waiting, and complete diff --git a/docs/agents/hermes.md b/docs/agents/hermes.md index 618984a2..0a23531c 100644 --- a/docs/agents/hermes.md +++ b/docs/agents/hermes.md @@ -59,10 +59,10 @@ runs. Hermes `/v1/runs` dispatch also carries: ```json { "engagement_mode": "REVIEW", - "forge_contract_version": "2026-06-06.2", + "forge_contract_version": "2026-07-13.1", "tool_allowlist": [], "runtime_policy": { - "contract_version": "2026-06-06.2", + "contract_version": "2026-07-13.1", "engagement_mode": "REVIEW", "allowed_host_tools": [], "enforcement_layers": [ diff --git a/prisma/migrations/0100_agent_progress_observability/migration.sql b/prisma/migrations/0100_agent_progress_observability/migration.sql new file mode 100644 index 00000000..2b4da50d --- /dev/null +++ b/prisma/migrations/0100_agent_progress_observability/migration.sql @@ -0,0 +1,9 @@ +-- Workspace-level observability policy for live agent work. +-- +-- `agentProgressUpdateMinutes` is the requested human-facing checkpoint +-- cadence injected into the Forge run protocol. `agentRunQuietMinutes` is a +-- non-terminal operator-attention threshold, intentionally distinct from the +-- existing `agentRunStaleMinutes` watchdog that mutates runs to STALLED. +ALTER TABLE "Workspace" + ADD COLUMN "agentProgressUpdateMinutes" INTEGER NOT NULL DEFAULT 5, + ADD COLUMN "agentRunQuietMinutes" INTEGER NOT NULL DEFAULT 5; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d1edbd76..e0aa08cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -818,6 +818,17 @@ model Workspace { /// enough that a finished turn an agent didn't close via `runs.complete` /// stops lingering as a live run on the issue page. 0 disables the sweep. agentRunStaleMinutes Int @default(30) + /// Expected cadence for meaningful, human-facing status checkpoints while + /// an agent run is active. Injected into the run protocol so agents refresh + /// their rolling STATUS comment during long phases without narrating every + /// internal thought. 0 disables the cadence reminder. + agentProgressUpdateMinutes Int @default(5) + /// Operator-facing quiet threshold for ACTIVE runs. After this many minutes + /// without an AgentRunEvent, surfaces may label the run QUIET / needing + /// attention. This does not mutate run state; `agentRunStaleMinutes` remains + /// the separate watchdog that canonically closes a run as STALLED. 0 disables + /// the early quiet signal. + agentRunQuietMinutes Int @default(5) /// Minutes a REVIEW step may wait for its dispatched reviewer to /// acknowledge before Forge opens a human fallback gate. 0 disables the /// fallback. The orchestration watchdog evaluates this workspace setting. diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index 531810be..097cedd2 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -36,11 +36,10 @@ import { WatchButton } from "@/components/watch-button"; import { IssueDetailTopbar } from "@/components/issue-detail/issue-topbar"; import { IssueMain } from "@/components/issue-detail/issue-main"; import { IssueRail } from "@/components/issue-detail/issue-rail"; -import { IssueAgentPanel } from "@/components/issue-detail/issue-agent-panel"; +import { AgentRunStrip } from "@/components/issue-detail/agent-run-strip"; import { TerminalRunFailureBanner } from "@/components/issue-detail/run-failure-banner"; import { IssueFollowThroughBanner } from "@/components/issue-detail/issue-follow-through-banner"; import { RuntimePreflightBanner } from "@/components/issue-detail/runtime-preflight-banner"; -import { RunActivityChip } from "@/components/issue-detail/run-activity-chip"; import { GitHubLinksPanel } from "@/components/issue-detail/github-links-panel"; import { AiTriageCard } from "@/components/ai-triage-card"; import { CreateLabelModal } from "@/components/inline-create/create-label-modal"; @@ -550,11 +549,6 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri onChange={(userIds) => assign.mutate({ id: issue.id, userIds })} /> setAgentPickerOpen(true)} /> - {/* "What's the agent doing right now?" — only renders when - there's an ACTIVE run on this issue. Lives next to the - AgentChip so the operator's eye lands on agent + status - together. */} - } actions={siblingScope ? : null} @@ -576,6 +570,13 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri + + - + + + setAgentProgressUpdateMinutes(Number(e.target.value) || 0) + } + disabled={!canEdit} + /> + + + + setAgentRunQuietMinutes(Number(e.target.value) || 0) + } + disabled={!canEdit} + /> + Promise) | undefined; + let heartbeat: ReturnType | undefined; + let closed = false; const stream = new ReadableStream({ - start(controller) { - const send = (d: unknown) => - controller.enqueue(encoder.encode(`data: ${JSON.stringify(d)}\n\n`)); - send({ type: "ready" }); - unsubscribe = subscribe(workspaceId, send); - const hb = setInterval(() => controller.enqueue(encoder.encode(": hb\n\n")), 15_000); - req.signal.addEventListener("abort", () => { - clearInterval(hb); + async start(controller) { + const send = (data: unknown, id?: string) => { + if (closed) return; + const frame = `${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(frame)); + }; + const liveBuffer: RealtimeEvent[] = []; + const seen = new Set(); + let highWaterCursor = cursor; + let replaying = true; + + const sendLiveEvent = (evt: RealtimeEvent) => { + const rawEventCursor = cursorForRealtimeEvent(evt); + const eventCursor = decodeRealtimeCursor(rawEventCursor); + // Redis publish is best-effort and may occur just before its enclosing + // transaction commits. If replay already observed a later durable row, + // still deliver this event but omit its SSE id so Last-Event-ID never + // moves backward. + const advancesCursor = + eventCursor && + (!highWaterCursor || compareRealtimeCursors(eventCursor, highWaterCursor) > 0); + if (advancesCursor) highWaterCursor = eventCursor; + send(evt, advancesCursor ? rawEventCursor : undefined); + }; + + const onAbort = () => { + if (closed) return; + closed = true; + if (heartbeat) clearInterval(heartbeat); void unsubscribe?.(); - controller.close(); - }); + try { + controller.close(); + } catch { + // The stream may already be closed by the runtime. + } + }; + req.signal.addEventListener("abort", onAbort, { once: true }); + + try { + // Subscribe first and buffer while reading durable rows. This closes + // the replay/live handoff race without changing the Redis bus. + unsubscribe = await subscribeReady(workspaceId, (evt) => { + if (replaying) { + liveBuffer.push(evt); + return; + } + if (seen.has(evt.id)) return; + seen.add(evt.id); + sendLiveEvent(evt); + }); + if (closed) { + await unsubscribe(); + return; + } + + let replayed = 0; + let needsReconcile = Boolean(rawCursor && !cursor); + if (cursor) { + try { + const catchup = await loadRealtimeCatchup(db, workspaceId, cursor); + for (const evt of catchup.events) { + seen.add(evt.id); + const eventCursor = decodeRealtimeCursor(evt.cursor); + const advancesCursor = + eventCursor && + (!highWaterCursor || compareRealtimeCursors(eventCursor, highWaterCursor) > 0); + if (advancesCursor) highWaterCursor = eventCursor; + send(evt, advancesCursor ? evt.cursor : undefined); + } + replayed = catchup.events.length; + needsReconcile ||= catchup.truncated; + } catch (error) { + console.error("[realtime] durable catch-up failed", error); + needsReconcile = true; + } + } + + // Flush events that landed after the Redis subscription but before + // replay completed. Duplicate durable rows are ignored by id. + replaying = false; + liveBuffer + .sort((a, b) => + a.createdAt === b.createdAt + ? a.id.localeCompare(b.id) + : a.createdAt.localeCompare(b.createdAt), + ) + .forEach((evt) => { + if (seen.has(evt.id)) return; + seen.add(evt.id); + sendLiveEvent(evt); + }); + + if (needsReconcile) { + send({ type: "reconcile", reason: cursor ? "catchup-limit" : "invalid-cursor" }); + } + send({ type: "ready", replayed, reconciled: needsReconcile }); + heartbeat = setInterval(() => { + if (!closed) controller.enqueue(encoder.encode(": hb\n\n")); + }, 15_000); + } catch (error) { + if (!closed) controller.error(error); + } + }, + async cancel() { + if (closed) return; + closed = true; + if (heartbeat) clearInterval(heartbeat); + await unsubscribe?.(); }, }); diff --git a/src/components/issue-detail/agent-run-strip.tsx b/src/components/issue-detail/agent-run-strip.tsx index d0f29d76..3d1c217f 100644 --- a/src/components/issue-detail/agent-run-strip.tsx +++ b/src/components/issue-detail/agent-run-strip.tsx @@ -1,7 +1,27 @@ "use client"; -import { useEffect, useState } from "react"; -import { AlertTriangle, Bot, Activity, Hourglass, RefreshCw } from "lucide-react"; + +import type { AgentStatus } from "@prisma/client"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { + Activity, + AlertTriangle, + Bell, + ChevronDown, + FolderOpen, + GitBranch, + History, + RefreshCw, + Square, + Terminal, + Zap, +} from "lucide-react"; import { toast } from "sonner"; +import { AgentAvatar } from "@/components/agents/agent-avatar"; +import { RunApprovalCard } from "@/components/agents/run-approval-card"; +import { AgentPresenceDot } from "@/components/agent-presence-dot"; +import { RunTimeline } from "@/components/mission-control/run-timeline"; +import { RuntimePolicyBadges } from "@/components/runtime-tool-surface"; import { EngagementModeGlyph, MODE_LABEL, @@ -9,204 +29,585 @@ import { MODE_SUBTITLE, type EngagementModeValue, } from "@/components/ui/engagement-mode-glyph"; -import { useConfirm } from "@/components/ui/modal"; -import { trpc } from "@/lib/trpc"; -import { useRealtime } from "@/hooks/use-realtime"; -import { relativeTime, cn } from "@/lib/utils"; -import { RuntimePolicyBadges } from "@/components/runtime-tool-surface"; -import { RunApprovalCard } from "@/components/agents/run-approval-card"; +import { useRealtime, useRealtimeConnection } from "@/hooks/use-realtime"; +import { useWorkspace } from "@/hooks/use-workspace"; import type { RuntimePolicySnapshot } from "@/lib/runtime-enforcement"; +import type { RuntimeToolCapability } from "@/lib/runtime-tools"; +import { trpc } from "@/lib/trpc"; +import { presenceAvailability } from "@/lib/transport-display"; +import { cn, relativeTime } from "@/lib/utils"; +import { useConfirm } from "@/components/ui/modal"; + +type WorkstreamAgent = { + id: string; + name: string; + profileKey: string; + avatar?: string | null; + status?: AgentStatus; + engagementMode?: EngagementModeValue | null; + provider?: string | null; + runtimeMode?: string | null; + lastHeartbeatAt?: Date | string | null; + webhookUrl?: string | null; + runtimeId?: string | null; + runtime?: { + name?: string | null; + adapterKey?: string | null; + config?: unknown; + } | null; +}; + +export type WorkstreamLatestRun = { + id: string; + status: string; + startedAt: Date | string; + lastEventAt: Date | string; + finishedAt?: Date | string | null; + currentStep?: string | null; + summary?: string | null; + engagementMode?: string | null; + runtimePolicy?: unknown; + agent: WorkstreamAgent; +}; + +type WorkstreamState = + | "assigned" + | "queued" + | "wake-sent" + | "acknowledged" + | "working" + | "quiet" + | "waiting" + | "completed" + | "stalled" + | "stopped" + | "errored"; /** - * Live-pulse strip for the assigned agent's current run on this issue. - * - * Subscribes to the workspace SSE bus and refreshes on `agent-run.*` - * events. Renders nothing when no ACTIVE run — the strip is invisible - * unless work is happening, so the issue page stays calm for issues - * that aren't currently being worked. - * - * Layout: a thin warm strip with a slow pulse dot, the agent name, - * the current step (denormalized on the run row), and "updated Ns ago" - * relative to lastEventAt. The data comes from `agentRun.activeForIssue` - * which already includes the agent + statusComment join. + * The issue's single operational surface for agent work. * - * Since the durable inbox refactor the strip also distinguishes - * canonical dispatch states (queued / wake-sent / acknowledged / - * running / stalled) using `acknowledgedAt`, `outputStartedAt`, - * `lastWakeAt`, and the legacy `lastEventAt` falloff. Webhook delivery - * success no longer implies the agent is doing work — the agent must - * have acked the run for the strip to show "running". + * Unlike the former repeated pulse strips, Workstream keeps identity, + * contract, runtime, controls, rolling status, and the event trace together. + * Active runs open their trace automatically; terminal history stays calm and + * collapsed. A quiet ACTIVE run is explicitly labelled Quiet — only a + * canonical persisted STALLED status is ever called Stalled. */ -export function AgentRunStrip({ issueId }: { issueId: string }) { +export function AgentRunStrip({ + issueId, + assignedAgent = null, + latestRun = null, + activityHref, +}: { + issueId: string; + assignedAgent?: WorkstreamAgent | null; + latestRun?: WorkstreamLatestRun | null; + activityHref?: string; +}) { const utils = trpc.useUtils(); - const { data: run } = trpc.agentRun.activeForIssue.useQuery({ issueId }, { staleTime: 5_000 }); + const workspace = useWorkspace(); + const realtime = useRealtimeConnection(); + const { data: activeRun } = trpc.agentRun.activeForIssue.useQuery( + { issueId }, + { staleTime: 5_000 }, + ); const [now, setNow] = useState(() => Date.now()); + const [expanded, setExpanded] = useState(false); + const initializedRunId = useRef(null); + + const relevantLatestRun = + latestRun && (!assignedAgent || latestRun.agent.id === assignedAgent.id) ? latestRun : null; + const displayRun = activeRun ?? relevantLatestRun; + const isActiveRun = Boolean( + activeRun || (displayRun && ["ACTIVE", "WAITING"].includes(displayRun.status.toUpperCase())), + ); + const displayAgent = mergeAgent( + activeRun?.agent ?? relevantLatestRun?.agent ?? null, + assignedAgent, + ); + const quietMs = + workspace.agentRunQuietMinutes > 0 ? workspace.agentRunQuietMinutes * 60_000 : null; + const state = deriveWorkstreamState(activeRun ?? relevantLatestRun, now, isActiveRun, quietMs); + const policy = (displayRun?.runtimePolicy ?? null) as RuntimePolicySnapshot | null; + + const { data: events } = trpc.agentRun.events.useQuery( + { runId: displayRun?.id ?? "", limit: 10 }, + { enabled: Boolean(displayRun?.id) && expanded, staleTime: 5_000 }, + ); - // Refresh the relative-time label every 10s so "updated Ns ago" stays - // honest without re-rendering on each second tick. useEffect(() => { - const t = setInterval(() => setNow(Date.now()), 10_000); - return () => clearInterval(t); + const timer = window.setInterval(() => setNow(Date.now()), 10_000); + return () => window.clearInterval(timer); }, []); - // Patch on agent-run SSE events for this run — the full query - // refetches but the local state already updates immediately so the - // strip feels live. - useRealtime((evt) => { - if (evt.subjectType !== "agent-run") return; - const payload = evt.payload as { issueId?: string } | null; + useEffect(() => { + if (!displayRun || initializedRunId.current === displayRun.id) return; + initializedRunId.current = displayRun.id; + setExpanded( + isActiveRun && (state === "working" || state === "acknowledged" || state === "wake-sent"), + ); + }, [displayRun, isActiveRun, state]); + + useRealtime((event) => { + if (event.subjectType !== "agent-run") return; + const payload = event.payload as { issueId?: string } | null; if (payload?.issueId !== issueId) return; void utils.agentRun.activeForIssue.invalidate({ issueId }); + if (displayRun?.id) { + void utils.agentRun.events.invalidate({ runId: displayRun.id, limit: 10 }); + } }); - if (!run) return null; - - const elapsedMs = now - new Date(run.startedAt).getTime(); - const elapsedLabel = formatElapsed(elapsedMs); - const lastEventLabel = relativeTime(run.lastEventAt); - // WAITING short-circuits the wake/ack derivation entirely — when the - // agent has self-blocked, the operator's eye should land on "this is - // on you" rather than on the wake-state machine. - const state: StripState = - run.status === "WAITING" - ? "waiting" - : deriveStripState({ - acknowledgedAt: run.acknowledgedAt, - outputStartedAt: run.outputStartedAt, - lastWakeAt: run.lastWakeAt, - lastEventAt: new Date(run.lastEventAt), - now, - }); + if (!displayAgent && !displayRun) return null; + + const mode = normalizeMode( + displayRun?.engagementMode ?? activeRun?.engagementMode ?? assignedAgent?.engagementMode, + ); + const elapsedLabel = displayRun + ? formatElapsed(now - new Date(displayRun.startedAt).getTime()) + : null; + const lastSignalLabel = displayRun ? relativeTime(displayRun.lastEventAt) : null; const step = - state === "idle" - ? run.currentStep - ? `${run.currentStep} · idle` - : "idle" - : (run.currentStep ?? - (state === "waiting" - ? "Waiting on you" - : state === "running" - ? "working…" - : state === "acknowledged" - ? "acknowledged · drafting…" - : state === "wake-sent" - ? `wake sent · waiting for ack${run.wakeAttempts > 1 ? ` (${run.wakeAttempts} attempts)` : ""}` - : state === "queued" - ? "queued · waking…" - : "no activity")); + activeRun?.currentStep ?? + activeRun?.statusComment?.currentStep ?? + relevantLatestRun?.currentStep ?? + stateCopy(state); + const statusBody = activeRun?.statusComment?.body?.trim(); + const statusTone = stateTone(state); return ( -
-
- - {state === "running" || state === "acknowledged" ? ( - // Live pulse only while events are still arriving. Once the - // agent's final turn lands (no events past the live window) the - // state settles to "idle" — a calm static dot, no ping — so the - // strip stops claiming active work the moment it's done. - <> - - - - ) : state === "waiting" ? ( - - ) : state === "idle" ? ( - - ) : state === "stalled" ? ( - - ) : ( - - )} - - {state === "waiting" ? ( - - ) : ( - +
+ + +
+ + {activityHref ? ( + + + Activity + + ) : null} + +
+
+ +
+ {mode ? : null} + {displayAgent ? : null} + + + + {displayRun ? ( + + + + {elapsedLabel} + + · + + last signal {lastSignalLabel} + + + ) : null} +
+ + {statusBody && statusBody !== step ? ( +
+
+ Current status +
+

+ {statusBody} +

+
+ ) : null} + + {activeRun?.awaitingApprovalAt ? ( + { + void utils.agentRun.activeForIssue.invalidate({ issueId }); + void utils.issue.byId.invalidate({ id: issueId }); + void utils.commandCenter.summary.invalidate(); + void utils.commandCenter.decisionsCount.invalidate(); + }} /> - )} - {run.agent.name} - {state === "waiting" && ( - // Soft "Waiting on you" pill in the warm ember tone — distinct - // from the alarming amber STALLED pill so patient agents read - // as "your turn" not "broken." - - Waiting on you - - )} - · - - {step} - + ) : null}
-
- -
- - - | undefined - } - /> - - - - {elapsedLabel} - - · - - updated {lastEventLabel} + + {displayRun && expanded ? ( +
+
+ + Live trace - + Latest 10 events +
+
+ ) : null} + + {isActiveRun && mode && displayRun ? ( +
+ +
+ ) : null} + + ); +} + +function WorkstreamActions({ + issueId, + runId, + agent, + quiet, +}: { + issueId: string; + runId: string | null; + agent: WorkstreamAgent | null; + quiet: boolean; +}) { + const utils = trpc.useUtils(); + const [confirmStop, setConfirmStop] = useState(false); + const invalidate = () => { + void utils.agentRun.activeForIssue.invalidate({ issueId }); + void utils.issue.byId.invalidate({ id: issueId }); + void utils.agentRun.activeAll.invalidate(); + void utils.issue.queue.invalidate(); + }; + const nudgeM = trpc.agentRun.nudge.useMutation({ + onSuccess: () => { + invalidate(); + toast.success("Nudge sent"); + }, + onError: (error) => toast.error(error.message), + }); + const kickM = trpc.agentRun.kick.useMutation({ + onSuccess: (result) => { + invalidate(); + if (result.kicked) toast.success("Run kicked"); + else toast.message("Run is still fresh"); + }, + onError: (error) => toast.error(error.message), + }); + const abandonM = trpc.agentRun.abandon.useMutation({ + onSuccess: () => { + invalidate(); + setConfirmStop(false); + toast.success("Run stopped"); + }, + onError: (error) => toast.error(error.message), + }); + const wakeM = trpc.issue.update.useMutation({ + onSuccess: () => { + invalidate(); + toast.success("Agent wake sent"); + }, + onError: (error) => toast.error(error.message), + }); + const busy = nudgeM.isPending || kickM.isPending || abandonM.isPending || wakeM.isPending; + const buttonClass = + "focus-ring inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background px-2 text-[0.6875rem] font-medium text-muted-foreground transition-colors hover:bg-subtle hover:text-foreground disabled:pointer-events-none disabled:opacity-50"; + + if (confirmStop && runId) { + return ( +
+ Stop run? + +
- {run.awaitingApprovalAt ? ( - { - void utils.agentRun.activeForIssue.invalidate({ issueId }); - void utils.issue.byId.invalidate({ id: issueId }); - void utils.commandCenter.summary.invalidate(); - void utils.commandCenter.decisionsCount.invalidate(); - }} - /> + ); + } + + if (!runId && agent) { + return ( + + ); + } + + if (!runId) return null; + return ( + <> + + {quiet ? ( + ) : null} + + + ); +} + +function StateBadge({ state }: { state: WorkstreamState }) { + const tone = stateTone(state); + const live = state === "working" || state === "acknowledged"; + return ( + + + {live ? ( + + ) : null} + + + {stateLabel(state)} + + ); +} + +function RealtimeBadge({ status }: { status: "connecting" | "live" | "reconnecting" | "offline" }) { + if (status === "live") { + return ( + + + Live + + ); + } + const offline = status === "offline"; + return ( + + + {offline ? "Offline · polling" : status === "connecting" ? "Connecting" : "Reconnecting"} + + ); +} + +function WorkModeBadge({ mode }: { mode: EngagementModeValue }) { + return ( + + + + {MODE_LABEL[mode]} + + + ); +} + +function RuntimeBadge({ + agent, + policy, +}: { + agent: WorkstreamAgent; + policy: RuntimePolicySnapshot | null; +}) { + const runtimeName = + policy?.runtimeName ?? agent.runtime?.name ?? providerLabel(agent.provider) ?? "Unattached"; + const persistence = agent.runtimeMode === "EPHEMERAL" ? "Session" : "Persistent"; + const configured = Boolean( + agent.runtimeId || agent.webhookUrl || agent.runtime || agent.provider, + ); + return ( + + Runs on {runtimeName} · {persistence} + + ); +} + +const TOOL_ICONS: Record = { + terminal: Terminal, + filesystem: FolderOpen, + git: GitBranch, +}; + +function RuntimeToolBadges({ policy }: { policy: RuntimePolicySnapshot | null }) { + if (!policy) return null; + const allowedHostTools = policy.allowedHostTools ?? []; + if (allowedHostTools.length === 0) { + return ( + + no host tools + + ); + } + return ( +
+ {allowedHostTools.map((tool) => { + const Icon = TOOL_ICONS[tool]; + return ( + + + {tool} + + ); + })}
); } @@ -216,7 +617,7 @@ function ProtocolDiagnostics({ }: { diagnostics?: Array<{ code: string; severity: string; title: string; description: string }>; }) { - const actionable = diagnostics?.filter((d) => d.severity !== "info") ?? []; + const actionable = diagnostics?.filter((diagnostic) => diagnostic.severity !== "info") ?? []; if (actionable.length === 0) return null; return (
@@ -251,54 +652,57 @@ function RunModeControl({ const { confirm, confirmElement } = useConfirm(); const utils = trpc.useUtils(); const restartM = trpc.agentRun.restartWithMode.useMutation({ - onSuccess: (res) => { + onSuccess: (result) => { void utils.agentRun.activeForIssue.invalidate({ issueId }); void utils.agentRun.activeAll.invalidate(); void utils.agentRun.recentTerminal.invalidate(); void utils.issue.byId.invalidate({ id: issueId }); - if (res.restarted) - toast.success(`Restarted as ${MODE_LABEL[res.mode as EngagementModeValue]}`); - else toast.message("Run already uses that mode"); + if (result.restarted) { + toast.success(`Restarted as ${MODE_LABEL[result.mode as EngagementModeValue]}`); + } else { + toast.message("Run already uses that mode"); + } }, - onError: (err) => toast.error(err.message), + onError: (error) => toast.error(error.message), }); return (
- restart as + Restart as
- {MODE_ORDER.map((m) => { - const active = mode === m; + {MODE_ORDER.map((nextMode) => { + const active = mode === nextMode; return ( ); })}
- - active mode locked - + Active mode is locked {confirmElement}
); } -type StripState = - | "queued" - | "wake-sent" - | "acknowledged" - | "running" - | "idle" - | "stalled" - | "waiting"; - -function deriveStripState(input: { - acknowledgedAt: Date | string | null; - outputStartedAt: Date | string | null; - lastWakeAt: Date | string | null; - lastEventAt: Date; - now: number; -}): Exclude { - // Live window: how recently an event must have arrived to still read as - // actively "running" (pulsing). Past this — but before the stale cutoff - // — the run has settled (its final turn landed) and shows a calm "idle" - // state instead of claiming ongoing work. - const LIVE_MS = 90_000; - const STALE_MS = 5 * 60_000; - if (input.outputStartedAt) { - const idleMs = input.now - input.lastEventAt.getTime(); - if (idleMs > STALE_MS) return "stalled"; - return idleMs > LIVE_MS ? "idle" : "running"; +function deriveWorkstreamState( + run: + | { + status: string; + acknowledgedAt?: Date | string | null; + outputStartedAt?: Date | string | null; + lastWakeAt?: Date | string | null; + lastEventAt: Date | string; + } + | null + | undefined, + now: number, + isActiveRun: boolean, + quietMs: number | null, +): WorkstreamState { + if (!run) return "assigned"; + const canonical = run.status.toUpperCase(); + if (canonical === "WAITING") return "waiting"; + if (!isActiveRun) { + if (canonical === "COMPLETED") return "completed"; + if (canonical === "STALLED") return "stalled"; + if (canonical === "ERRORED" || canonical === "FAILED") return "errored"; + if (canonical === "ABANDONED") return "stopped"; } - if (input.acknowledgedAt) { - return input.now - input.lastEventAt.getTime() > STALE_MS ? "stalled" : "acknowledged"; + const age = now - new Date(run.lastEventAt).getTime(); + if (quietMs !== null && age >= quietMs) return "quiet"; + if (run.outputStartedAt) return "working"; + if (run.acknowledgedAt) return "acknowledged"; + if (run.lastWakeAt) return "wake-sent"; + return "queued"; +} + +function stateLabel(state: WorkstreamState): string { + return { + assigned: "Assigned", + queued: "Dispatched", + "wake-sent": "Wake sent", + acknowledged: "Acknowledged", + working: "Working", + quiet: "Quiet", + waiting: "Waiting on you", + completed: "Completed", + stalled: "Stalled", + stopped: "Stopped", + errored: "Errored", + }[state]; +} + +function stateCopy(state: WorkstreamState): string { + return { + assigned: "Assigned · no active run", + queued: "Queued for runtime dispatch", + "wake-sent": "Wake delivered · waiting for acknowledgement", + acknowledged: "Agent acknowledged · preparing output", + working: "Agent is working", + quiet: "No recent runtime signal", + waiting: "Waiting for your reply", + completed: "Latest run completed", + stalled: "Latest run was marked stalled", + stopped: "Latest run was stopped", + errored: "Latest run ended with an error", + }[state]; +} + +function stateDescription(state: WorkstreamState): string { + if (state === "quiet") { + return "This run is still ACTIVE but has not sent a recent signal. Quiet is not the canonical STALLED state."; } - if (input.lastWakeAt) { - const wake = new Date(input.lastWakeAt).getTime(); - return input.now - wake > STALE_MS ? "stalled" : "wake-sent"; + if (state === "stalled") return "The server recorded this run as canonically STALLED."; + return stateCopy(state); +} + +function stateTone(state: WorkstreamState): { + container: string; + badge: string; + dot: string; +} { + if (state === "working" || state === "acknowledged") { + return { + container: "border-ember/30", + badge: "border-ember/30 bg-ember/10 text-ember", + dot: "bg-ember", + }; } - return "queued"; + if (state === "waiting") { + return { + container: "border-warning/30", + badge: "border-warning/30 bg-warning/10 text-warning", + dot: "bg-warning", + }; + } + if (state === "quiet") { + return { + container: "border-warning/20", + badge: "border-warning/30 bg-warning/10 text-warning", + dot: "bg-warning", + }; + } + if (state === "stalled" || state === "errored") { + return { + container: "border-destructive/30", + badge: "border-destructive/30 bg-destructive/10 text-destructive", + dot: "bg-destructive", + }; + } + if (state === "completed") { + return { + container: "border-success/20", + badge: "border-success/30 bg-success/10 text-success", + dot: "bg-success", + }; + } + return { + container: "border-border", + badge: "border-border bg-background/60 text-muted-foreground", + dot: "bg-muted-foreground", + }; +} + +function mergeAgent( + runAgent: WorkstreamAgent | null, + assignedAgent: WorkstreamAgent | null, +): WorkstreamAgent | null { + if (!runAgent) return assignedAgent; + if (!assignedAgent || runAgent.id !== assignedAgent.id) return runAgent; + return { + ...assignedAgent, + ...runAgent, + runtime: runAgent.runtime ?? assignedAgent.runtime, + }; +} + +function normalizeMode(value: string | null | undefined): EngagementModeValue | null { + const normalized = value?.toUpperCase(); + return normalized && MODE_ORDER.includes(normalized as EngagementModeValue) + ? (normalized as EngagementModeValue) + : null; +} + +function providerLabel(provider: string | null | undefined): string | null { + const normalized = provider?.trim(); + if (!normalized) return null; + return normalized + .toLowerCase() + .split(/[_\s-]+/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "); } function formatElapsed(ms: number): string { if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s`; if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`; - const h = Math.floor(ms / 3_600_000); - const m = Math.floor((ms % 3_600_000) / 60_000); - return m === 0 ? `${h}h` : `${h}h ${m}m`; + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; } diff --git a/src/components/issue-detail/issue-main.tsx b/src/components/issue-detail/issue-main.tsx index 9c67dd62..9209d876 100644 --- a/src/components/issue-detail/issue-main.tsx +++ b/src/components/issue-detail/issue-main.tsx @@ -14,7 +14,6 @@ import { RichContentRenderer, } from "@/components/markdown/attachment-renderer"; import { DescriptionAiAssist } from "@/components/issue-detail/description-ai-assist"; -import { AgentRunStrip } from "@/components/issue-detail/agent-run-strip"; import { PlanStepContextCards, type PlanStepIssueContext, @@ -261,14 +260,6 @@ function timestampMs(value: Date | string | null | undefined): number { return value instanceof Date ? value.getTime() : new Date(value).getTime(); } -function commentTimelineMs(comment: Comment): number { - // STATUS comments are rolling run updates: their meaningful timeline - // position is the last update, not the row creation time. BODY comments - // stay at creation time so edited comments don't jump around. - if (comment.kind === "STATUS") return timestampMs(comment.updatedAt ?? comment.createdAt); - return timestampMs(comment.createdAt); -} - /** * Agent comments often start with a one-line provenance tag like * @@ -341,7 +332,6 @@ export function IssueMain({
- @@ -1040,14 +1030,19 @@ function Comments({ onChange: setDraft, }); - // STATUS comments are rolling agent-run updates. They stay in the same - // chronological thread as BODY/SYSTEM rows, but use updatedAt as their - // effective time so the status card moves when the agent actually reports - // new progress. The separate AgentRunStrip is the always-current control - // surface for live runs. - const timelineComments = [...comments].sort( - (a, b) => commentTimelineMs(a) - commentTimelineMs(b), - ); + // The active rolling STATUS row is presented in Workstream, where its + // revisions and live run trace belong. Conversation rows keep a stable + // createdAt position; a status update can no longer jump through replies. + // Finished status summaries remain available as historical context. + const timelineComments = comments + .filter( + (comment) => + !( + comment.kind === "STATUS" && + (comment.run?.status === "ACTIVE" || comment.run?.status === "WAITING") + ), + ) + .sort((a, b) => timestampMs(a.createdAt) - timestampMs(b.createdAt)); // Identify the most recent agent-authored BODY comment — only its // quick-reply chips render (older chips are stale CTAs). STATUS rows // never carry chips, so the BODY filter is sufficient. @@ -1077,14 +1072,6 @@ function Comments({ /> ))}
- {/* Live run status repeated directly above the composer so the - operator sees "working… / waiting on you" right where they type - — on a long thread the top-of-page strip has scrolled away. The - strip self-hides when there's no active run, so it only appears - when an agent is actually in the loop. */} -
- -
{ e.preventDefault(); diff --git a/src/components/issue-detail/issue-rail.tsx b/src/components/issue-detail/issue-rail.tsx index b7d40c11..c120eac4 100644 --- a/src/components/issue-detail/issue-rail.tsx +++ b/src/components/issue-detail/issue-rail.tsx @@ -7,21 +7,24 @@ import { IssueRelationsPanel } from "@/components/relations/issue-relations-pane import { IssueActivityPanel } from "@/components/issue-detail/issue-activity-panel"; import { cn } from "@/lib/utils"; import { MOTION } from "@/lib/motion"; +import { trpc } from "@/lib/trpc"; /** * Tabbed right-rail for the issue detail page. Hosts the existing * attachments + relations panels and the new activity stream. * * Tab state is mirrored into the `?tab=` search param so individual tabs - * are deep-linkable (and survive reloads). Keys 1/2/3 cycle between tabs + * are deep-linkable (and survive reloads). Activity leads the navigation; + * the historical bare URL still resolves to Attachments for compatibility. + * Keys 1/2/3 cycle between tabs * when the page has focus — bound here, scoped to the issue detail route * so we don't pollute global keybindings. */ const TABS = [ + { id: "activity", label: "Activity", icon: ActivityIcon }, { id: "attachments", label: "Attachments", icon: Paperclip }, { id: "relations", label: "Relations", icon: Link2 }, - { id: "activity", label: "Activity", icon: ActivityIcon }, ] as const; type TabId = (typeof TABS)[number]["id"]; @@ -33,8 +36,10 @@ function isTabId(v: string | null | undefined): v is TabId { export function IssueRail({ issueId, header, + activityCount, }: { issueId: string; + activityCount?: number; /** * Optional content rendered above the tab strip — the issue detail * page passes its "Properties" group (project / labels / due / agent @@ -48,6 +53,10 @@ export function IssueRail({ const searchParams = useSearchParams(); const raw = searchParams?.get("tab"); const active: TabId = isTabId(raw) ? raw : "attachments"; + const { data: activeRun } = trpc.agentRun.activeForIssue.useQuery( + { issueId }, + { staleTime: 5_000 }, + ); const setTab = useCallback( (next: TabId) => { @@ -74,13 +83,13 @@ export function IssueRail({ if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; if (e.key === "1") { e.preventDefault(); - setTab("attachments"); + setTab("activity"); } else if (e.key === "2") { e.preventDefault(); - setTab("relations"); + setTab("attachments"); } else if (e.key === "3") { e.preventDefault(); - setTab("activity"); + setTab("relations"); } }; window.addEventListener("keydown", onKey); @@ -89,11 +98,7 @@ export function IssueRail({ return (
- {header && ( -
- {header} -
- )} + {header &&
{header}
}
{t.label} + {t.id === "activity" && activityCount ? ( + + {activityCount > 99 ? "99+" : activityCount} + + ) : null} + {t.id === "activity" && activeRun ? ( + + + + + ) : null} {selected && ( )} diff --git a/src/components/realtime-provider.tsx b/src/components/realtime-provider.tsx index 82ef9688..96016837 100644 --- a/src/components/realtime-provider.tsx +++ b/src/components/realtime-provider.tsx @@ -1,7 +1,11 @@ "use client"; import { useEffect } from "react"; import { trpc } from "@/lib/trpc"; -import { dispatchRealtimeEvent, type RealtimeEventShape } from "@/hooks/use-realtime"; +import { + dispatchRealtimeEvent, + setRealtimeConnectionHealth, + type RealtimeEventShape, +} from "@/hooks/use-realtime"; /** * Subscribes to the workspace SSE stream and invalidates tRPC queries on @@ -18,7 +22,26 @@ export function RealtimeProvider({ workspaceId }: { workspaceId: string }) { useEffect(() => { if (!workspaceId) return; - const es = new EventSource(`/api/realtime?workspaceId=${workspaceId}`); + setRealtimeConnectionHealth({ + status: typeof navigator !== "undefined" && !navigator.onLine ? "offline" : "connecting", + lastConnectedAt: null, + lastEventAt: null, + }); + const cursorKey = `forge.realtime.cursor.${workspaceId}`; + let savedCursor: string | null = null; + try { + savedCursor = window.sessionStorage.getItem(cursorKey); + } catch { + // Storage can be unavailable in hardened/private browser contexts. + } + const search = new URLSearchParams({ workspaceId }); + if (savedCursor) search.set("cursor", savedCursor); + const es = new EventSource(`/api/realtime?${search.toString()}`); + es.onopen = () => { + // HTTP is open, but remain connecting until the server finishes durable + // replay and emits its ready control frame. + setRealtimeConnectionHealth({ status: "connecting" }); + }; es.onmessage = (msg) => { let evt: RealtimeEventShape; try { @@ -26,6 +49,31 @@ export function RealtimeProvider({ workspaceId }: { workspaceId: string }) { } catch { return; } + if (msg.lastEventId) { + try { + window.sessionStorage.setItem(cursorKey, msg.lastEventId); + } catch { + // Cursor persistence is an enhancement; native EventSource retries + // still carry Last-Event-ID for the lifetime of this instance. + } + } + if (evt.type === "ready") { + const now = new Date().toISOString(); + setRealtimeConnectionHealth({ status: "live", lastConnectedAt: now }); + return; + } + if (evt.type === "reconcile") { + // The cursor was invalid or the bounded replay window overflowed. + // Reconcile all mounted tRPC observers once; subsequent live events + // return to the existing fine-grained invalidation path. + void utils.invalidate(); + dispatchRealtimeEvent(evt); + return; + } + setRealtimeConnectionHealth({ + status: "live", + lastEventAt: evt.createdAt ?? new Date().toISOString(), + }); // Fan out to hook subscribers first — their handlers often want to // patch local state before the tRPC invalidation kicks in. dispatchRealtimeEvent(evt); @@ -89,7 +137,26 @@ export function RealtimeProvider({ workspaceId }: { workspaceId: string }) { // the issue branch above already triggers a rail refresh (issue.byId // bundles relations) — no extra case needed. }; - return () => es.close(); + es.onerror = () => { + setRealtimeConnectionHealth({ + status: typeof navigator !== "undefined" && !navigator.onLine + ? "offline" + : "reconnecting", + }); + }; + const onOffline = () => setRealtimeConnectionHealth({ status: "offline" }); + const onOnline = () => { + if (es.readyState !== EventSource.OPEN) { + setRealtimeConnectionHealth({ status: "reconnecting" }); + } + }; + window.addEventListener("offline", onOffline); + window.addEventListener("online", onOnline); + return () => { + window.removeEventListener("offline", onOffline); + window.removeEventListener("online", onOnline); + es.close(); + }; }, [workspaceId, utils]); return null; diff --git a/src/hooks/use-realtime.ts b/src/hooks/use-realtime.ts index bf87be7d..570b7db7 100644 --- a/src/hooks/use-realtime.ts +++ b/src/hooks/use-realtime.ts @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; /** * Realtime event wire format — mirrors `RealtimeEvent` on the server but @@ -21,6 +21,61 @@ type EventHandler = (evt: RealtimeEventShape) => void; const listeners = new Set(); +export type RealtimeConnectionStatus = + | "connecting" + | "live" + | "reconnecting" + | "offline"; + +export interface RealtimeConnectionHealth { + status: RealtimeConnectionStatus; + lastConnectedAt: string | null; + lastEventAt: string | null; +} + +const connectionListeners = new Set<() => void>(); +const INITIAL_CONNECTION_HEALTH: RealtimeConnectionHealth = { + status: "connecting", + lastConnectedAt: null, + lastEventAt: null, +}; +let connectionHealth = INITIAL_CONNECTION_HEALTH; + +/** Internal provider bridge; exported so the transport stays decoupled from UI. */ +export function setRealtimeConnectionHealth( + next: Partial, +): void { + const value = { ...connectionHealth, ...next }; + if ( + value.status === connectionHealth.status && + value.lastConnectedAt === connectionHealth.lastConnectedAt && + value.lastEventAt === connectionHealth.lastEventAt + ) { + return; + } + connectionHealth = value; + for (const listener of connectionListeners) listener(); +} + +/** + * Current workspace stream health for connection badges and degraded-mode + * messaging. `offline` means the browser is offline; `reconnecting` means the + * network is up but EventSource is retrying. Existing query polling remains the + * fallback in either degraded state. + */ +export function useRealtimeConnection(): RealtimeConnectionHealth { + return useSyncExternalStore( + (listener) => { + connectionListeners.add(listener); + return () => { + connectionListeners.delete(listener); + }; + }, + () => connectionHealth, + () => INITIAL_CONNECTION_HEALTH, + ); +} + /** * Global registry the `RealtimeProvider` feeds SSE events into. Hooks * register themselves here rather than spinning up their own EventSource diff --git a/src/hooks/use-workspace.ts b/src/hooks/use-workspace.ts index e75fdc51..a7271db8 100644 --- a/src/hooks/use-workspace.ts +++ b/src/hooks/use-workspace.ts @@ -20,6 +20,10 @@ export type WorkspaceContextValue = { cycleCooldownDays: number; timeTrackingEnabled: boolean; attachmentQuotaMb: number; + /** Requested cadence for human-facing agent progress checkpoints. */ + agentProgressUpdateMinutes: number; + /** Non-terminal quiet threshold used by live run surfaces. */ + agentRunQuietMinutes: number; }; export const WorkspaceContext = createContext(null); diff --git a/src/lib/agent-stale.ts b/src/lib/agent-stale.ts index dc0542ac..a1411114 100644 --- a/src/lib/agent-stale.ts +++ b/src/lib/agent-stale.ts @@ -1,16 +1,39 @@ /** - * Single source of truth for the "stalled run" threshold. An AgentRun is - * considered stalled (in the UI sense) when its `lastEventAt` is older - * than this. The Mission Control overlay, the agent detail Stalled - * bucket, and the dashboard Agent Activity tile all import this value - * so they can never disagree. + * Single source of truth for the operator-attention "quiet run" threshold. + * An ACTIVE AgentRun older than this may need an update, but it is not + * canonically STALLED. `AgentRun.status === STALLED` is the only stalled state. * * The server-side watchdog that *closes* stalled runs lives in * `src/server/services/agent-run-stale.ts` and uses * `Workspace.agentRunStaleMinutes` — that's a per-workspace knob for - * automatic state transitions. This constant is purely for "show this - * row as needing attention." Keeping the two separate is intentional: - * an operator can see a run as stalled and kick it well before the - * watchdog would auto-close it. + * automatic state transitions. This constant is purely for "show this active + * row as quiet / needing attention." Keeping the two separate is intentional: + * an operator can nudge a quiet run before the watchdog canonically stalls it. */ -export const STALE_RUN_MS = 5 * 60_000; +export const QUIET_RUN_MS = 5 * 60_000; + +/** @deprecated Prefer QUIET_RUN_MS; retained while callers migrate their copy. */ +export const STALE_RUN_MS = QUIET_RUN_MS; + +export type RunFreshness = "LIVE" | "QUIET" | "STALLED"; + +/** + * Keep UI attention freshness separate from the persisted terminal status. + * WAITING and terminal non-STALLED runs are not reclassified by elapsed time. + */ +export function deriveRunFreshness(input: { + status: string; + lastEventAt: Date | string; + now?: Date | number; + quietMs?: number; +}): RunFreshness { + if (input.status === "STALLED") return "STALLED"; + if (input.status !== "ACTIVE") return "LIVE"; + + const now = input.now instanceof Date ? input.now.getTime() : (input.now ?? Date.now()); + const lastEventAt = + input.lastEventAt instanceof Date + ? input.lastEventAt.getTime() + : new Date(input.lastEventAt).getTime(); + return now - lastEventAt >= (input.quietMs ?? QUIET_RUN_MS) ? "QUIET" : "LIVE"; +} diff --git a/src/server/realtime-catchup.ts b/src/server/realtime-catchup.ts new file mode 100644 index 00000000..e0139d72 --- /dev/null +++ b/src/server/realtime-catchup.ts @@ -0,0 +1,157 @@ +import "server-only"; +import { EventKind, type PrismaClient } from "@prisma/client"; +import type { RealtimeEvent } from "@/server/realtime"; + +export const REALTIME_CATCHUP_LIMIT = 500; + +type CursorSource = "activity" | "run"; + +export interface RealtimeCursor { + at: string; + source: CursorSource; + id: string; +} + +export interface RealtimeReplayEvent extends RealtimeEvent { + cursor: string; +} + +function sourceRank(source: CursorSource): number { + return source === "activity" ? 0 : 1; +} + +export function compareRealtimeCursors(a: RealtimeCursor, b: RealtimeCursor): number { + const time = Date.parse(a.at) - Date.parse(b.at); + if (time !== 0) return time; + const source = sourceRank(a.source) - sourceRank(b.source); + if (source !== 0) return source; + return a.id.localeCompare(b.id); +} + +export function encodeRealtimeCursor(cursor: RealtimeCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +export function decodeRealtimeCursor(raw: string | null | undefined): RealtimeCursor | null { + if (!raw || raw.length > 512) return null; + try { + const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as Partial; + if ( + typeof parsed.at !== "string" || + Number.isNaN(Date.parse(parsed.at)) || + (parsed.source !== "activity" && parsed.source !== "run") || + typeof parsed.id !== "string" || + parsed.id.length === 0 || + parsed.id.length > 128 + ) { + return null; + } + return { at: new Date(parsed.at).toISOString(), source: parsed.source, id: parsed.id }; + } catch { + return null; + } +} + +/** + * Read the durable events missed after a browser cursor. Activity events and + * granular run events live in separate append-only tables, so this establishes + * a stable cross-table order of `(createdAt, source, id)` and maps both back to + * the existing realtime wire shape. + */ +export async function loadRealtimeCatchup( + db: PrismaClient, + workspaceId: string, + cursor: RealtimeCursor, + limit = REALTIME_CATCHUP_LIMIT, +): Promise<{ events: RealtimeReplayEvent[]; truncated: boolean }> { + const at = new Date(cursor.at); + const [activityRows, runRows] = await Promise.all([ + db.activityEvent.findMany({ + where: { + workspaceId, + // Deliberately overlap the cursor millisecond. PostgreSQL timestamps + // are exposed through JS at millisecond precision and two concurrent + // rows can publish in a different order than their lexical ids. A + // small duplicate replay is safer than skipping the late row. + createdAt: { gte: at }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: limit + 1, + }), + db.agentRunEvent.findMany({ + where: { + workspaceId, + createdAt: { gte: at }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: limit + 1, + include: { + run: { select: { issueId: true, agentId: true, currentStep: true } }, + }, + }), + ]); + + const merged = [ + ...activityRows.map((row) => ({ source: "activity" as const, row })), + ...runRows.map((row) => ({ source: "run" as const, row })), + ].sort((a, b) => { + const time = a.row.createdAt.getTime() - b.row.createdAt.getTime(); + if (time !== 0) return time; + const source = sourceRank(a.source) - sourceRank(b.source); + if (source !== 0) return source; + return a.row.id.localeCompare(b.row.id); + }); + + const truncated = merged.length > limit; + const events = merged.slice(0, limit).map(({ source, row }) => { + const cursorValue = encodeRealtimeCursor({ + at: row.createdAt.toISOString(), + source, + id: row.id, + }); + if (source === "activity") { + return { + id: row.id, + workspaceId: row.workspaceId, + kind: row.kind, + subjectType: row.subjectType, + subjectId: row.subjectId, + payload: row.payload, + actorId: row.actorId, + createdAt: row.createdAt.toISOString(), + cursor: cursorValue, + } satisfies RealtimeReplayEvent; + } + return { + id: row.id, + workspaceId: row.workspaceId, + kind: EventKind.AGENT_RUN_STEP, + subjectType: "agent-run", + subjectId: row.runId, + payload: { + eventKind: row.kind, + currentStep: row.run.currentStep, + runId: row.runId, + issueId: row.run.issueId, + agentId: row.run.agentId, + replayed: true, + }, + actorId: null, + createdAt: row.createdAt.toISOString(), + cursor: cursorValue, + } satisfies RealtimeReplayEvent; + }); + + return { events, truncated }; +} + +export function cursorForRealtimeEvent(evt: RealtimeEvent): string { + return encodeRealtimeCursor({ + at: new Date(evt.createdAt).toISOString(), + source: + evt.subjectType === "agent-run" && evt.kind === EventKind.AGENT_RUN_STEP + ? "run" + : "activity", + id: evt.id, + }); +} diff --git a/src/server/realtime.ts b/src/server/realtime.ts index d7eaa025..b73e280b 100644 --- a/src/server/realtime.ts +++ b/src/server/realtime.ts @@ -7,7 +7,7 @@ import type { EventKind } from "@prisma/client"; * * Publishers (API routes / tRPC mutations) call `publish(workspaceId, evt)`. * Subscribers live in two places: - * 1. WebSocket route (`/api/realtime`) that fans out to connected browsers. + * 1. SSE route (`/api/realtime`) that fans out to connected browsers. * 2. Plugin webhook worker that enqueues deliveries to subscribed plugins. * * Channel convention: `forge:ws:` — one channel per workspace @@ -52,3 +52,35 @@ export function subscribe( await sub.quit(); }; } + +/** + * Subscribe and wait until Redis confirms the channel subscription. The + * browser SSE endpoint uses this variant so it can buffer live messages before + * reading the durable catch-up window, closing the otherwise tiny + * query-before-subscribe race. Existing plugin callers keep the synchronous + * `subscribe` API above. + */ +export async function subscribeReady( + workspaceId: string, + onMessage: (evt: RealtimeEvent) => void, +): Promise<() => Promise> { + const ch = channel(workspaceId); + const sub = redisSub.duplicate(); + sub.on("message", (_channel, raw) => { + try { + onMessage(JSON.parse(raw) as RealtimeEvent); + } catch { + // swallow malformed payloads — producer contract violated + } + }); + try { + await sub.subscribe(ch); + } catch (error) { + await sub.quit().catch(() => void 0); + throw error; + } + return async () => { + await sub.unsubscribe(ch); + await sub.quit(); + }; +} diff --git a/src/server/routers/__tests__/workspace-members.test.ts b/src/server/routers/__tests__/workspace-members.test.ts index dab7d203..7cf51beb 100644 --- a/src/server/routers/__tests__/workspace-members.test.ts +++ b/src/server/routers/__tests__/workspace-members.test.ts @@ -59,6 +59,26 @@ describe("workspaceRouter — admin member management", () => { expect(workspace.agentRunStaleMinutes).toBe(45); }); + it("updates agent progress cadence and non-terminal quiet threshold", async () => { + const { caller, fixture } = await adminSetup(); + const prisma = getPrisma(); + + await caller.update({ + agentProgressUpdateMinutes: 8, + agentRunQuietMinutes: 12, + }); + + const workspace = await prisma.workspace.findUniqueOrThrow({ + where: { id: fixture.workspace.id }, + select: { + agentProgressUpdateMinutes: true, + agentRunQuietMinutes: true, + }, + }); + expect(workspace.agentProgressUpdateMinutes).toBe(8); + expect(workspace.agentRunQuietMinutes).toBe(12); + }); + it("updates the reviewer-start fallback window", async () => { const { caller, fixture } = await adminSetup(); const prisma = getPrisma(); diff --git a/src/server/routers/agent-run.ts b/src/server/routers/agent-run.ts index 667ec7df..d7e13894 100644 --- a/src/server/routers/agent-run.ts +++ b/src/server/routers/agent-run.ts @@ -47,6 +47,10 @@ type RunWithPolicyInputs = { expectedOutput?: string | null; verificationChecklist?: Prisma.JsonValue | null; artifactRequired?: boolean; + workspace?: { + agentProgressUpdateMinutes: number; + agentRunQuietMinutes: number; + }; } | null; agent: { provider?: string | null; @@ -84,6 +88,12 @@ function enrichRun(run: T) { artifactRequired: run.issue.artifactRequired ?? false, } : null, + ...(run.issue?.workspace + ? { + progressUpdateMs: run.issue.workspace.agentProgressUpdateMinutes * 60_000, + quietMs: run.issue.workspace.agentRunQuietMinutes * 60_000, + } + : {}), }), }; } @@ -159,6 +169,12 @@ export const agentRunRouter = router({ expectedOutput: true, verificationChecklist: true, artifactRequired: true, + workspace: { + select: { + agentProgressUpdateMinutes: true, + agentRunQuietMinutes: true, + }, + }, }, }, statusComment: { @@ -270,7 +286,14 @@ export const agentRunRouter = router({ verificationChecklist: true, artifactRequired: true, status: { select: { id: true, name: true, category: true, color: true } }, - workspace: { select: { key: true, slug: true } }, + workspace: { + select: { + key: true, + slug: true, + agentProgressUpdateMinutes: true, + agentRunQuietMinutes: true, + }, + }, }, }, statusComment: { @@ -437,7 +460,14 @@ export const agentRunRouter = router({ expectedOutput: true, verificationChecklist: true, artifactRequired: true, - workspace: { select: { key: true, slug: true } }, + workspace: { + select: { + key: true, + slug: true, + agentProgressUpdateMinutes: true, + agentRunQuietMinutes: true, + }, + }, }, }, }, diff --git a/src/server/routers/workspace.ts b/src/server/routers/workspace.ts index 725977a2..534071f1 100644 --- a/src/server/routers/workspace.ts +++ b/src/server/routers/workspace.ts @@ -124,6 +124,8 @@ export const workspaceRouter = router({ aiProvider: true, aiModel: true, agentRunStaleMinutes: true, + agentProgressUpdateMinutes: true, + agentRunQuietMinutes: true, reviewStartTimeoutMinutes: true, ephemeralAgentIdleMinutes: true, runTokenBudget: true, @@ -268,6 +270,8 @@ export const workspaceRouter = router({ agentIdleTimeoutMinutes: z.number().int().min(0).max(1440).optional(), assignmentSlaMinutes: z.number().int().min(0).max(10080).optional(), agentRunStaleMinutes: z.number().int().min(0).max(10080).optional(), + agentProgressUpdateMinutes: z.number().int().min(0).max(1440).optional(), + agentRunQuietMinutes: z.number().int().min(0).max(10080).optional(), reviewStartTimeoutMinutes: z.number().int().min(0).max(10080).optional(), ephemeralAgentIdleMinutes: z.number().int().min(0).max(10080).optional(), // Per-run safety budgets (null = unlimited; opt-in). See run-budget.ts. diff --git a/src/server/services/__tests__/engagement-mode.test.ts b/src/server/services/__tests__/engagement-mode.test.ts index 851ac0f4..6073dfde 100644 --- a/src/server/services/__tests__/engagement-mode.test.ts +++ b/src/server/services/__tests__/engagement-mode.test.ts @@ -19,7 +19,11 @@ const ws: WorkspaceEngagementConfig = { describe("resolveEngagementMode", () => { it("explicit override wins on every surface", () => { for (const surface of ["assignment", "mention", "chat", "plan", "watcher", "queue"] as const) { - const r = resolveEngagementMode({ surface, explicit: EngagementMode.RESEARCH, workspace: ws }); + const r = resolveEngagementMode({ + surface, + explicit: EngagementMode.RESEARCH, + workspace: ws, + }); expect(r.mode).toBe(EngagementMode.RESEARCH); expect(r.source).toBe("explicit"); } @@ -66,9 +70,10 @@ describe("resolveEngagementMode", () => { }), ).toMatchObject({ mode: "RESEARCH", source: "sticky-active-run" }); // none set → surface default - expect( - resolveEngagementMode({ surface: "assignment", workspace: ws }), - ).toMatchObject({ mode: "EXECUTE", source: "surface-default" }); + expect(resolveEngagementMode({ surface: "assignment", workspace: ws })).toMatchObject({ + mode: "EXECUTE", + source: "surface-default", + }); }); it("null/undefined tiers are skipped (don't short-circuit)", () => { @@ -103,8 +108,13 @@ describe("resolveEngagementMode", () => { it("assignment + queue use the workspace assignment default", () => { expect(resolveEngagementMode({ surface: "assignment", workspace: ws }).mode).toBe("EXECUTE"); expect(resolveEngagementMode({ surface: "queue", workspace: ws }).mode).toBe("EXECUTE"); - const research: WorkspaceEngagementConfig = { ...ws, assignmentEngagementMode: EngagementMode.RESEARCH }; - expect(resolveEngagementMode({ surface: "assignment", workspace: research }).mode).toBe("RESEARCH"); + const research: WorkspaceEngagementConfig = { + ...ws, + assignmentEngagementMode: EngagementMode.RESEARCH, + }; + expect(resolveEngagementMode({ surface: "assignment", workspace: research }).mode).toBe( + "RESEARCH", + ); }); it("assignment + queue honor the agent binding override before the workspace default", () => { @@ -138,7 +148,10 @@ describe("resolveEngagementMode", () => { }); it("mention FIXED → default mode, not inferable", () => { - const fixed: WorkspaceEngagementConfig = { ...ws, mentionEngagementPolicy: MentionEngagementPolicy.FIXED }; + const fixed: WorkspaceEngagementConfig = { + ...ws, + mentionEngagementPolicy: MentionEngagementPolicy.FIXED, + }; const r = resolveEngagementMode({ surface: "mention", workspace: fixed }); expect(r.mode).toBe("DISCUSS"); expect(r.source).toBe("policy-fixed"); @@ -146,15 +159,25 @@ describe("resolveEngagementMode", () => { }); it("mention REQUIRE_MARKER → DISCUSS (no execution without a marker)", () => { - const req: WorkspaceEngagementConfig = { ...ws, mentionEngagementPolicy: MentionEngagementPolicy.REQUIRE_MARKER }; + const req: WorkspaceEngagementConfig = { + ...ws, + mentionEngagementPolicy: MentionEngagementPolicy.REQUIRE_MARKER, + }; const r = resolveEngagementMode({ surface: "mention", workspace: req }); expect(r.mode).toBe("DISCUSS"); expect(r.source).toBe("policy-require-marker"); }); it("mention with explicit marker beats REQUIRE_MARKER", () => { - const req: WorkspaceEngagementConfig = { ...ws, mentionEngagementPolicy: MentionEngagementPolicy.REQUIRE_MARKER }; - const r = resolveEngagementMode({ surface: "mention", explicit: EngagementMode.EXECUTE, workspace: req }); + const req: WorkspaceEngagementConfig = { + ...ws, + mentionEngagementPolicy: MentionEngagementPolicy.REQUIRE_MARKER, + }; + const r = resolveEngagementMode({ + surface: "mention", + explicit: EngagementMode.EXECUTE, + workspace: req, + }); expect(r.mode).toBe("EXECUTE"); expect(r.source).toBe("explicit"); }); @@ -183,6 +206,33 @@ describe("resolveEngagementMode", () => { expect(text).toContain("runs.complete"); }); + it("separates semantic status checkpoints from the automatic mechanical trace", () => { + const text = forgeRunProtocolInstruction(); + expect(text).toContain( + "Automatic runtime events and tool-call events are the mechanical trace", + ); + expect(text).toContain("comments.upsertStatus"); + expect(text).toContain("when the work changes phase"); + expect(text).toContain("outcome so far and the next action"); + expect(text).toContain("never publish chain-of-thought"); + }); + + it("injects the workspace semantic-checkpoint cadence when configured", () => { + expect(forgeRunProtocolInstruction({ progressUpdateMinutes: 1 })).toContain( + "at least every 1 minute", + ); + expect(forgeRunProtocolInstruction({ progressUpdateMinutes: 12 })).toContain( + "at least every 12 minutes", + ); + }); + + it("keeps phase-change checkpoints but disables timed reminders when cadence is zero", () => { + const text = forgeRunProtocolInstruction({ progressUpdateMinutes: 0 }); + expect(text).toContain("cadence reminders are disabled"); + expect(text).toContain("meaningful phase changes"); + expect(text).not.toContain("at least every 0 minutes"); + }); + it("forgeRunInstruction combines mode contract with run protocol", () => { const text = forgeRunInstruction({ mode: EngagementMode.RESEARCH, diff --git a/src/server/services/__tests__/realtime-catchup.test.ts b/src/server/services/__tests__/realtime-catchup.test.ts new file mode 100644 index 00000000..779d86d8 --- /dev/null +++ b/src/server/services/__tests__/realtime-catchup.test.ts @@ -0,0 +1,157 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { EventKind } from "@prisma/client"; +import { + compareRealtimeCursors, + decodeRealtimeCursor, + encodeRealtimeCursor, + loadRealtimeCatchup, +} from "@/server/realtime-catchup"; +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("realtime durable catch-up", () => { + it("round-trips opaque cursors and rejects malformed input", () => { + const cursor = { + at: "2026-07-13T12:00:00.000Z", + source: "run" as const, + id: "event-123", + }; + expect(decodeRealtimeCursor(encodeRealtimeCursor(cursor))).toEqual(cursor); + expect(decodeRealtimeCursor("not-a-cursor")).toBeNull(); + expect( + compareRealtimeCursors( + { ...cursor, source: "activity", id: "z" }, + { ...cursor, source: "run", id: "a" }, + ), + ).toBeLessThan(0); + }); + + it("merges missed activity and granular run rows without crossing tenants", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RTC" }); + const other = await createWorkspaceFixture({ keyPrefix: "RTO" }); + fixtures.push(fixture, other); + const prisma = getPrisma(); + const issue = await createIssue(fixture); + const otherIssue = await createIssue(other); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Realtime agent", + profileKey: "realtime-agent", + }, + }); + const otherAgent = await prisma.agent.create({ + data: { + workspaceId: other.workspace.id, + name: "Other agent", + profileKey: "other-realtime-agent", + }, + }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + currentStep: "verifying", + }, + }); + const otherRun = await prisma.agentRun.create({ + data: { + workspaceId: other.workspace.id, + issueId: otherIssue.id, + agentId: otherAgent.id, + }, + }); + const base = new Date("2026-07-13T12:00:00.000Z"); + const activity = await prisma.activityEvent.create({ + data: { + workspaceId: fixture.workspace.id, + kind: EventKind.ISSUE_UPDATED, + subjectType: "issue", + subjectId: issue.id, + payload: { title: "Updated" }, + createdAt: new Date(base.getTime() + 1_000), + }, + }); + const runEvent = await prisma.agentRunEvent.create({ + data: { + workspaceId: fixture.workspace.id, + runId: run.id, + kind: "TOOL_CALL", + payload: { tool: "typecheck" }, + createdAt: new Date(base.getTime() + 2_000), + }, + }); + await prisma.agentRunEvent.create({ + data: { + workspaceId: other.workspace.id, + runId: otherRun.id, + kind: "STEP", + createdAt: new Date(base.getTime() + 1_500), + }, + }); + + const result = await loadRealtimeCatchup(prisma, fixture.workspace.id, { + at: base.toISOString(), + source: "activity", + id: "before", + }); + + expect(result.truncated).toBe(false); + expect(result.events.map((event) => event.id)).toEqual([activity.id, runEvent.id]); + expect(result.events[1]).toMatchObject({ + kind: EventKind.AGENT_RUN_STEP, + subjectType: "agent-run", + subjectId: run.id, + payload: { + eventKind: "TOOL_CALL", + currentStep: "verifying", + issueId: issue.id, + agentId: agent.id, + replayed: true, + }, + }); + expect(result.events.every((event) => decodeRealtimeCursor(event.cursor))).toBe(true); + }); + + it("signals a bounded replay overflow so clients can reconcile", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RTL" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const base = new Date("2026-07-13T12:00:00.000Z"); + await prisma.activityEvent.createMany({ + data: [1, 2, 3].map((second) => ({ + workspaceId: fixture.workspace.id, + kind: EventKind.ISSUE_UPDATED, + subjectType: "workspace", + subjectId: fixture.workspace.id, + payload: { second }, + createdAt: new Date(base.getTime() + second * 1_000), + })), + }); + + const result = await loadRealtimeCatchup( + prisma, + fixture.workspace.id, + { at: base.toISOString(), source: "activity", id: "before" }, + 2, + ); + expect(result.events).toHaveLength(2); + expect(result.truncated).toBe(true); + }); +}); diff --git a/src/server/services/__tests__/run-protocol-diagnostics.test.ts b/src/server/services/__tests__/run-protocol-diagnostics.test.ts new file mode 100644 index 00000000..09bc2cb4 --- /dev/null +++ b/src/server/services/__tests__/run-protocol-diagnostics.test.ts @@ -0,0 +1,155 @@ +import { AgentRunStatus, EngagementMode } from "@prisma/client"; +import { describe, expect, it } from "vitest"; +import { + deriveRunProtocolSignals, + runProtocolDiagnostics, +} from "@/server/services/run-protocol-diagnostics"; + +const now = new Date("2026-07-13T12:10:00.000Z"); + +function run( + overrides: Partial[0]["run"]> = {}, +): Parameters[0]["run"] { + return { + status: AgentRunStatus.ACTIVE, + engagementMode: EngagementMode.EXECUTE, + acknowledgedAt: new Date("2026-07-13T12:00:05.000Z"), + outputStartedAt: new Date("2026-07-13T12:00:10.000Z"), + lastEventAt: new Date("2026-07-13T12:09:50.000Z"), + statusComment: { updatedAt: new Date("2026-07-13T12:09:00.000Z") }, + ...overrides, + }; +} + +describe("deriveRunProtocolSignals", () => { + it("tracks the four protocol signals independently", () => { + expect(deriveRunProtocolSignals({ run: run(), now })).toEqual({ + acknowledgement: { + state: "RECORDED", + at: new Date("2026-07-13T12:00:05.000Z"), + }, + output: { state: "RECORDED", at: new Date("2026-07-13T12:00:10.000Z") }, + progress: { state: "CURRENT", at: new Date("2026-07-13T12:09:00.000Z") }, + completion: { state: "PENDING", at: null }, + }); + }); + + it("does not let fresh mechanical events disguise a quiet semantic checkpoint", () => { + const signals = deriveRunProtocolSignals({ + run: run({ + lastEventAt: new Date("2026-07-13T12:09:59.000Z"), + statusComment: { updatedAt: new Date("2026-07-13T12:00:00.000Z") }, + }), + now, + }); + + expect(signals.progress).toMatchObject({ state: "QUIET" }); + expect(signals.completion).toMatchObject({ state: "PENDING" }); + }); + + it("distinguishes loaded-but-missing progress from progress not loaded by the caller", () => { + expect( + deriveRunProtocolSignals({ run: run({ statusComment: null }), now }).progress.state, + ).toBe("MISSING"); + + const withoutStatusRelation = run(); + delete withoutStatusRelation.statusComment; + expect(deriveRunProtocolSignals({ run: withoutStatusRelation, now }).progress.state).toBe( + "UNKNOWN", + ); + }); + + it("disables timed semantic-progress warnings when workspace cadence is zero", () => { + const target = run({ + statusComment: null, + outputStartedAt: new Date("2026-07-13T10:00:00.000Z"), + }); + expect( + deriveRunProtocolSignals({ run: target, now, progressUpdateMs: 0 }).progress.state, + ).toBe("UNKNOWN"); + expect(runProtocolDiagnostics({ run: target, now, progressUpdateMs: 0 })).toEqual([]); + }); + + it("keeps an intentional WAITING run out of quiet progress", () => { + const signals = deriveRunProtocolSignals({ + run: run({ + status: AgentRunStatus.WAITING, + lastEventAt: new Date("2026-07-13T10:00:00.000Z"), + statusComment: null, + }), + now, + }); + + expect(signals.progress.state).toBe("WAITING"); + expect(signals.completion.state).toBe("PENDING"); + }); + + it("represents canonical terminal outcomes without inferring them from age", () => { + expect( + deriveRunProtocolSignals({ run: run({ status: AgentRunStatus.STALLED }), now }).completion + .state, + ).toBe("FAILED"); + expect( + deriveRunProtocolSignals({ run: run({ status: AgentRunStatus.COMPLETED }), now }).completion + .state, + ).toBe("RECORDED"); + }); +}); + +describe("runProtocolDiagnostics", () => { + it("reports quiet semantic progress even while mechanical events remain fresh", () => { + const diagnostics = runProtocolDiagnostics({ + run: run({ + lastEventAt: new Date("2026-07-13T12:09:59.000Z"), + statusComment: { updatedAt: new Date("2026-07-13T12:00:00.000Z") }, + }), + now, + }); + + expect(diagnostics.map((item) => item.code)).toEqual(["progress-quiet"]); + }); + + it("calls an old active run quiet and completion-missing, not canonically stalled", () => { + const diagnostics = runProtocolDiagnostics({ + run: run({ + lastEventAt: new Date("2026-07-13T12:00:00.000Z"), + statusComment: { updatedAt: new Date("2026-07-13T12:09:00.000Z") }, + }), + now, + }); + + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "completion-missing", + title: "Completion not recorded", + }), + ]); + expect(diagnostics[0]?.description).toContain("not canonically stalled"); + }); + + it("disables the quiet-run completion reminder when the workspace threshold is zero", () => { + const diagnostics = runProtocolDiagnostics({ + run: run({ + lastEventAt: new Date("2026-07-13T10:00:00.000Z"), + statusComment: { updatedAt: new Date("2026-07-13T12:09:00.000Z") }, + }), + now, + quietMs: 0, + }); + + expect(diagnostics).toEqual([]); + }); + + it("does not raise quiet-progress or completion reminders for WAITING runs", () => { + const diagnostics = runProtocolDiagnostics({ + run: run({ + status: AgentRunStatus.WAITING, + lastEventAt: new Date("2026-07-13T10:00:00.000Z"), + statusComment: null, + }), + now, + }); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/src/server/services/agent-presence.ts b/src/server/services/agent-presence.ts index 8ffb834c..4086816d 100644 --- a/src/server/services/agent-presence.ts +++ b/src/server/services/agent-presence.ts @@ -1,15 +1,15 @@ import "server-only"; /** - * Server-side mirror of the UI "stalled run" threshold. Re-exports the + * Server-side mirror of the UI "quiet run" threshold. Re-exports the * value from `src/lib/agent-stale.ts` so both client and server stay * agreed on the number — but isolates the import so server code doesn't * pull a `"use client"` boundary unnecessarily. * - * Use this for any tRPC proc that filters runs as "stalled in the UI - * sense" (agent detail page, dashboard agent-activity tile, kick + * Use this for any tRPC proc that filters active runs as "quiet / needing + * attention" (agent detail page, dashboard agent-activity tile, kick * eligibility). The auto-close watchdog uses * `Workspace.agentRunStaleMinutes` instead — that's a different knob, * intentionally per-workspace and tunable. */ -export { STALE_RUN_MS } from "@/lib/agent-stale"; +export { QUIET_RUN_MS, STALE_RUN_MS } from "@/lib/agent-stale"; diff --git a/src/server/services/agent-run.ts b/src/server/services/agent-run.ts index b5d578ae..5ce2f764 100644 --- a/src/server/services/agent-run.ts +++ b/src/server/services/agent-run.ts @@ -41,6 +41,8 @@ const OUTPUT_EVENT_KINDS = new Set(["COMMENT", "STATUS", "TRANSITION", "STEP", " * how to consume (subjectType + subjectId + payload + kind). */ function publishRunEvent(params: { + eventId?: string; + createdAt?: Date; workspaceId: string; kind: EventKind; runId: string; @@ -49,7 +51,7 @@ function publishRunEvent(params: { payload?: Record; }): void { void publish({ - id: nanoid(), + id: params.eventId ?? nanoid(), workspaceId: params.workspaceId, kind: params.kind, subjectType: "agent-run", @@ -61,7 +63,7 @@ function publishRunEvent(params: { agentId: params.agentId, }, actorId: null, - createdAt: new Date().toISOString(), + createdAt: (params.createdAt ?? new Date()).toISOString(), }); } @@ -364,7 +366,7 @@ export async function appendRunEvent( }, ): Promise { const now = new Date(); - await tx.agentRunEvent.create({ + const event = await tx.agentRunEvent.create({ data: { workspaceId: params.workspaceId, runId: params.runId, @@ -390,6 +392,8 @@ export async function appendRunEvent( // row. The events table is the durable timeline; this is just for the // live strip to react in real time. publishRunEvent({ + eventId: event.id, + createdAt: event.createdAt, workspaceId: params.workspaceId, kind: EventKind.AGENT_RUN_STEP, runId: params.runId, diff --git a/src/server/services/dispatch/run-dispatcher.ts b/src/server/services/dispatch/run-dispatcher.ts index 52b9155e..3675685c 100644 --- a/src/server/services/dispatch/run-dispatcher.ts +++ b/src/server/services/dispatch/run-dispatcher.ts @@ -251,7 +251,7 @@ async function startNewRuns(): Promise { title: true, description: true, assignedAgentId: true, - workspace: { select: { key: true } }, + workspace: { select: { key: true, agentProgressUpdateMinutes: true } }, project: { select: { repoUrl: true, repoBranch: true } }, assignedAgent: { select: { @@ -300,11 +300,14 @@ async function startNewRuns(): Promise { const engagementMode = (evtPayload.engagementMode as "EXECUTE" | "RESEARCH" | "REVIEW" | "DISCUSS" | undefined) ?? "EXECUTE"; - const instruction = forgeRunInstruction({ - mode: engagementMode, - source: "surface-default", - inferable: false, - }); + const instruction = forgeRunInstruction( + { + mode: engagementMode, + source: "surface-default", + inferable: false, + }, + { progressUpdateMinutes: issue.workspace.agentProgressUpdateMinutes }, + ); const runtimePolicy = storedRuntimePolicy(already?.runtimePolicy, engagementMode) ?? buildRuntimePolicySnapshot({ @@ -453,7 +456,7 @@ async function startUnbackedAgentRuns(limit: number): Promise { number: true, title: true, description: true, - workspace: { select: { key: true } }, + workspace: { select: { key: true, agentProgressUpdateMinutes: true } }, }, }), db.agent.findUnique({ @@ -493,11 +496,14 @@ async function startUnbackedAgentRuns(limit: number): Promise { if (!connector) continue; const engagementMode = run.engagementMode; - const instruction = forgeRunInstruction({ - mode: engagementMode, - source: "surface-default", - inferable: false, - }); + const instruction = forgeRunInstruction( + { + mode: engagementMode, + source: "surface-default", + inferable: false, + }, + { progressUpdateMinutes: issue.workspace.agentProgressUpdateMinutes }, + ); const runtimePolicy = storedRuntimePolicy(run.runtimePolicy, engagementMode) ?? buildRuntimePolicySnapshot({ @@ -645,7 +651,7 @@ async function resumeWaitingRuns(limit: number): Promise { number: true, title: true, description: true, - workspace: { select: { key: true } }, + workspace: { select: { key: true, agentProgressUpdateMinutes: true } }, }, }, agent: { @@ -704,11 +710,14 @@ async function resumeWaitingRuns(limit: number): Promise { if (replies.length === 0) continue; const engagementMode = run.engagementMode; - const instruction = forgeRunInstruction({ - mode: engagementMode, - source: "surface-default", - inferable: false, - }); + const instruction = forgeRunInstruction( + { + mode: engagementMode, + source: "surface-default", + inferable: false, + }, + { progressUpdateMinutes: run.issue.workspace.agentProgressUpdateMinutes }, + ); const runtimePolicy = storedRuntimePolicy(run.runtimePolicy, engagementMode) ?? buildRuntimePolicySnapshot({ diff --git a/src/server/services/engagement-mode.ts b/src/server/services/engagement-mode.ts index 3df61858..6ab512b9 100644 --- a/src/server/services/engagement-mode.ts +++ b/src/server/services/engagement-mode.ts @@ -13,15 +13,9 @@ import { * unit-testable. */ -export const FORGE_RUN_CONTRACT_VERSION = "2026-06-06.2"; +export const FORGE_RUN_CONTRACT_VERSION = "2026-07-13.1"; -export type EngagementSurface = - | "assignment" - | "queue" - | "mention" - | "chat" - | "plan" - | "watcher"; +export type EngagementSurface = "assignment" | "queue" | "mention" | "chat" | "plan" | "watcher"; export type EngagementSource = | "explicit" @@ -61,9 +55,7 @@ const VALID_SOURCES = new Set(Object.keys(SOURCE_TO_ENUM)); * (surface-default / explicit) instead of the generic "payload" tier. */ export function asEngagementSource(value: unknown): EngagementSource | null { - return typeof value === "string" && VALID_SOURCES.has(value) - ? (value as EngagementSource) - : null; + return typeof value === "string" && VALID_SOURCES.has(value) ? (value as EngagementSource) : null; } export interface ResolvedEngagement { @@ -128,8 +120,7 @@ export function resolveEngagementMode(input: { case "queue": return { mode: - input.workspace.assignmentAgentEngagementMode ?? - input.workspace.assignmentEngagementMode, + input.workspace.assignmentAgentEngagementMode ?? input.workspace.assignmentEngagementMode, source: "surface-default", inferable: false, }; @@ -148,7 +139,11 @@ export function resolveEngagementMode(input: { inferable: false, }; case MentionEngagementPolicy.REQUIRE_MARKER: - return { mode: EngagementMode.DISCUSS, source: "policy-require-marker", inferable: false }; + return { + mode: EngagementMode.DISCUSS, + source: "policy-require-marker", + inferable: false, + }; case MentionEngagementPolicy.INFER: default: // INFER: start from the conversational default but let the agent @@ -176,8 +171,13 @@ const RUN_PROTOCOL_INSTRUCTIONS = "substantive work. If you do not have a runId, call `agent.context.bundle` " + "with the issue id or `agent.inbox.list` to find the current run first. " + "When you begin producing output, call `agent.inbox.outputStarted({ runId })`. " + - "Use `comments.upsertStatus` only for meaningful human-facing checkpoints, " + - "not every internal thought. If blocked, call `runs.setWaiting({ runId, " + + "Automatic runtime events and tool-call events are the mechanical trace; do not " + + "copy them into comments. Use `comments.upsertStatus` for semantic, human-facing " + + "checkpoints: when the work changes phase (for example Inspecting, Implementing, " + + "or Verifying), when a material interim result changes the plan, and before " + + "continuing a long phase. State the outcome so far and the next action; never " + + "publish chain-of-thought, raw tool logs, or a status for every internal step. " + + "If blocked, call `runs.setWaiting({ runId, " + "reason, blocking: true })` and stop. Finish the run with `runs.complete`. " + "Include the mode-specific required fields when completing: EXECUTE supplies " + "artifact/checklist evidence when the issue contract requires it, RESEARCH " + @@ -186,6 +186,27 @@ const RUN_PROTOCOL_INSTRUCTIONS = "Non-EXECUTE modes are read/report/review only; Forge rejects issue-state " + "mutations from those runs."; +export interface ForgeRunProtocolOptions { + /** Workspace-requested semantic checkpoint cadence. Zero disables reminders. */ + progressUpdateMinutes?: number | null; +} + +function progressCadenceInstruction(options?: ForgeRunProtocolOptions): string { + const minutes = options?.progressUpdateMinutes; + if (minutes === undefined || minutes === null) return ""; + if (minutes <= 0) { + return ( + " Workspace cadence reminders are disabled; still post semantic checkpoints " + + "at meaningful phase changes, material findings, and blockers." + ); + } + return ( + ` While actively working, post a semantic status checkpoint at least every ${minutes} ` + + `minute${minutes === 1 ? "" : "s"} when no phase change or material finding has already ` + + "produced one." + ); +} + const BASE_INSTRUCTIONS: Record = { EXECUTE: "ENGAGEMENT MODE: EXECUTE. Take this to completion. The definition of done " + @@ -216,11 +237,14 @@ export function engagementInstruction(resolved: ResolvedEngagement): string { } /** Shared run lifecycle instructions injected into dispatched agent turns. */ -export function forgeRunProtocolInstruction(): string { - return RUN_PROTOCOL_INSTRUCTIONS; +export function forgeRunProtocolInstruction(options?: ForgeRunProtocolOptions): string { + return RUN_PROTOCOL_INSTRUCTIONS + progressCadenceInstruction(options); } /** Complete dispatch instruction block: mode contract + Forge run protocol. */ -export function forgeRunInstruction(resolved: ResolvedEngagement): string { - return `${engagementInstruction(resolved)}\n\n${forgeRunProtocolInstruction()}`; +export function forgeRunInstruction( + resolved: ResolvedEngagement, + options?: ForgeRunProtocolOptions, +): string { + return `${engagementInstruction(resolved)}\n\n${forgeRunProtocolInstruction(options)}`; } diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index be1e487a..b2bc5f67 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -7469,6 +7469,7 @@ export const mcpTools = { timeTrackingEnabled: true, attachmentQuotaMb: true, requiredAckSeconds: true, + agentProgressUpdateMinutes: true, autoDispatch: true, autoDispatchMode: true, }, @@ -8015,6 +8016,7 @@ export const mcpTools = { timeTrackingEnabled: true, attachmentQuotaMb: true, requiredAckSeconds: true, + agentProgressUpdateMinutes: true, autoDispatch: true, autoDispatchMode: true, }, @@ -8146,7 +8148,9 @@ export const mcpTools = { source: "surface-default", inferable: false, }), - protocolInstruction: forgeRunProtocolInstruction(), + protocolInstruction: forgeRunProtocolInstruction({ + progressUpdateMinutes: workspace.agentProgressUpdateMinutes, + }), mayMutateIssue: modeMayExecute(currentMode), }; diff --git a/src/server/services/run-protocol-diagnostics.ts b/src/server/services/run-protocol-diagnostics.ts index 806b6912..61fd02a2 100644 --- a/src/server/services/run-protocol-diagnostics.ts +++ b/src/server/services/run-protocol-diagnostics.ts @@ -12,7 +12,9 @@ import { export type RunProtocolDiagnosticCode = | "never-acked" | "acked-no-output" - | "output-not-completed" + | "progress-unreported" + | "progress-quiet" + | "completion-missing" | "completed-invalid"; export type RunProtocolDiagnostic = { @@ -24,6 +26,29 @@ export type RunProtocolDiagnostic = { const DEFAULT_STALE_MS = 5 * 60_000; +export type RunProtocolSignalState = + | "MISSING" + | "PENDING" + | "RECORDED" + | "CURRENT" + | "QUIET" + | "WAITING" + | "FAILED" + | "UNKNOWN"; + +export type RunProtocolSignal = { + state: RunProtocolSignalState; + at: Date | null; +}; + +export type RunProtocolSignals = { + acknowledgement: RunProtocolSignal; + output: RunProtocolSignal; + /** Semantic operator checkpoint, deliberately separate from mechanical events. */ + progress: RunProtocolSignal; + completion: RunProtocolSignal; +}; + type RunForDiagnostics = { status: AgentRunStatus; engagementMode: EngagementMode; @@ -35,8 +60,89 @@ type RunForDiagnostics = { verificationResult?: Prisma.JsonValue | null; followUps?: Prisma.JsonValue | null; completionMeta?: Prisma.JsonValue | null; + /** + * Undefined means the caller did not load semantic-status data. Null means it + * loaded the relation and the agent has not posted a checkpoint. + */ + statusComment?: { updatedAt: Date | string } | null; }; +function dateValue(value: Date | string | null | undefined): Date | null { + if (!value) return null; + return value instanceof Date ? value : new Date(value); +} + +/** + * Derive each run-protocol signal independently so clients do not have to + * infer protocol health from one overloaded "stale" flag. Mechanical event + * freshness (`lastEventAt`) never makes semantic progress current; only the + * rolling STATUS comment does that. + */ +export function deriveRunProtocolSignals(input: { + run: RunForDiagnostics; + now?: Date; + /** Timed semantic-checkpoint reminder. Zero disables timed progress warnings. */ + progressUpdateMs?: number; + /** @deprecated Use progressUpdateMs. */ + quietMs?: number; +}): RunProtocolSignals { + const { run } = input; + const now = input.now ?? new Date(); + const progressUpdateMs = input.progressUpdateMs ?? input.quietMs ?? DEFAULT_STALE_MS; + const acknowledgedAt = dateValue(run.acknowledgedAt); + const outputStartedAt = dateValue(run.outputStartedAt); + const lastEventAt = dateValue(run.lastEventAt); + const checkpointAt = dateValue(run.statusComment?.updatedAt); + const terminal = + run.status === "COMPLETED" || run.status === "ABANDONED" || run.status === "STALLED"; + + const acknowledgement: RunProtocolSignal = acknowledgedAt + ? { state: "RECORDED", at: acknowledgedAt } + : { state: "MISSING", at: null }; + const output: RunProtocolSignal = outputStartedAt + ? { state: "RECORDED", at: outputStartedAt } + : { state: acknowledgedAt ? "MISSING" : "PENDING", at: null }; + + let progress: RunProtocolSignal; + if (run.status === "WAITING") { + progress = { state: "WAITING", at: lastEventAt }; + } else if (terminal) { + progress = checkpointAt + ? { state: "RECORDED", at: checkpointAt } + : { state: run.statusComment === undefined ? "UNKNOWN" : "MISSING", at: null }; + } else if (!outputStartedAt) { + progress = { state: "PENDING", at: null }; + } else if (run.statusComment === undefined) { + progress = { state: "UNKNOWN", at: null }; + } else if (progressUpdateMs <= 0) { + progress = checkpointAt + ? { state: "RECORDED", at: checkpointAt } + : { state: "UNKNOWN", at: null }; + } else if (!checkpointAt) { + progress = { + state: + now.getTime() - outputStartedAt.getTime() >= progressUpdateMs ? "MISSING" : "PENDING", + at: null, + }; + } else { + progress = { + state: now.getTime() - checkpointAt.getTime() >= progressUpdateMs ? "QUIET" : "CURRENT", + at: checkpointAt, + }; + } + + let completion: RunProtocolSignal; + if (run.status === "COMPLETED") { + completion = { state: "RECORDED", at: lastEventAt }; + } else if (run.status === "STALLED" || run.status === "ABANDONED") { + completion = { state: "FAILED", at: lastEventAt }; + } else { + completion = { state: "PENDING", at: null }; + } + + return { acknowledgement, output, progress, completion }; +} + function arrayValue(value: Prisma.JsonValue | null | undefined): unknown[] { return Array.isArray(value) ? value : []; } @@ -51,8 +157,7 @@ function completionInput(run: RunForDiagnostics): RunCompletionInput { const meta = completionMetaRecord(run.completionMeta); const confidence = typeof meta.confidence === "string" ? (meta.confidence as RunCompletionConfidence) : undefined; - const verdict = - typeof meta.verdict === "string" ? (meta.verdict as RunReviewVerdict) : undefined; + const verdict = typeof meta.verdict === "string" ? (meta.verdict as RunReviewVerdict) : undefined; return { summary: run.summary ?? undefined, producedArtifactIds: run.producedArtifactIds ?? undefined, @@ -67,11 +172,20 @@ export function runProtocolDiagnostics(input: { run: RunForDiagnostics; issue?: IssueCompletionContract | null; now?: Date; + /** Workspace semantic STATUS cadence; zero disables timed progress warnings. */ + progressUpdateMs?: number; + /** Workspace quiet-run threshold used for possible missing completion. */ + quietMs?: number; + /** @deprecated Backward-compatible alias for quietMs. */ staleMs?: number; }): RunProtocolDiagnostic[] { const run = input.run; const out: RunProtocolDiagnostic[] = []; const inFlight = run.status === "ACTIVE" || run.status === "WAITING"; + const now = input.now ?? new Date(); + const quietMs = input.quietMs ?? input.staleMs ?? DEFAULT_STALE_MS; + const progressUpdateMs = input.progressUpdateMs ?? DEFAULT_STALE_MS; + const signals = deriveRunProtocolSignals({ run, now, progressUpdateMs }); if (inFlight && !run.acknowledgedAt) { out.push({ @@ -89,15 +203,34 @@ export function runProtocolDiagnostics(input: { }); } - if (inFlight && run.outputStartedAt) { + if (inFlight && run.status !== "WAITING" && signals.progress.state === "MISSING") { + out.push({ + code: "progress-unreported", + severity: "warning", + title: "Progress not reported", + description: + "The runtime is producing output, but the agent has not posted a semantic status checkpoint.", + }); + } else if (inFlight && run.status !== "WAITING" && signals.progress.state === "QUIET") { + out.push({ + code: "progress-quiet", + severity: "warning", + title: "Progress update is quiet", + description: + "The runtime may still be active, but its last operator-facing checkpoint is old.", + }); + } + + if (inFlight && run.status !== "WAITING" && run.outputStartedAt) { const last = typeof run.lastEventAt === "string" ? new Date(run.lastEventAt) : run.lastEventAt; - const idleMs = (input.now ?? new Date()).getTime() - last.getTime(); - if (idleMs >= (input.staleMs ?? DEFAULT_STALE_MS)) { + const idleMs = now.getTime() - last.getTime(); + if (quietMs > 0 && idleMs >= quietMs) { out.push({ - code: "output-not-completed", + code: "completion-missing", severity: "warning", - title: "Output started, not completed", - description: "The agent started producing output but has not closed the run with runs.complete.", + title: "Completion not recorded", + description: + "The run is quiet after output started and has not been closed with runs.complete. It is not canonically stalled until its status changes to STALLED.", }); } } @@ -121,4 +254,3 @@ export function runProtocolDiagnostics(input: { return out; } - diff --git a/tests/unit/agent-stale.test.ts b/tests/unit/agent-stale.test.ts new file mode 100644 index 00000000..79819f6a --- /dev/null +++ b/tests/unit/agent-stale.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { deriveRunFreshness } from "@/lib/agent-stale"; + +const now = new Date("2026-07-13T12:10:00.000Z"); + +describe("deriveRunFreshness", () => { + it("calls an active run quiet without promoting it to canonical STALLED", () => { + expect( + deriveRunFreshness({ + status: "ACTIVE", + lastEventAt: "2026-07-13T12:00:00.000Z", + now, + }), + ).toBe("QUIET"); + }); + + it("reserves STALLED for the persisted run status", () => { + expect( + deriveRunFreshness({ + status: "STALLED", + lastEventAt: "2026-07-13T12:09:59.000Z", + now, + }), + ).toBe("STALLED"); + }); + + it("does not age WAITING or terminal non-stalled runs into quiet", () => { + const old = "2026-07-13T11:00:00.000Z"; + expect(deriveRunFreshness({ status: "WAITING", lastEventAt: old, now })).toBe("LIVE"); + expect(deriveRunFreshness({ status: "COMPLETED", lastEventAt: old, now })).toBe("LIVE"); + expect(deriveRunFreshness({ status: "ABANDONED", lastEventAt: old, now })).toBe("LIVE"); + }); +}); From 989d4a9929378627738368514440251d2145b81b Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 13 Jul 2026 19:07:50 -0400 Subject: [PATCH 2/4] feat: redesign Mission Control operations surfaces --- DEVLOG.md | 27 + design-qa.md | 153 ++-- src/app/(app)/w/[slug]/layout.tsx | 72 +- .../global-shell/global-operations-model.ts | 63 ++ .../global-shell/mission-control-home.tsx | 719 ++++++++++++------ .../mission-control/mission-control.tsx | 262 +++---- .../mission-control/operations-model.ts | 20 + src/components/mission-control/queue-tab.tsx | 455 ++++++++--- .../mission-control/settings-popover.tsx | 13 +- .../global-mission-control-operations.test.ts | 49 ++ tests/unit/mission-control-operations.test.ts | 22 + 11 files changed, 1201 insertions(+), 654 deletions(-) create mode 100644 src/components/global-shell/global-operations-model.ts create mode 100644 src/components/mission-control/operations-model.ts create mode 100644 tests/unit/global-mission-control-operations.test.ts create mode 100644 tests/unit/mission-control-operations.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 1682af59..4633f6c6 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,33 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-13 — Mission Control operator hierarchy + +Audited the cross-workspace Mission Control overview and workspace quick-access +dock against seeded local data at desktop and mobile viewports, then implemented +the selected Operations Shelf direction across both surfaces. Expanded workspace +Mission Control now reflows content above a summary / triage queue / agent +presence shelf on desktop and becomes a near-full-height, single-scroll sheet on +mobile. The persistent pill and glance clear the mobile bottom navigation. + +The global overview now leads with runtime and dispatch posture, then separates +workspace queue, assigned attention, agent presence, runtime coverage, and +recent activity into a coherent operator scan. Its summary is derived from real +global query data, retains read-only navigation to durable surfaces, and renders +independent loading, empty, and retry states without inventing mutation paths. + +Corrected queue truth while restructuring the surface: tab and pill badges now +show the total queue, summary copy keeps total and unassigned counts distinct, +and issue references use the workspace's canonical `FRG-*` key. Added explicit +queue/agent loading and error states, real tab semantics, one Collapse action, +platform-correct shortcut copy, 44 px mobile controls, and focused model tests. + +Verification: `pnpm lint` passed with existing repository warnings only, +`pnpm typecheck` passed, focused Vitest passed (**5/5**), `git diff --check` +passed, desktop/mobile interaction and responsive checks passed in the in-app +browser for both surfaces, browser logs contained no attributable warnings or +errors, and `design-qa.md` finished with `final result: passed`. + ## 2026-07-13 — Issue Workstream + durable realtime progress Consolidated the issue's repeated agent-status surfaces into one first-class diff --git a/design-qa.md b/design-qa.md index 2b12c0c2..da4c494d 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,102 +1,73 @@ -# Dashboard operations + workspace flow design QA +# Mission Control operations — design QA -**Source visual truth** +## Source and implementation evidence -- `/home/bailey/.codex/attachments/2dfba31e-74c5-4afb-9a5e-42a2706d62a2/codex-clipboard-04bd5e13-4b34-4306-936f-d6f6703abe96.png` -- The screenshot establishes the real production density and unused Live Operations space. The accompanying direction establishes the intended changes: Pulse + Schedule in that top area and What's New in the right-hand Workspace Flow slot. +### Workspace Operations Shelf -**Implementation evidence** +- Source visual truth: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\11-direction-3.png` +- Desktop implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\21-workspace-operations-desktop.png` +- Mobile implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\22-workspace-operations-mobile.png` +- Full-view comparison: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\24-workspace-qa-comparison.png` +- Viewports: 1536 x 1024 desktop; 390 x 844 mobile +- State: Forge workspace Dashboard, Mission Control expanded, Queue selected, seeded local data loaded -- Desktop layout: `/home/bailey/.codex/audits/forge-dashboard-iteration-2026-07-10/dashboard-desktop-layout.png` -- Tablet layout: `/home/bailey/.codex/audits/forge-dashboard-iteration-2026-07-10/dashboard-tablet-layout.png` -- Mobile layout: `/home/bailey/.codex/audits/forge-dashboard-iteration-2026-07-10/dashboard-mobile-layout.png` -- Full-view top comparison: `/home/bailey/.codex/audits/forge-dashboard-iteration-2026-07-10/top-comparison.png` -- Focused cockpit comparison: `/home/bailey/.codex/audits/forge-dashboard-iteration-2026-07-10/cockpit-comparison.png` -- Viewports: 1600 × 2600 desktop, 1024 × 2600 tablet, 390 × 3000 mobile; authenticated dark-theme dashboard with seeded operational data. -- State note: the source has 12 visible personal-work cards while the implementation fixture has 6. That difference is intentional QA coverage: 9+ work cards select the taller single-column operations rail visible in the source state; sparse states select two compact operation columns. +The full-view comparison preserves each 1536 x 1024 frame at native scale. It shows the selected bottom operations-shelf composition beside the browser-rendered implementation. Both reflow the dashboard above a bottom-attached shelf, use summary / queue / agent-presence zones, keep Queue selected, expose one ember primary action, and retain Forge's warm-earthy dark tokens and compact typography. -## Findings +The implementation intentionally omits the mock's speculative filter, sort, and inline assignee controls because the current quick-access query is read-only and deep-links to the durable issue surface. It uses honest dispatch-state copy and canonical issue links instead. This is an expected product constraint rather than unresolved visual drift. + +### Global Mission Control + +- Source desktop baseline: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\01-global-desktop-accepted.png` +- Source mobile baseline: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\02-global-mobile-accepted.jpg` +- Desktop implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\19-global-operations-desktop.png` +- Mobile implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\20-global-operations-mobile.png` +- Lower mobile implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\20b-global-operations-mobile-lower.png` +- Full-view comparison: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\23-global-qa-comparison.png` +- Viewports: 1536 x 1024 desktop; 390 x 844 mobile +- State: cross-workspace overview with seeded Forge workspace, zero registered runtimes, 19 open issues, no assigned attention, three offline agents, and recent activity + +The baseline and implementation comparison confirms the redesign preserves the global shell, real data, existing tokens, density, navigation destinations, and read-only contract while replacing the undifferentiated card grid with the selected direction's operator hierarchy. Runtime and dispatch posture now leads; workspace queue and assigned attention are the primary work plane; agent presence, runtime coverage, and activity follow as supporting evidence. -- No remaining P0, P1, or P2 findings. -- **Information architecture:** Pulse and Schedule now belong to Live Operations. Pipeline + What's New form a bounded 2+1 Workspace Flow lead row; Suggestions, Notes, and Workspace activity reclaim the full desktop board below instead of preserving an empty right rail. -- **Spacing and layout rhythm:** passed. Busy personal-work states use a stacked operation rail; sparse states use paired operation cards. Tablet uses two columns and mobile one. Full-width secondary modules remove the long desktop third-column void. -- **Typography:** passed. Existing Forge font families, weights, density-aware utilities, truncation, and hierarchy are preserved. Long schedule, sprint, metric, and changelog text truncates rather than stretching cards. -- **Colors and tokens:** passed. All changes use the existing border, card, background, muted, ember, warning, danger, and success tokens. -- **Image and icon fidelity:** passed. The surface contains no new raster imagery. Existing product components and Lucide icons remain; no placeholder or approximate assets were introduced. -- **Copy and content:** passed. Product labels remain concise and operational. Schedule exposes at most 3 due rows plus a remainder link; What's New exposes at most 4 current items and 3 historical headings; Workspace activity remains capped at 5 rows; Pulse displays large counts as `999+` while retaining the exact value in its title. -- **Responsive overflow:** passed. Automated checks found no page or visible-widget horizontal overflow at 1600, 1024, or 390 px. +## Required fidelity surfaces + +- **Fonts and typography:** Both surfaces use Forge's existing font stack and density-aware scale. Section labels, headings, metrics, issue keys, and row copy establish the same compact operational hierarchy without a parallel type system. +- **Spacing and layout:** The workspace shelf attaches to the bottom of the content flow and never covers the dashboard. The global surface uses one posture banner, a 2:1 primary work grid, then three supporting columns. Mobile collapses both surfaces into one readable vertical sequence with no clipped controls or horizontal overflow. +- **Colors and tokens:** Existing `background`, `card`, `border`, `ember`, `warning`, `danger`, `success`, `foreground`, and `muted-foreground` tokens are used throughout. No hard-coded palette, gradients, or new design system were introduced. +- **Images and icons:** Neither target requires raster imagery. All interface icons use Forge's existing Lucide dependency; no placeholder asset, handcrafted SVG, CSS drawing, or generated substitute was added. +- **Copy and content:** Mission Control terminology remains consistent. Workspace issue keys use canonical `FRG-*` references. The global runtime CTA appears only when setup is required, Queue remains distinct from assigned Attention, and Sprint terminology elsewhere remains unchanged. + +## States, interaction, and accessibility + +- Live, Queue, Agents, and Chat expose `tablist`, `tab`, `aria-selected`, `aria-controls`, and `tabpanel` semantics. +- Numeric shortcuts 1–4 switch workspace tabs and Escape collapses the shelf. The primary Assign next action opens the oldest unassigned issue; issue and agent rows deep-link to their durable surfaces. +- The global workspace row, issue-queue action, inbox, activity, agent settings, and runtime settings navigation were exercised from the rendered page. +- Workspace Queue and Agent Presence provide explicit loading, empty, and error states. Every global query region has a bounded loading state, empty state, and retry action without hiding the rest of Mission Control. +- Mobile workspace tabs, Settings, and Collapse meet a 44 px target. Global mobile interactive rows and actions meet at least 40 px, preserve visible focus styles from existing components, and wrap long content without collision. +- Desktop and mobile document widths match their viewport widths. Browser log review found no warnings or errors attributable to the implementation. ## Comparison history -1. **P2 — sparse-state cockpit gap:** the first pass stacked all five operation widgets, making Live Operations taller than a sparse personal-work canvas. Fixed by selecting one operation column for 9+ work cards and two columns below that threshold. Post-fix evidence: `dashboard-desktop-layout.png` and `cockpit-comparison.png`. -2. **P2 — compact widget overflow:** automated responsive QA found Quick Notes and then Standup exceeding their mobile/half-card bounds. Fixed by allowing the Notes header to wrap and keeping Standup metrics in a two-column internal grid. Post-fix responsive E2E: passed at all three viewports. -3. **P2 — residual Workspace Flow rail:** after moving Pulse and Schedule, wide secondary modules still occupied only two of three desktop tracks, leaving a long empty third column below What's New. Fixed by allowing Suggestions, Notes, and Workspace activity to span all three desktop tracks after the bounded Pipeline + What's New row. Post-fix evidence: `dashboard-desktop-layout.png`. - -## Implementation checklist - -- [x] Pulse + Schedule live in the priority cockpit. -- [x] What's New owns the compact Workspace Flow lead slot. -- [x] Busy and sparse work states choose different operation density. -- [x] Item caps, truncation, remainder links, and metric bounds are explicit. -- [x] Desktop/tablet/mobile DOM order, columns, and overflow are tested. -- [x] Saved dashboard preferences migrate once to the new structural version. -- [x] No deployment performed. - -## Command Center organization QA - -**Source visual truth** - -- `/home/bailey/.codex/attachments/1d60bf8c-1850-408c-b226-12603da14fec/codex-clipboard-6c55c8ab-47d8-4231-91ca-49d56c2a2105.png` -- The source establishes the permanent activity rail, repeated AXI-91 decision - noise, inactive attention area, and uneven lower-page columns that motivated - the Command Center pass. - -**Implementation evidence** - -- Desktop: `/home/bailey/.codex/audits/forge-command-center-iteration-2026-07-10/command-center-desktop-content.png` -- Tablet: `/home/bailey/.codex/audits/forge-command-center-iteration-2026-07-10/command-center-tablet-content.png` -- Mobile: `/home/bailey/.codex/audits/forge-command-center-iteration-2026-07-10/command-center-mobile-content.png` -- Source/result overview: `/home/bailey/.codex/audits/forge-command-center-iteration-2026-07-10/command-center-comparison.png` -- Viewports: 1600 × 2200 desktop, 1024 × 2200 tablet, and 390 × 2600 mobile; - authenticated dark-theme Command Center with one seeded stalled run. - -### Findings - -- No remaining P0, P1, or P2 findings. -- **Information architecture:** passed. Needs Action, Live Operations, and - Workspace Context create a clear reading order and remove the permanent - right-hand rail. -- **Signal quality:** passed. Empty attention groups disappear. A stalled run - has one canonical recovery card; Agent Attention retains aggregate counts - without repeating the same run explanation. -- **Content bounds:** passed. Attention groups scroll internally; live modules - cap at four rows, activity at eight, and artifacts at six, with full-surface - links for overflow. -- **Responsive layout:** passed. Live modules render 3/2/1 columns and context - renders 8+4/stacked/stacked at desktop/tablet/mobile widths. Automated checks - found no horizontal overflow. -- **Product fidelity:** passed. Existing Forge Section, activity, attention, - token, typography, and icon patterns are reused; no new visual system or - external design surface was introduced. - -### Comparison history - -1. **P1 — repeated decision rail:** one stalled issue could occupy most of the - fixed activity rail. Fixed by moving the bounded activity feed into Workspace - Context and relying on the existing grouped activity behavior. -2. **P2 — empty attention structure:** Asks and Review Gates rendered even when - empty. Fixed by rendering only non-empty attention groups. -3. **P2 — duplicate recovery detail:** Agent Attention repeated the stalled-run - explanation immediately below the canonical attention card. Fixed by - excluding those run-detail rows while preserving aggregate agent state. - -### Implementation checklist - -- [x] Needs Action leads with only actionable groups. -- [x] Live Operations owns agent state and bounded execution summaries. -- [x] Workspace Context replaces the permanent sticky rail. -- [x] Caps, internal overflow, and full-surface links are explicit. -- [x] Desktop/tablet/mobile columns and overflow are tested. -- [x] No deployment performed. +### Iteration 1 + +- **[P2] Workspace mobile controls were 24–40 px.** Settings, tabs, and Collapse did not meet the intended touch target. Fixed by increasing mobile controls to 44 px while retaining compact desktop sizing. +- **[P1] The collapsed workspace pill could sit behind mobile navigation.** Fixed by applying bottom-navigation safe clearance to bottom-corner pill and glance states. +- **[P1] Global runtime health was buried below workspace volume.** Fixed by replacing the equal-weight card grid with a derived runtime and dispatch posture banner, keeping registration failure visible before queue triage. +- **[P2] Queue, attention, presence, and activity competed at the same level globally.** Fixed by separating actionable workspace queue and personal attention from supporting coverage and recency evidence. + +### Iteration 2 + +- Post-fix mobile measurement confirms the workspace controls meet 44 px and neither surface overflows at 390 x 844. +- Post-fix desktop captures confirm the workspace shelf remains in layout and the global scan begins with runtime posture, queue, and attention. +- Combined comparisons contain no actionable P0, P1, or P2 differences against the approved direction, captured product baseline, and existing Forge conventions. + +## Findings + +No actionable P0, P1, or P2 findings remain. + +## Follow-up polish + +- **P3:** If Forge later exposes safe quick-assignment mutations and queue sort/filter contracts, the workspace shelf can adopt inline controls without changing its structure. + +## Final result final result: passed diff --git a/src/app/(app)/w/[slug]/layout.tsx b/src/app/(app)/w/[slug]/layout.tsx index 8ec80882..0d7a3b65 100644 --- a/src/app/(app)/w/[slug]/layout.tsx +++ b/src/app/(app)/w/[slug]/layout.tsx @@ -115,48 +115,48 @@ export default async function WorkspaceShellLayout({ - - -
- -
- {/* Ambient background — one viewport-sized layer behind all page + + +
+ +
+ {/* Ambient background — one viewport-sized layer behind all page content, driven by the per-user `data-bg` appearance pref. Lives in the non-scrolling
so it always covers the visible area and animates smoothly (see .forge-page-bg). */} -
- {/* Canvas-backed ambient variants (reactive / particles). +
+ {/* Canvas-backed ambient variants (reactive / particles). Self-gates on ; renders nothing otherwise. When active, the CSS below hides .forge-page-bg so the two layers never stack. */} - - - {children} -
-
- - - - - - - - -
-
+ + + {children} + +
+
+ + + + + + + +
+
diff --git a/src/components/global-shell/global-operations-model.ts b/src/components/global-shell/global-operations-model.ts new file mode 100644 index 00000000..0ec2fe5d --- /dev/null +++ b/src/components/global-shell/global-operations-model.ts @@ -0,0 +1,63 @@ +export type GlobalOperationsPosture = { + tone: "success" | "warning" | "danger" | "neutral"; + label: string; + summary: string; + actionLabel: string; + actionHref: string; +}; + +export function deriveGlobalOperationsPosture(input: { + activeRuns: number; + agentsOnline: number; + runtimeCount: number; + runtimesOnline: number; +}): GlobalOperationsPosture { + if (input.runtimeCount === 0) { + return { + tone: "warning", + label: "Setup required", + summary: "Register a runtime before agents can execute queued work.", + actionLabel: "Register a runtime", + actionHref: "/settings/runtimes", + }; + } + + if (input.runtimesOnline < input.runtimeCount) { + const offline = input.runtimeCount - input.runtimesOnline; + return { + tone: "danger", + label: "Runtime attention", + summary: `${offline} ${offline === 1 ? "runtime is" : "runtimes are"} unavailable for dispatch.`, + actionLabel: "Inspect runtimes", + actionHref: "/settings/runtimes", + }; + } + + if (input.activeRuns > 0) { + return { + tone: "success", + label: "Work in motion", + summary: `${input.activeRuns} ${input.activeRuns === 1 ? "agent run is" : "agent runs are"} active across your workspaces.`, + actionLabel: "View live activity", + actionHref: "/activity", + }; + } + + if (input.agentsOnline > 0) { + return { + tone: "success", + label: "Ready to dispatch", + summary: `${input.agentsOnline} ${input.agentsOnline === 1 ? "agent is" : "agents are"} online and ready for work.`, + actionLabel: "Open agent coverage", + actionHref: "/settings/agents", + }; + } + + return { + tone: "neutral", + label: "Standing by", + summary: "Runtime coverage is healthy, but no agents are currently online.", + actionLabel: "Review agents", + actionHref: "/settings/agents", + }; +} diff --git a/src/components/global-shell/mission-control-home.tsx b/src/components/global-shell/mission-control-home.tsx index 2b772c05..7aa8fe4f 100644 --- a/src/components/global-shell/mission-control-home.tsx +++ b/src/components/global-shell/mission-control-home.tsx @@ -1,47 +1,50 @@ "use client"; +import type { ReactNode } from "react"; import Link from "next/link"; import { - CircleDot, + Activity, + ArrowRight, AtSign, - Workflow, - Command, + Bot, + CheckCircle2, ChevronRight, + CircleAlert, + CircleDot, + ExternalLink, + Inbox, Server, - Bot, + Settings2, Sparkles, - type LucideIcon, + Workflow, } from "lucide-react"; -import { Spinner, EmptyState } from "@/components/ui"; -import { trpc } from "@/lib/trpc"; +import { EmptyState, Spinner } from "@/components/ui"; import { activityActorName, activityActorOwnerTitle } from "@/lib/activity-actor"; -import { relativeTime, cn } from "@/lib/utils"; +import { trpc } from "@/lib/trpc"; +import { cn, relativeTime } from "@/lib/utils"; import { workspaceColor } from "@/lib/workspace-color"; +import { deriveGlobalOperationsPosture } from "./global-operations-model"; import { WorkspaceBadge } from "./global-shell"; -/** - * Mission Control — the cross-workspace home rendered inside the global - * ("concourse") shell at `/`. Read-only aggregations from `global.*` - * routers, each row stamped with a workspace chip so the user can see - * which tenant it belongs to before clicking in. Ports the Mission - * Control screen from the design handoff (`screens-global.jsx`). - */ - type Workspace = { id: string; slug: string; name: string; key: string }; export function WsChip({ ws, dense }: { ws: Workspace; dense?: boolean }) { - const c = workspaceColor(ws.key); + const color = workspaceColor(ws.key); return ( {ws.key[0]} @@ -50,79 +53,126 @@ export function WsChip({ ws, dense }: { ws: Workspace; dense?: boolean }) { ); } -function StatusPip({ status }: { status?: string | null }) { - const c = - status === "ONLINE" - ? "hsl(var(--success))" - : status === "BUSY" - ? "hsl(var(--ember))" - : status === "IDLE" - ? "hsl(var(--warning))" - : "hsl(var(--muted-foreground))"; +function StatusPip({ online }: { online: boolean }) { return ( ); } -function MetricTile({ - icon: Icon, - label, - value, - sub, - loading, -}: { - icon: LucideIcon; - label: string; - value: React.ReactNode; - sub?: string; - loading?: boolean; -}) { - return ( -
-
- - - {label} - -
-
- {loading ? : value} -
- {sub &&
{sub}
} -
- ); -} - function SectionCard({ title, + eyebrow, count, action, className, children, }: { title: string; - count?: React.ReactNode; - action?: React.ReactNode; + eyebrow: string; + count?: ReactNode; + action?: ReactNode; className?: string; - children: React.ReactNode; + children: ReactNode; }) { return ( -
-
-

{title}

- {count !== undefined && ( - {count} - )} - {action &&
{action}
} +
+
+
+
+ {eyebrow} +
+
+

{title}

+ {count !== undefined && ( + {count} + )} +
+
+ {action &&
{action}
}
- {children} +
{children}
); } +function RetryState({ title, onRetry }: { title: string; onRetry: () => void }) { + return ( +
+ +
+

{title}

+

+ The rest of Mission Control is still available. +

+
+ +
+ ); +} + +function LoadingState({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +function HeaderLink({ href, children }: { href: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +function SignalMetric({ + icon: Icon, + label, + value, + detail, + tone = "neutral", +}: { + icon: typeof Server; + label: string; + value: ReactNode; + detail: string; + tone?: "neutral" | "success" | "warning" | "danger"; +}) { + const toneClass = { + neutral: "text-foreground", + success: "text-success", + warning: "text-warning", + danger: "text-danger", + }[tone]; + + return ( +
+
+ {label} +
+
+ {value} +
+
{detail}
+
+ ); +} + export function MissionControlHome() { const summary = trpc.global.summary.useQuery(); const work = trpc.global.work.useQuery(); @@ -131,158 +181,281 @@ export function MissionControlHome() { const runtimes = trpc.global.runtimes.useQuery(); const activity = trpc.global.activity.useQuery(); - const s = summary.data; + const snapshot = summary.data; const agentsList = agents.data ?? []; - const onlineAgents = agentsList.filter((a) => a.online).length; + const onlineAgents = agentsList.filter((agent) => agent.online).length; const runtimesList = runtimes.data ?? []; - const runtimesOnline = runtimesList.filter((r) => r.online).length; + const runtimesOnline = runtimesList.filter((runtime) => runtime.online).length; + const assignedWork = work.data ?? []; + const workspaceList = [...(workspaces.data ?? [])].sort( + (a, b) => b.activeRuns - a.activeRuns || b.openIssues - a.openIssues, + ); + + const posture = snapshot + ? deriveGlobalOperationsPosture({ + activeRuns: snapshot.activeRuns, + agentsOnline: snapshot.agentsOnline, + runtimeCount: snapshot.runtimeCount, + runtimesOnline: snapshot.runtimesOnline, + }) + : null; + + const postureClasses = posture + ? { + success: "border-success/30 bg-success/5 text-success", + warning: "border-warning/30 bg-warning/5 text-warning", + danger: "border-danger/30 bg-danger/5 text-danger", + neutral: "border-border bg-background/40 text-muted-foreground", + }[posture.tone] + : "border-border bg-background/40 text-muted-foreground"; return ( -
- {/* Top metric row */} -
- - - - -
+
+
+
+
+
+ Operator posture +
+

+ Runtime and dispatch health +

+
+ {posture && ( +
+ {posture.tone === "success" ? ( + + ) : posture.tone === "neutral" ? ( + + ) : ( + + )} + {posture.label} +
+ )} +
+ + {summary.isLoading ? ( + + ) : summary.isError || !snapshot || !posture ? ( + void summary.refetch()} + /> + ) : ( +
+
+ + 0 ? "success" : "neutral"} + /> + 0 ? "success" : "neutral"} + /> + 0 ? "warning" : "success"} + /> +
+
+
+
{posture.label}
+

+ {posture.summary} +

+
+ + {posture.actionLabel} + +
+
+ )} +
- {/* 12-col grid */} -
- {/* My work across workspaces */} +
Manage} className="lg:col-span-8" > - {work.isLoading ? ( -
- -
- ) : (work.data?.length ?? 0) === 0 ? ( + {workspaces.isLoading ? ( + + ) : workspaces.isError ? ( + void workspaces.refetch()} + /> + ) : workspaceList.length === 0 ? ( } - title="Nothing assigned to you" - description="Issues assigned to you across your workspaces show up here." + icon={} + title="No workspaces yet" + description="Create a workspace to start routing work through Mission Control." /> ) : ( -
- {work.data!.map((issue) => ( - + {workspaceList.map((workspace) => ( +
- - - {issue.workspace.key}-{issue.number} - - {issue.title} - {issue.status && ( - - {issue.status.name} - - )} - - {relativeTime(issue.updatedAt)} - - +
+ +
+ + {workspace.name} + + {workspace.key} + + +
+ {workspace.agents} agents + · + {workspace.members} members + {workspace.lastActiveAt && ( + <> + · + active {relativeTime(workspace.lastActiveAt)} + + )} +
+
+
+
+
+
+
+ {workspace.openIssues} +
+
+ Open +
+
+
+
0 && "text-success", + )} + > + {workspace.activeRuns} +
+
+ Live +
+
+
+ + + +
+
))}
)}
- {/* Workspaces card */} - manage - - } + eyebrow="Attention" + title="Assigned to you" + count={work.data ? assignedWork.length : undefined} + action={Open inbox} className="lg:col-span-4" > - {workspaces.isLoading ? ( -
- + {work.isLoading ? ( + + ) : work.isError ? ( + void work.refetch()} /> + ) : assignedWork.length === 0 ? ( +
+ + + +

No assigned attention

+

+ You are clear. Open workspace queues remain visible at left. +

- ) : (workspaces.data?.length ?? 0) === 0 ? ( - ) : ( -
- {workspaces.data!.map((w) => ( +
+ {assignedWork.slice(0, 6).map((issue) => ( - - - - {w.name} - {w.key} - - - {w.openIssues} open · {w.activeRuns} live · {w.members} members - + + + {issue.workspace.key}-{issue.number} - + {issue.title} + ))}
)} - {/* My agents */} - configure - - } + eyebrow="Coverage" + title="Agent presence" + count={agents.data ? `${onlineAgents} online · ${agentsList.length} total` : undefined} + action={Configure} className="lg:col-span-5" > {agents.isLoading ? ( -
- -
+ + ) : agents.isError ? ( + void agents.refetch()} + /> ) : agentsList.length === 0 ? ( ) : ( -
- {agentsList.map((a) => ( +
+ {agentsList.slice(0, 6).map((agent) => ( - {a.avatar ?? "🤖"} + + {agent.avatar ?? agent.name.slice(0, 1).toUpperCase()} + - {a.name} - - @{a.profileKey} + + {agent.name} + + @{agent.profileKey} + - - {a.bindings.length === 0 ? ( - no bindings - ) : ( - a.bindings.map((b) => b.workspace.name).join(" · ") - )} + + {agent.bindings.length === 0 + ? "No workspace bindings" + : agent.bindings.map((binding) => binding.workspace.name).join(" · ")} - - {a.provider.toLowerCase()} + + {agent.online ? "Online" : "Offline"} ))} @@ -322,69 +497,112 @@ export function MissionControlHome() { )} - {/* Runtimes */} + Settings + + } className="lg:col-span-4" > {runtimes.isLoading ? ( -
- -
- ) : runtimesList.length === 0 ? ( - } - title="No runtimes" - description="Run `forge daemon start` on a host to register one." + + ) : runtimes.isError ? ( + void runtimes.refetch()} /> + ) : runtimesList.length === 0 ? ( +
+ + + +

No runtimes registered

+

+ Register a host before agents can execute queued work. +

+ + Register runtime + +
) : ( -
- {runtimesList.map((r) => { - const Ico = - r.kind === "CLOUD" ? Sparkles : r.kind === "REMOTE_HTTP" ? Server : Bot; - return ( - - - - - {r.name} - - - - {r.kind.toLowerCase().replace("_", " ")} - {r.heartbeatAt ? ` · ${relativeTime(r.heartbeatAt)}` : ""} - +
+ {runtimesList.slice(0, 5).map((runtime) => ( + + + {runtime.kind === "CLOUD" ? ( + + ) : ( + + )} + + + + + {runtime.name} - - ); - })} + + {runtime.kind.toLowerCase().replace("_", " ")} + {runtime.heartbeatAt ? ` · ${relativeTime(runtime.heartbeatAt)}` : ""} + + + + ))}
)} - {/* Activity feed */} - + + View all + + } + className="lg:col-span-3" + > {activity.isLoading ? ( -
- -
+ + ) : activity.isError ? ( + void activity.refetch()} /> ) : (activity.data?.length ?? 0) === 0 ? ( - + } + title="No activity yet" + description="Agent and operator events will appear here." + /> ) : ( -
- {activity.data!.slice(0, 8).map((e) => ( - +
+ {activity.data!.slice(0, 8).map((event) => ( + ))}
)}
+ +
+
+ Mission Control is read-only across workspaces. +
+
+ Open inbox + View activity +
+
); } @@ -402,16 +620,19 @@ function ActivityRow({ }) { const actor = activityActorName(event); const ownerTitle = activityActorOwnerTitle(event); + return ( -
+
- {event.kind.split(".")[1] || event.kind} - + + {event.kind.split(".")[1] || event.kind} + + {actor} - + {relativeTime(event.createdAt)}
diff --git a/src/components/mission-control/mission-control.tsx b/src/components/mission-control/mission-control.tsx index 2ce5f941..b57ff742 100644 --- a/src/components/mission-control/mission-control.tsx +++ b/src/components/mission-control/mission-control.tsx @@ -2,19 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { - Activity, - ChevronUp, - ChevronDown, - GripHorizontal, - MessageSquare, - Pin, - PinOff, - X as XIcon, - AlertTriangle, - ExternalLink, - Hourglass, -} from "lucide-react"; +import { Activity, ChevronUp, ChevronDown, MessageSquare, AlertTriangle } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { useRealtime } from "@/hooks/use-realtime"; import { useHotkey, useChord } from "@/lib/keyboard"; @@ -33,7 +21,6 @@ import { useMissionControl, type MissionControlTab, } from "@/hooks/use-mission-control"; -import { useResizeHandle } from "@/hooks/use-resize-handle"; import { playRunCompletedSound, playRunStalledSound } from "@/lib/mission-control-sound"; import { LiveTab } from "./live-tab"; import { QueueTab } from "./queue-tab"; @@ -43,6 +30,7 @@ import { SettingsPopover } from "./settings-popover"; import { PillSparkline } from "./pill-sparkline"; import { ChatPreviewTab } from "./chat-preview-tab"; import { Kbd } from "@/components/ui/kbd"; +import { useModKeyLabel } from "@/lib/platform"; /** * Mission Control — the global agent ops widget. @@ -89,14 +77,13 @@ export function MissionControl() { setSize, setTab, setCorner, - setDimensions, - togglePin, toggleSound, toggleCollapse, pinRun, unpinRun, isPinned, } = useMissionControl(slug); + const modKey = useModKeyLabel(); const utils = trpc.useUtils(); // Live data for the pill (always fetched even when collapsed so the @@ -134,7 +121,8 @@ export function MissionControl() { // Runs blocked on a human approve/reject — surfaced as an amber badge on // the Live tab so it's actionable from any tab, not just inside Live. const approvalCount = activeRuns?.filter((r) => r.awaitingApprovalAt != null).length ?? 0; - const queueCount = (queue ?? []).filter((q) => !q.assignedAgent).length; + const queueCount = queue?.length ?? 0; + const nextQueueIssue = (queue ?? []).find((issue) => !issue.assignedAgent) ?? null; const stalledRuns = useMemo(() => { // Detect runs that haven't ticked in >5min — even if the watchdog // hasn't flipped them yet, the pill should warn. @@ -281,25 +269,12 @@ export function MissionControl() { }, }); - // ---------- Resize handle ---------- - const { onPointerDown: onResizePointerDown, isResizing } = useResizeHandle({ - containerRef, - onResizeEnd: setDimensions, - minWidth: 380, - minHeight: 420, - maxWidth: 720, - maxHeight: 800, - anchor: state.corner, - }); - // ---------- Click-outside auto-collapse ---------- - // When the panel is open and not pinned, clicking anywhere outside - // the widget collapses it back to pill. Respects `state.pinned` so a - // pinned widget stays open while the user clicks around the app. - // Uses pointerdown instead of click so the collapse fires before the - // outside element handles its own click — feels more responsive. + // Glance remains a transient popover. The full operations shelf stays + // open while the operator works elsewhere in the workspace and only + // collapses through its explicit control or Escape. useEffect(() => { - if (state.size === "pill") return; + if (state.size !== "glance") return; if (state.pinned) return; const handler = (ev: PointerEvent) => { const node = containerRef.current; @@ -461,10 +436,12 @@ export function MissionControl() { // mode on drop. const cornerClass = cornerToClass(state.corner); const isFullChatRoute = Boolean(slug && pathname?.startsWith(`/w/${slug}/chat`)); + const usesBottomCorner = state.corner === "br" || state.corner === "bl"; const pillCornerClass = - isFullChatRoute && (state.corner === "br" || state.corner === "bl") + isFullChatRoute && usesBottomCorner ? cn(cornerClass, "bottom-24") - : cornerClass; + : cn(cornerClass, usesBottomCorner && "max-md:bottom-24"); + const glanceCornerClass = cn(cornerClass, usesBottomCorner && "max-md:bottom-24"); // Pill summary: agent presence dots (up to 3) + count + first row's // current step if there is one. @@ -509,8 +486,8 @@ export function MissionControl() { }} title={ hasStalled - ? `${stalledRuns.length} stalled ${stalledRuns.length === 1 ? "run" : "runs"} · open Activity` - : "Activity (⌘')" + ? `${stalledRuns.length} stalled ${stalledRuns.length === 1 ? "run" : "runs"} · open Mission Control` + : `Mission Control (${modKey}')` } className={cn( "group flex items-center gap-2 rounded-full border bg-card/90 px-3 py-1.5 text-[0.75rem] shadow-sm backdrop-blur", @@ -615,7 +592,7 @@ export function MissionControl() { data-mission-control-root className={cn( "fixed z-40 flex flex-col rounded-lg border border-border bg-card shadow-md backdrop-blur", - cornerClass, + glanceCornerClass, isDragging && "opacity-90", )} style={{ @@ -636,144 +613,111 @@ export function MissionControl() { ); } - // Resize handle position tracks the panel's anchor corner. - // For a bottom-right anchor the handle sits at the top-left. - // For other corners the handle moves to the opposite corner. - const resizeHandleClass = - state.corner === "br" - ? "top-0 left-0 cursor-nwse-resize" - : state.corner === "bl" - ? "top-0 right-0 cursor-nesw-resize" - : state.corner === "tr" - ? "bottom-0 left-0 cursor-nesw-resize" - : "bottom-0 right-0 cursor-nwse-resize"; - - // ---------- Panel mode ---------- + // ---------- Operations shelf ---------- return (
- {/* Resize handle — invisible 12×12 hit area at the appropriate corner */} -