Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 0
Add restart endpoint#17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
754da6b06b8f5e1a51d28ab5e05d798f470File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -3,156 +3,181 @@ import { agentStmts, remoteStmts, ticketStmts } from "../db/index.ts"; | ||
| import { errorMeta, logger } from "../lib/logger.ts"; | ||
| import { agentProcessManager } from "../services/AgentProcessManager.ts"; | ||
| import { GitWorktreeManager } from "../services/GitWorktreeManager.ts"; | ||
| import type { OrchestratorService } from "../services/OrchestratorService.ts"; | ||
| import { broadcastNotification } from "../ws/hub.ts"; | ||
| const log = logger.child("agents"); | ||
| export const agentsRouter = new Hono(); | ||
| export function agentsRouter(orchestrator: OrchestratorService) { | ||
| const app = new Hono(); | ||
| agentsRouter.get("/:id", (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| return c.json(agent); | ||
| }); | ||
| app.get("/:id", (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| return c.json(agent); | ||
| }); | ||
| agentsRouter.get("/:id/diff", async (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| app.get("/:id/diff", async (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| const remoteConfig = remoteStmts.get.get(); | ||
| if (!remoteConfig) return c.json({ error: "no remote configured" }, 400); | ||
| const remoteConfig = remoteStmts.get.get(); | ||
| if (!remoteConfig) return c.json({ error: "no remote configured" }, 400); | ||
| log.debug("fetching diff", { | ||
| agentId: agent.id, | ||
| worktreePath: agent.worktreePath, | ||
| baseBranch: agent.baseBranch, | ||
| }); | ||
| try { | ||
| const git = new GitWorktreeManager(remoteConfig.localPath); | ||
| const diff = await git.getDiff(agent.worktreePath, agent.baseBranch); | ||
| log.debug("diff fetched", { agentId: agent.id, files: diff.files.length }); | ||
| return c.json(diff); | ||
| } catch (err) { | ||
| log.error("failed to fetch diff", { agentId: agent.id, ...errorMeta(err) }); | ||
| return c.json({ error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| agentsRouter.post("/:id/merge", async (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| const remoteConfig = remoteStmts.get.get(); | ||
| if (!remoteConfig) return c.json({ error: "no remote configured" }, 400); | ||
| log.info("merge requested", { | ||
| agentId: agent.id, | ||
| branch: agent.branch, | ||
| baseBranch: agent.baseBranch, | ||
| log.debug("fetching diff", { | ||
| agentId: agent.id, | ||
| worktreePath: agent.worktreePath, | ||
| baseBranch: agent.baseBranch, | ||
| }); | ||
| try { | ||
| const git = new GitWorktreeManager(remoteConfig.localPath); | ||
| const diff = await git.getDiff(agent.worktreePath, agent.baseBranch); | ||
| log.debug("diff fetched", { agentId: agent.id, files: diff.files.length }); | ||
| return c.json(diff); | ||
| } catch (err) { | ||
| log.error("failed to fetch diff", { agentId: agent.id, ...errorMeta(err) }); | ||
| return c.json({ error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| try { | ||
| const git = new GitWorktreeManager(remoteConfig.localPath); | ||
| const result = await git.mergeToBase(agent.worktreePath, agent.branch, agent.baseBranch); | ||
| if (result.success) { | ||
| log.info("merge succeeded", { agentId: agent.id, branch: agent.branch }); | ||
| const ticket = ticketStmts.get.get(agent.ticketId); | ||
| if (ticket) { | ||
| ticketStmts.updateStatus.run({ $status: "done", $updatedAt: Date.now(), $id: ticket.id }); | ||
| const updatedTicket = ticketStmts.get.get(ticket.id); | ||
| if (updatedTicket) broadcastNotification({ type: "ticket-updated", ticket: updatedTicket }); | ||
| broadcastNotification({ type: "kanban-sync", tickets: ticketStmts.list.all() }); | ||
| app.post("/:id/merge", async (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| const remoteConfig = remoteStmts.get.get(); | ||
| if (!remoteConfig) return c.json({ error: "no remote configured" }, 400); | ||
| log.info("merge requested", { | ||
| agentId: agent.id, | ||
| branch: agent.branch, | ||
| baseBranch: agent.baseBranch, | ||
| }); | ||
| try { | ||
| const git = new GitWorktreeManager(remoteConfig.localPath); | ||
| const result = await git.mergeToBase(agent.worktreePath, agent.branch, agent.baseBranch); | ||
| if (result.success) { | ||
| log.info("merge succeeded", { agentId: agent.id, branch: agent.branch }); | ||
| const ticket = ticketStmts.get.get(agent.ticketId); | ||
| if (ticket) { | ||
| ticketStmts.updateStatus.run({ $status: "done", $updatedAt: Date.now(), $id: ticket.id }); | ||
| const updatedTicket = ticketStmts.get.get(ticket.id); | ||
| if (updatedTicket) | ||
| broadcastNotification({ type: "ticket-updated", ticket: updatedTicket }); | ||
| orchestrator.onTicketMoved(ticket.id, "done").catch((err) => { | ||
| log.error("orchestrator cleanup failed after merge", { | ||
| agentId: agent.id, | ||
| ...errorMeta(err), | ||
| }); | ||
| }); | ||
| } | ||
| } else if (result.conflicted) { | ||
| log.warn("merge aborted: rebase conflict", { agentId: agent.id, branch: agent.branch }); | ||
| } else { | ||
| log.warn("merge failed", { agentId: agent.id, branch: agent.branch, error: result.error }); | ||
| } | ||
| } else if (result.conflicted) { | ||
| log.warn("merge aborted: rebase conflict", { agentId: agent.id, branch: agent.branch }); | ||
| } else { | ||
| log.warn("merge failed", { agentId: agent.id, branch: agent.branch, error: result.error }); | ||
| return c.json(result); | ||
| } catch (err) { | ||
| log.error("merge threw unexpected error", { | ||
| agentId: agent.id, | ||
| branch: agent.branch, | ||
| ...errorMeta(err), | ||
| }); | ||
| return c.json({ success: false, conflicted: false, error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| app.post("/:id/commit", async (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| const remoteConfig = remoteStmts.get.get(); | ||
| if (!remoteConfig) return c.json({ error: "no remote configured" }, 400); | ||
| const body = await c.req.json<{ message?: string }>().catch(() => ({ message: undefined })); | ||
| const message = body.message?.trim() || "chore: commit agent changes"; | ||
| log.info("commit requested", { agentId: agent.id, message }); | ||
| try { | ||
| const git = new GitWorktreeManager(remoteConfig.localPath); | ||
| await git.commitWorktree(agent.worktreePath, message); | ||
| log.info("commit succeeded", { agentId: agent.id }); | ||
| return c.json({ ok: true }); | ||
| } catch (err) { | ||
| log.error("commit failed", { agentId: agent.id, ...errorMeta(err) }); | ||
| return c.json({ error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| return c.json(result); | ||
| } catch (err) { | ||
| log.error("merge threw unexpected error", { | ||
| app.post("/:id/rebase", async (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| const remoteConfig = remoteStmts.get.get(); | ||
| if (!remoteConfig) return c.json({ error: "no remote configured" }, 400); | ||
| const abortOnConflict = agent.status !== "running"; | ||
| log.info("rebase requested", { | ||
| agentId: agent.id, | ||
| branch: agent.branch, | ||
| ...errorMeta(err), | ||
| baseBranch: agent.baseBranch, | ||
| abortOnConflict, | ||
| }); | ||
| return c.json({ success: false, conflicted: false, error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| agentsRouter.post("/:id/commit", async (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| const remoteConfig = remoteStmts.get.get(); | ||
| if (!remoteConfig) return c.json({ error: "no remote configured" }, 400); | ||
| const body = await c.req.json<{ message?: string }>().catch(() => ({ message: undefined })); | ||
| const message = body.message?.trim() || "chore: commit agent changes"; | ||
| log.info("commit requested", { agentId: agent.id, message }); | ||
| try { | ||
| const git = new GitWorktreeManager(remoteConfig.localPath); | ||
| await git.commitWorktree(agent.worktreePath, message); | ||
| log.info("commit succeeded", { agentId: agent.id }); | ||
| return c.json({ ok: true }); | ||
| } catch (err) { | ||
| log.error("commit failed", { agentId: agent.id, ...errorMeta(err) }); | ||
| return c.json({ error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| agentsRouter.post("/:id/rebase", async (c) => { | ||
| const agent = agentStmts.get.get(c.req.param("id")); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| const remoteConfig = remoteStmts.get.get(); | ||
| if (!remoteConfig) return c.json({ error: "no remote configured" }, 400); | ||
| // Only abort on conflict when the agent isn't running — if it is running, | ||
| // leave the worktree in the conflicted state so the agent can resolve it. | ||
| const abortOnConflict = agent.status !== "running"; | ||
| log.info("rebase requested", { | ||
| agentId: agent.id, | ||
| baseBranch: agent.baseBranch, | ||
| abortOnConflict, | ||
| try { | ||
| const git = new GitWorktreeManager(remoteConfig.localPath); | ||
| const result = await git.rebase(agent.worktreePath, agent.baseBranch, abortOnConflict); | ||
| if (result.success) { | ||
| log.info("rebase succeeded", { agentId: agent.id }); | ||
| } else if (result.conflicted) { | ||
| log.warn("rebase conflict detected", { agentId: agent.id, abortOnConflict }); | ||
| } | ||
| return c.json(result); | ||
| } catch (err) { | ||
| log.error("rebase threw unexpected error", { agentId: agent.id, ...errorMeta(err) }); | ||
| return c.json({ success: false, conflicted: false, error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| app.post("/:id/kill", (c) => { | ||
| const id = c.req.param("id"); | ||
| const agent = agentStmts.get.get(id); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| log.info("killing agent", { agentId: id }); | ||
| agentProcessManager.kill(id); | ||
| agentStmts.updateStatus.run({ $id: id, $status: "error", $endedAt: Date.now() }); | ||
| return c.body(null, 204); | ||
| }); | ||
| try { | ||
| const git = new GitWorktreeManager(remoteConfig.localPath); | ||
| const result = await git.rebase(agent.worktreePath, agent.baseBranch, abortOnConflict); | ||
| if (result.success) { | ||
| log.info("rebase succeeded", { agentId: agent.id }); | ||
| } else if (result.conflicted) { | ||
| log.warn("rebase conflict detected", { agentId: agent.id, abortOnConflict }); | ||
| app.post("/:id/restart", async (c) => { | ||
| const id = c.req.param("id"); | ||
| const agent = agentStmts.get.get(id); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| log.info("restarting agent", { agentId: id }); | ||
| await agentProcessManager.killAndWait(id); | ||
| await orchestrator.resumeAgent(agent); | ||
| return c.body(null, 204); | ||
| }); | ||
| app.post("/:id/input", async (c) => { | ||
| const id = c.req.param("id"); | ||
| let body: { input?: string }; | ||
| try { | ||
| body = await c.req.json<{ input?: string }>(); | ||
| } catch { | ||
| return c.json({ error: "invalid JSON" }, 400); | ||
| } | ||
| return c.json(result); | ||
| } catch (err) { | ||
| log.error("rebase threw unexpected error", { agentId: agent.id, ...errorMeta(err) }); | ||
| return c.json({ success: false, conflicted: false, error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| agentsRouter.post("/:id/kill", (c) => { | ||
| const id = c.req.param("id"); | ||
| const agent = agentStmts.get.get(id); | ||
| if (!agent) return c.json({ error: "agent not found" }, 404); | ||
| log.info("killing agent", { agentId: id }); | ||
| agentProcessManager.kill(id); | ||
| agentStmts.updateStatus.run({ $id: id, $status: "error", $endedAt: Date.now() }); | ||
| return c.body(null, 204); | ||
| }); | ||
| agentsRouter.post("/:id/input", async (c) => { | ||
| const id = c.req.param("id"); | ||
| const body = await c.req.json<{ input?: string }>(); | ||
| if (!body.input) return c.json({ error: "input is required" }, 400); | ||
| try { | ||
| agentProcessManager.write(id, body.input); | ||
| return c.json({ ok: true }); | ||
| } catch (err) { | ||
| log.error("failed to write input to agent", { agentId: id, ...errorMeta(err) }); | ||
| return c.json({ error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
| if (!body.input) return c.json({ error: "input is required" }, 400); | ||
| try { | ||
| agentProcessManager.write(id, body.input); | ||
| return c.json({ ok: true }); | ||
| } catch (err) { | ||
| log.error("failed to write input to agent", { agentId: id, ...errorMeta(err) }); | ||
| return c.json({ error: (err as Error).message }, 500); | ||
| } | ||
| }); | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return app; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -8,6 +8,7 @@ export interface AgentProcess { | ||
| id: string; | ||
| proc: ReturnType<typeof Bun.spawn>; | ||
| emitter: EventEmitter; | ||
| terminating: boolean; | ||
| } | ||
| const processes = new Map<string, AgentProcess>(); | ||
| @@ -66,14 +67,20 @@ export class AgentProcessManager { | ||
| agentStmts.updatePid.run({ $pid: proc.pid, $id: agentId }); | ||
| const ap: AgentProcess = { id: agentId, proc, emitter }; | ||
| const ap: AgentProcess = { id: agentId, proc, emitter, terminating: false }; | ||
| processes.set(agentId, ap); | ||
| return ap; | ||
| } | ||
| write(agentId: string, input: string | Buffer): void { | ||
| const ap = processes.get(agentId); | ||
| if (!ap) throw new Error(`No process for agent ${agentId}`); | ||
| if (!ap || ap.terminating) throw new Error(`No process for agent ${agentId}`); | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ap.proc.terminal!.write(input); | ||
| } | ||
| tryWrite(agentId: string, input: string | Buffer): void { | ||
| const ap = processes.get(agentId); | ||
| if (!ap || ap.terminating) return; | ||
| ap.proc.terminal!.write(input); | ||
| } | ||
| @@ -84,6 +91,18 @@ export class AgentProcessManager { | ||
| processes.delete(agentId); | ||
| } | ||
| async killAndWait(agentId: string): Promise<void> { | ||
| const ap = processes.get(agentId); | ||
| if (!ap) return; | ||
| ap.terminating = true; | ||
| ap.proc.kill(); | ||
| await ap.proc.exited; | ||
| // proc.exited.then() registered in spawn() runs first (registered earlier), | ||
| // so the DB update, onExit callback, and processes.delete() all complete | ||
| // before this line; the delete here is a safe no-op guard. | ||
| processes.delete(agentId); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| subscribe(agentId: string): EventEmitter | null { | ||
| return processes.get(agentId)?.emitter ?? null; | ||
| } | ||
| @@ -96,6 +115,11 @@ export class AgentProcessManager { | ||
| return processes.has(agentId); | ||
| } | ||
| isAcceptingInput(agentId: string): boolean { | ||
| const ap = processes.get(agentId); | ||
| return ap !== undefined && !ap.terminating; | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| listRunning(): string[] { | ||
| return [...processes.keys()]; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.