mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user