feat: agentic review — background job runner with SSE streaming (#443)

* feat(server): add agent job manager with process lifecycle and SSE streaming

Introduces the backend for agentic review — a server-agnostic job runner
that spawns agent processes, monitors their lifecycle via proc.exited, and
broadcasts status updates over a dedicated SSE stream.

New files:
- packages/shared/agent-jobs.ts: runtime-agnostic types, SSE helpers, state machine
- packages/server/agent-jobs.ts: Bun HTTP adapter with spawn/kill/SSE/REST routes

Wired into the review server with late-bound serverUrl, killAll on stop
and process exit for defense-in-depth cleanup. Capability detection via
Bun.which() for Claude Code, Codex CLI, and shell.

For provenance purposes, this commit was AI assisted.

* feat(ui): add Review Agents tab with job management and live status

Adds the client-side UI for agentic review:
- useAgentJobs hook: SSE primary with polling fallback, capabilities fetch
- AgentsTab: shared component with provider dropdown, job cards, kill controls
- ReviewAgentsIcon: magnifying glass with cog icon
- ReviewPanel: new "Review Agents" tab conditional on detected capabilities
- App.tsx: initializes hook and passes props to ReviewPanel

Jobs show live elapsed time, status badges, annotation counts per source,
and expandable error details. Stub command (sleep 10-30s) for initial testing.

For provenance purposes, this commit was AI assisted.

* fix(server): narrow proc.stderr type to satisfy TS

Bun types proc.stderr as ReadableStream | number. Add typeof guard
to narrow before passing to Response constructor.

For provenance purposes, this commit was AI assisted.

* fix(server): prevent stdout deadlock, stderr pipe stall, and worktree cwd mismatch

- Change stdout from "pipe" to "ignore" — agents post findings via the
  annotations API, not stdout. Unconsumed pipe buffer caused deadlock.
- Drain stderr continuously instead of reading after exit — prevents
  pipe-full stall on chatty agents.
- Make agent getCwd worktree-aware using the same currentDiffType check
  the AI endpoints use, so agents spawn in the correct checkout.
- Exclude 'agents' tab from PR context fetch guard in ReviewPanel.

For provenance purposes, this commit was AI assisted.

* feat(pi): mirror agent-job endpoints in Pi review server

Add apps/pi-extension/server/agent-jobs.ts — a Node.js port of the Bun
agent-jobs handler using child_process.spawn, res.write() SSE, and
execFileSync for capability detection.

Wire into serverReview.ts: route delegation, killAll in stop(),
process.on("exit") guard, worktree-aware getCwd, and serverUrl
late-binding. Add agent-jobs to vendor.sh for shared type vendoring.

For provenance purposes, this commit was AI assisted.

* fix(agents): harden agent jobs — listener leak, provider allowlist, Windows support, docs

- Use process.once instead of process.on to prevent exit listener accumulation
- Validate provider against known capabilities before spawning (rejects unknown providers)
- Use platform-aware which/where for CLI detection on Windows
- Broadcast jobs:cleared event from kill-all endpoint (was dead client code)
- Document all /api/agents/* endpoints in CLAUDE.md Review Server table

For provenance purposes, this commit was AI assisted.

* fix(agents): SSE ref reset, jobs:cleared divergence, exit listener cleanup

- Reset receivedSnapshotRef/fallbackRef on re-mount so polling fallback
  activates correctly on subsequent tab switches
- Remove jobs:cleared broadcast from kill-all — killAll() already fires
  individual job:completed events, so the UI shows status transitions
  instead of going blank
- Store exit handler ref so stop() can removeListener, preventing
  accumulation in long-lived hosts (OpenCode, Pi)

For provenance purposes, this commit was AI assisted.
This commit is contained in:
Michael Ramos
2026-03-31 10:06:18 -07:00
committed by GitHub
parent ab9cae3488
commit d8a67beb1c
14 changed files with 1417 additions and 7 deletions
+26 -1
View File
@@ -17,6 +17,7 @@ import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraft
import { contentHash, deleteDraft } from "./draft";
import { createEditorAnnotationHandler } from "./editor-annotations";
import { createExternalAnnotationHandler } from "./external-annotations";
import { createAgentJobHandler } from "./agent-jobs";
import { saveConfig, detectGitUser, getServerConfig } from "./config";
import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, getPRUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr";
import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai";
@@ -105,6 +106,20 @@ export async function startReviewServer(
let currentDiffType: DiffType = options.diffType || "uncommitted";
let currentError = options.error;
// Agent jobs — background process manager (late-binds serverUrl via getter)
let serverUrl = "";
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
getCwd: () => {
if (currentDiffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(currentDiffType);
if (parsed) return parsed.path;
}
return gitContext?.cwd ?? process.cwd();
},
});
// AI provider setup (graceful — AI features degrade if SDK unavailable)
const aiRegistry = new ProviderRegistry();
const aiSessionManager = new SessionManager();
@@ -448,6 +463,12 @@ export async function startReviewServer(
});
if (externalResponse) return externalResponse;
// API: Agent jobs (background review agents)
const agentResponse = await agentJobs.handle(req, url, {
disableIdleTimeout: () => server.timeout(req, 0),
});
if (agentResponse) return agentResponse;
// API: Submit review feedback
if (url.pathname === "/api/feedback" && req.method === "POST") {
try {
@@ -570,7 +591,9 @@ export async function startReviewServer(
}
const port = server.port!;
const serverUrl = `http://localhost:${port}`;
serverUrl = `http://localhost:${port}`;
const exitHandler = () => agentJobs.killAll();
process.once("exit", exitHandler);
// Notify caller that server is ready
if (onReady) {
@@ -583,6 +606,8 @@ export async function startReviewServer(
isRemote,
waitForDecision: () => decisionPromise,
stop: () => {
process.removeListener("exit", exitHandler);
agentJobs.killAll();
aiSessionManager.disposeAll();
aiRegistry.disposeAll();
server.stop();