diff --git a/.changeset/launch-orchestrator-clean.md b/.changeset/launch-orchestrator-clean.md new file mode 100644 index 000000000..a13c2f708 --- /dev/null +++ b/.changeset/launch-orchestrator-clean.md @@ -0,0 +1,8 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-web": minor +--- + +feat: "Launch Orchestrator (clean context)" action on the orchestrator session page + +Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080. diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 8f2057e27..d92c1a4f1 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -13,7 +13,7 @@ import { readMetadata, readMetadataRaw, } from "../../metadata.js"; -import { getProjectWorktreesDir } from "../../paths.js"; +import { getProjectDir, getProjectWorktreesDir } from "../../paths.js"; import type { OrchestratorConfig, PluginRegistry, @@ -2489,4 +2489,183 @@ describe("spawn", () => { }); }); + + describe("relaunchOrchestrator", () => { + it("spawns a fresh orchestrator when none exists", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(session.status).toBe("working"); + expect(mockWorkspace.create).toHaveBeenCalledTimes(1); + expect(mockRuntime.create).toHaveBeenCalledTimes(1); + }); + + it("kills and replaces an existing orchestrator regardless of strategy", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + expect(mockWorkspace.create).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "app-orchestrator" }), + ); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["status"]).toBe("working"); + expect(meta?.["runtimeHandle"]).not.toContain("old-rt"); + }); + + it("ignores project orchestratorSessionStrategy: ignore and still replaces", async () => { + const configWithIgnore: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"]!, + orchestratorSessionStrategy: "ignore", + }, + }, + }; + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config: configWithIgnore, registry: mockRegistry }); + + await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + expect(mockWorkspace.create).toHaveBeenCalled(); + }); + + it("coalesces concurrent relaunch calls into a single replacement", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const [s1, s2] = await Promise.all([ + sm.relaunchOrchestrator({ projectId: "my-app" }), + sm.relaunchOrchestrator({ projectId: "my-app" }), + ]); + + expect(s1.id).toBe("app-orchestrator"); + expect(s2.id).toBe("app-orchestrator"); + expect(mockRuntime.destroy).toHaveBeenCalledTimes(1); + expect(mockWorkspace.create).toHaveBeenCalledTimes(1); + expect(mockRuntime.create).toHaveBeenCalledTimes(1); + }); + + it("ensureOrchestrator waits for an in-flight relaunch before returning", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + let releaseWorkspace: () => void = () => {}; + const blockingWorkspace = new Promise((resolve) => { + releaseWorkspace = resolve; + }); + (mockWorkspace.create as ReturnType).mockImplementationOnce(async (cfg) => { + await blockingWorkspace; + return { + path: join(tmpDir, "ws-relaunch"), + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" }); + await Promise.resolve(); + const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" }); + + releaseWorkspace(); + + const [relaunched, ensured] = await Promise.all([relaunchPromise, ensurePromise]); + expect(relaunched.id).toBe("app-orchestrator"); + expect(ensured.id).toBe("app-orchestrator"); + // Both should resolve to the *post-relaunch* session, not the killed one. + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + }); + + it("waits for an in-flight ensureOrchestrator before replacing", async () => { + let releaseWorkspace: () => void = () => {}; + const blockingWorkspace = new Promise((resolve) => { + releaseWorkspace = resolve; + }); + const ensureWorkspacePath = join(tmpDir, "ws-ensure"); + (mockWorkspace.create as ReturnType).mockImplementationOnce(async (cfg) => { + await blockingWorkspace; + return { + path: ensureWorkspacePath, + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" }); + // Yield so ensure can register itself in the in-flight map. + await Promise.resolve(); + const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" }); + + releaseWorkspace(); + + const [ensured, relaunched] = await Promise.all([ensurePromise, relaunchPromise]); + + expect(ensured.id).toBe("app-orchestrator"); + expect(relaunched.id).toBe("app-orchestrator"); + // Relaunch's kill must run, destroying the runtime ensure just spawned. + expect(mockRuntime.destroy).toHaveBeenCalled(); + }); + + it("rewrites the orchestrator-prompt file with the new system prompt", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.relaunchOrchestrator({ + projectId: "my-app", + systemPrompt: "FRESH ORCHESTRATOR PROMPT", + }); + + const promptPath = join( + getProjectDir("my-app"), + "orchestrator-prompt-app-orchestrator.md", + ); + expect(existsSync(promptPath)).toBe(true); + expect(readFileSync(promptPath, "utf-8")).toBe("FRESH ORCHESTRATOR PROMPT"); + }); + }); }); diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index e7a51e9df..b20f05ffa 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -499,6 +499,11 @@ export function createMockSessionManager(): OpenCodeSessionManager { .mockResolvedValue( makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), ), + relaunchOrchestrator: vi + .fn() + .mockResolvedValue( + makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), + ), restore: vi.fn().mockResolvedValue(makeSession()), list: vi.fn().mockResolvedValue([]), listCached: vi.fn().mockResolvedValue([]), diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index b4879b8ee..84dbe6a1e 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -538,6 +538,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM expiresAt: number; } | null = null; const ensureOrchestratorPromises = new Map>(); + const relaunchOrchestratorPromises = new Map>(); function invalidateCache(): void { sessionCache = null; @@ -1815,6 +1816,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } const sessionId = getOrchestratorSessionId(project); + + // If a relaunch is mid-flight for this sessionId, wait it out — otherwise + // we could return a session that relaunch is about to kill, or race the + // relaunch's spawnOrchestrator on the same reservation. + const pendingRelaunch = relaunchOrchestratorPromises.get(sessionId); + if (pendingRelaunch) { + await pendingRelaunch.catch((err) => { + console.warn( + `[ensureOrchestrator] in-flight relaunch for ${sessionId} failed before ensure proceeded:`, + err, + ); + }); + } + const existing = await get(sessionId); if (existing) { const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( @@ -1876,6 +1891,61 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return promise; } + async function relaunchOrchestratorInternal( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + const sessionId = getOrchestratorSessionId(project); + const sessionsDir = getProjectSessionsDir(orchestratorConfig.projectId); + + // If ensureOrchestrator is mid-flight for this sessionId, wait it out. + // Otherwise get() would return null (metadata not yet written) and we'd + // skip the kill, then race the in-flight spawnOrchestrator on the same + // reservation — surfacing "session already exists" instead of replacing. + const pendingEnsure = ensureOrchestratorPromises.get(sessionId); + if (pendingEnsure) { + await pendingEnsure.catch((err) => { + console.warn( + `[relaunchOrchestrator] in-flight ensure for ${sessionId} failed before relaunch proceeded:`, + err, + ); + }); + } + + const existing = await get(sessionId); + if (existing) { + const existingAgent = resolveSelectionForSession( + project, + sessionId, + readMetadataRaw(sessionsDir, sessionId) ?? {}, + ).agentName; + await kill(sessionId, { purgeOpenCode: existingAgent === "opencode" }); + deleteMetadata(sessionsDir, sessionId); + } + return spawnOrchestrator(orchestratorConfig); + } + + async function relaunchOrchestrator( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + const sessionId = getOrchestratorSessionId(project); + const existingPromise = relaunchOrchestratorPromises.get(sessionId); + if (existingPromise) return existingPromise; + + const promise = relaunchOrchestratorInternal(orchestratorConfig).finally(() => { + relaunchOrchestratorPromises.delete(sessionId); + }); + relaunchOrchestratorPromises.set(sessionId, promise); + return promise; + } + async function list(projectId?: string): Promise { const allSessions = Object.entries(config.projects).flatMap(([entryProjectId, project]) => { if (projectId && entryProjectId !== projectId) return []; @@ -3054,6 +3124,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM spawn, spawnOrchestrator, ensureOrchestrator, + relaunchOrchestrator, restore, list, listCached, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d759c7769..1d5af5b3e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1845,6 +1845,13 @@ export interface SessionManager { spawn(config: SessionSpawnConfig): Promise; spawnOrchestrator(config: OrchestratorSpawnConfig): Promise; ensureOrchestrator(config: OrchestratorSpawnConfig): Promise; + /** + * Replace the canonical orchestrator with a fresh one. If an orchestrator + * already exists for the project, it is killed, its metadata deleted, and a + * new orchestrator spawned with no carryover state. Ignores + * `orchestratorSessionStrategy` — replacement is the whole point. + */ + relaunchOrchestrator(config: OrchestratorSpawnConfig): Promise; restore(sessionId: SessionId): Promise; list(projectId?: string): Promise; get(sessionId: SessionId): Promise; diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 84f769e97..0a32bf0dc 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -129,6 +129,7 @@ const mockSessionManager: SessionManager = { cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })), spawnOrchestrator: vi.fn(), ensureOrchestrator: vi.fn(), + relaunchOrchestrator: vi.fn(), remap: vi.fn(async () => "ses_mock"), restore: vi.fn(async (id: string) => { const session = testSessions.find((s) => s.id === id); @@ -232,7 +233,7 @@ import { GET as sessionDetailGET, PATCH as sessionDetailPATCH, } from "@/app/api/sessions/[id]/route"; -import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route"; +import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; @@ -1173,53 +1174,49 @@ describe("API Routes", () => { expect(data.recovery).toBe("reuse-or-recreate-workspace"); expect(data.error).toContain('AO found an older orchestrator workspace for "my-app"'); }); - }); - describe("GET /api/orchestrators", () => { - it("returns orchestrators for a project", async () => { - const orchestrator = makeSession({ - id: "my-app-orchestrator", - projectId: "my-app", - metadata: { role: "orchestrator" }, + it("calls relaunchOrchestrator instead of spawnOrchestrator when clean is true", async () => { + (mockSessionManager.relaunchOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); + + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean: true }), + headers: { "Content-Type": "application/json" }, }); - (mockSessionManager.list as ReturnType).mockResolvedValueOnce([orchestrator]); + const res = await orchestratorsPOST(req); - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), - ); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.orchestrators).toHaveLength(1); - expect(data.orchestrators[0].id).toBe("my-app-orchestrator"); - expect(data.projectName).toBe("My App"); + expect(res.status).toBe(201); + expect(mockSessionManager.relaunchOrchestrator).toHaveBeenCalledWith({ + projectId: "my-app", + systemPrompt: expect.stringContaining("# My App Orchestrator"), + }); + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); }); - it("returns 400 when project parameter is missing", async () => { - const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators")); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toMatch(/Missing project query parameter/); - }); + it("uses spawnOrchestrator when clean is false or omitted", async () => { + (mockSessionManager.spawnOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); - it("returns 404 for unknown project", async () => { - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"), - ); - expect(res.status).toBe(404); - const data = await res.json(); - expect(data.error).toMatch(/Unknown project/); - }); + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean: false }), + headers: { "Content-Type": "application/json" }, + }); + await orchestratorsPOST(req); - it("returns 500 when list fails", async () => { - (mockSessionManager.list as ReturnType).mockRejectedValueOnce( - new Error("boom"), - ); - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), - ); - expect(res.status).toBe(500); - const data = await res.json(); - expect(data.error).toBe("boom"); + expect(mockSessionManager.spawnOrchestrator).toHaveBeenCalled(); + expect(mockSessionManager.relaunchOrchestrator).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/__tests__/orchestrators.test.tsx b/packages/web/src/__tests__/orchestrators.test.tsx deleted file mode 100644 index 8aaba6d00..000000000 --- a/packages/web/src/__tests__/orchestrators.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import OrchestratorsRoute from "@/app/orchestrators/page"; -import { getServices } from "@/lib/services"; -import { getAllProjects } from "@/lib/project-name"; - -// ── Mocks ───────────────────────────────────────────────────────────── - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ - push: vi.fn(), - }), -})); - -vi.mock("next/link", () => ({ - default: ({ children, href }: { children: React.ReactNode; href: string }) => ( - {children} - ), -})); - -vi.mock("@/lib/services", () => ({ - getServices: vi.fn(), -})); - -vi.mock("@/lib/project-name", () => ({ - getAllProjects: vi.fn(), -})); - -global.fetch = vi.fn(); - -// ── Tests ───────────────────────────────────────────────────────────── - -describe("Orchestrators Page (OrchestratorsRoute)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders the page with searchParams and listed orchestrators", async () => { - const mockSessionManager = { - list: vi.fn().mockResolvedValue([ - { - id: "app-orchestrator", - projectId: "my-app", - status: "working", - activity: "active", - metadata: { role: "orchestrator" }, - createdAt: new Date(), - lastActivityAt: new Date(), - }, - ]), - }; - - (getServices as any).mockResolvedValue({ - config: { - projects: { - "my-app": { name: "My App", sessionPrefix: "app" }, - }, - }, - sessionManager: mockSessionManager, - }); - - (getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]); - - const searchParams = Promise.resolve({ project: "my-app" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("My App")).toBeInTheDocument(); - expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); - }); - - it("shows error when project is missing in searchParams", async () => { - const searchParams = Promise.resolve({}); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("Missing Project")).toBeInTheDocument(); - }); - - it("shows error when project is not found in config", async () => { - (getServices as any).mockResolvedValue({ - config: { projects: {} }, - sessionManager: { list: vi.fn() }, - }); - (getAllProjects as any).mockReturnValue([]); - - const searchParams = Promise.resolve({ project: "ghost" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument(); - }); - - it("handles service errors gracefully", async () => { - (getServices as any).mockRejectedValue(new Error("Database down")); - - const searchParams = Promise.resolve({ project: "my-app" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("Database down")).toBeInTheDocument(); - }); -}); diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts index 886761693..54890fa85 100644 --- a/packages/web/src/app/api/orchestrators/route.ts +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -1,8 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; -import { generateOrchestratorPrompt, generateSessionPrefix } from "@aoagents/ao-core"; +import { generateOrchestratorPrompt } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { validateIdentifier, validateConfiguredProject } from "@/lib/validation"; -import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; function classifySpawnError(projectId: string, error: unknown): { status: number; @@ -35,46 +34,6 @@ function classifySpawnError(projectId: string, error: unknown): { }; } -/** - * GET /api/orchestrators?project= - * List existing orchestrator sessions for a project. - */ -export async function GET(request: NextRequest) { - const projectId = request.nextUrl.searchParams.get("project"); - - if (!projectId) { - return NextResponse.json({ error: "Missing project query parameter" }, { status: 400 }); - } - - const projectErr = validateIdentifier(projectId, "projectId"); - if (projectErr) { - return NextResponse.json({ error: projectErr }, { status: 400 }); - } - - try { - const { config, sessionManager } = await getServices(); - const configProjectErr = validateConfiguredProject(config.projects, projectId); - if (configProjectErr) { - return NextResponse.json({ error: configProjectErr }, { status: 404 }); - } - const project = config.projects[projectId]; - const sessionPrefix = project.sessionPrefix ?? projectId; - - const allSessions = await sessionManager.list(projectId); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - const orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes); - - return NextResponse.json({ orchestrators, projectName: project.name }); - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to list orchestrators" }, - { status: 500 }, - ); - } -} - export async function POST(request: NextRequest) { const body = (await request.json().catch(() => null)) as Record | null; if (!body) { @@ -86,6 +45,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: projectErr }, { status: 400 }); } + const clean = body.clean === true; + try { const { config, sessionManager } = await getServices(); const projectId = body.projectId as string; @@ -96,7 +57,9 @@ export async function POST(request: NextRequest) { const project = config.projects[projectId]; const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); - const session = await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); + const session = clean + ? await sessionManager.relaunchOrchestrator({ projectId, systemPrompt }) + : await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); return NextResponse.json( { diff --git a/packages/web/src/app/api/projects/[id]/route.ts b/packages/web/src/app/api/projects/[id]/route.ts index f940aefae..c98964402 100644 --- a/packages/web/src/app/api/projects/[id]/route.ts +++ b/packages/web/src/app/api/projects/[id]/route.ts @@ -31,7 +31,7 @@ function sanitizeString(value: unknown): string | undefined { } function revalidateProjectPaths(projectId: string): void { - for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) { + for (const route of ["/", "/prs", `/projects/${projectId}`]) { try { revalidatePath(route); } catch { diff --git a/packages/web/src/app/api/projects/route.ts b/packages/web/src/app/api/projects/route.ts index 0947558a6..2bafa5212 100644 --- a/packages/web/src/app/api/projects/route.ts +++ b/packages/web/src/app/api/projects/route.ts @@ -33,7 +33,7 @@ function isGitRepository(projectPath: string): boolean { } function revalidatePortfolioPaths(projectId: string): void { - for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) { + for (const route of ["/", "/prs", `/projects/${projectId}`]) { try { revalidatePath(route); } catch { diff --git a/packages/web/src/app/orchestrators/page.tsx b/packages/web/src/app/orchestrators/page.tsx deleted file mode 100644 index 63444c915..000000000 --- a/packages/web/src/app/orchestrators/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { Metadata } from "next"; -import { OrchestratorSelector, type Orchestrator } from "@/components/OrchestratorSelector"; -import { getServices } from "@/lib/services"; -import { getAllProjects } from "@/lib/project-name"; -import { generateSessionPrefix } from "@aoagents/ao-core"; -import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; - -export const dynamic = "force-dynamic"; - -export async function generateMetadata(props: { - searchParams: Promise<{ project?: string }>; -}): Promise { - const searchParams = await props.searchParams; - const projectId = searchParams.project; - let projectName = "Orchestrator"; - if (projectId) { - const projects = getAllProjects(); - const project = projects.find((p) => p.id === projectId); - if (project) { - projectName = project.name; - } - } - return { title: { absolute: `ao | ${projectName} - Orchestrator` } }; -} - -export default async function OrchestratorsRoute(props: { - searchParams: Promise<{ project?: string }>; -}) { - const searchParams = await props.searchParams; - const projectId = searchParams.project; - - if (!projectId) { - return ( -
-
-

- Missing Project -

-

- No project specified. Please provide a project parameter. -

-
-
- ); - } - - let orchestrators: Orchestrator[] = []; - let projectName = projectId; - let error: string | null = null; - - try { - const { config, sessionManager } = await getServices(); - const project = config.projects[projectId]; - - if (!project) { - error = `Project "${projectId}" not found`; - } else { - projectName = project.name; - const sessionPrefix = project.sessionPrefix ?? projectId; - const allSessions = await sessionManager.list(projectId); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - orchestrators = mapSessionsToOrchestrators( - allSessions, - sessionPrefix, - project.name, - allSessionPrefixes, - ); - } - } catch (err) { - error = err instanceof Error ? err.message : "Failed to load orchestrators"; - } - - return ( - - ); -} diff --git a/packages/web/src/components/OrchestratorSelector.tsx b/packages/web/src/components/OrchestratorSelector.tsx deleted file mode 100644 index a178a7a6a..000000000 --- a/packages/web/src/components/OrchestratorSelector.tsx +++ /dev/null @@ -1,273 +0,0 @@ -"use client"; - -import { useState, useRef } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { cn } from "@/lib/cn"; -import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; - -export interface Orchestrator { - id: string; - projectId: string; - projectName: string; - status: string; - activity: string | null; - createdAt: string | null; - lastActivityAt: string | null; -} - -interface OrchestratorSelectorProps { - orchestrators: Orchestrator[]; - projectId: string; - projectName: string; - error: string | null; -} - -function formatRelativeTime(isoDate: string | null): string { - if (!isoDate) return "Unknown"; - const date = new Date(isoDate); - const timestamp = date.getTime(); - // Guard against invalid dates (NaN) and future timestamps - if (!Number.isFinite(timestamp)) return "Unknown"; - const now = new Date(); - const diffMs = now.getTime() - timestamp; - // Handle future timestamps - if (diffMs < 0) return "Just now"; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - return `${diffDays}d ago`; -} - -function getStatusColor(status: string): string { - switch (status) { - case "working": - return "var(--color-status-working)"; - case "spawning": - return "var(--color-status-attention)"; - case "pr_open": - case "review_pending": - case "approved": - case "mergeable": - return "var(--color-status-ready)"; - case "ci_failed": - case "changes_requested": - return "var(--color-status-error)"; - case "merged": - case "done": - case "killed": - case "terminated": - return "var(--color-text-tertiary)"; - default: - return "var(--color-text-secondary)"; - } -} - -function getActivityLabel(activity: string | null): string { - if (!activity) return ""; - switch (activity) { - case "active": - return "Active"; - case "ready": - return "Ready"; - case "idle": - return "Idle"; - case "waiting_input": - return "Waiting"; - case "blocked": - return "Blocked"; - case "exited": - return "Exited"; - default: - return activity; - } -} - -export function OrchestratorSelector({ - orchestrators, - projectId, - projectName, - error, -}: OrchestratorSelectorProps) { - const router = useRouter(); - const [isSpawning, setIsSpawning] = useState(false); - const [spawnError, setSpawnError] = useState(null); - const spawnLockRef = useRef(false); - - const handleSpawnNew = async () => { - // Synchronous re-entrancy guard: React state updates are async, - // so two clicks before rerender would fire two POSTs without this. - if (spawnLockRef.current) return; - spawnLockRef.current = true; - setIsSpawning(true); - setSpawnError(null); - - try { - const response = await fetch("/api/orchestrators", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId }), - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to spawn orchestrator"); - } - - const data = await response.json(); - router.push(projectSessionPath(projectId, data.orchestrator.id)); - } catch (err) { - setSpawnError(err instanceof Error ? err.message : "Failed to spawn orchestrator"); - } finally { - setIsSpawning(false); - spawnLockRef.current = false; - } - }; - - if (error) { - return ( -
-
-

Error

-

{error}

- - Go to Dashboard - -
-
- ); - } - - return ( -
- {/* Header */} -
-
-
-

- {projectName} -

-

Project orchestrator

-
- - Dashboard - -
-
- - {/* Content */} -
-
- {/* Info banner */} -
-

- AO keeps one main orchestrator per project. Opening or spawning here reuses that - canonical session instead of creating another duplicate. -

-
- - {/* Existing orchestrators */} -
-

- Main Session -

-
- {orchestrators.map((orch) => ( - -
-
-
-
{orch.id}
-
- {orch.status.replace(/_/g, " ")} - {orch.activity && ( - <> - · - {getActivityLabel(orch.activity)} - - )} -
-
-
-
-
Created {formatRelativeTime(orch.createdAt)}
- {orch.lastActivityAt && ( -
Active {formatRelativeTime(orch.lastActivityAt)}
- )} -
- - ))} -
-
- - {/* Start new section */} -
-

- Open Orchestrator -

- - {spawnError && ( -

{spawnError}

- )} -
-
-
-
- ); -} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index d904d559d..5cb0d0f35 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -66,6 +66,7 @@ export function SessionDetail({ const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [showTerminal, setShowTerminal] = useState(false); + const [relaunchError, setRelaunchError] = useState(null); const pr = session.pr; const terminalEnded = isDashboardSessionTerminal(session); const isRestorable = isDashboardSessionRestorable(session); @@ -119,6 +120,47 @@ export function SessionDetail({ } }, [session.id]); + const handleRelaunchClean = useCallback(async () => { + const confirmed = window.confirm( + "This will discard the current orchestrator's conversation and state. Continue?", + ); + if (!confirmed) return; + setRelaunchError(null); + try { + const res = await fetch("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: session.projectId, clean: true }), + }); + if (!res.ok) { + // Surface server-side errors. Note: a failure here may indicate the + // existing orchestrator was killed but respawn failed — the banner + // tells the user the previous session was terminated so they don't + // assume the page they're looking at is still live. + let message = ""; + try { + const data = (await res.json()) as { error?: string }; + message = data.error ?? ""; + } catch { + message = await res.text().catch(() => ""); + } + throw new Error(message || `HTTP ${res.status}`); + } + // Hard-navigate to the freshly spawned orchestrator's session page. + // Orchestrator session IDs are fixed per project, so this is the same + // path in practice — but reading from the response keeps us correct if + // the contract ever changes (and a hard nav forces the terminal + // WebSocket to reconnect against the new tmux session). + const data = (await res.json()) as { orchestrator?: { id: string } }; + const newId = data.orchestrator?.id ?? session.id; + window.location.href = projectSessionPath(session.projectId, newId); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to relaunch orchestrator"; + console.error("Failed to relaunch orchestrator:", err); + setRelaunchError(message); + } + }, [session.id, session.projectId]); + const orchestratorHref = useMemo(() => { if (isOrchestrator) return projectSessionPath(session.projectId, session.id); if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId); @@ -158,6 +200,7 @@ export function SessionDetail({ onToggleSidebar={handleToggleSidebar} onRestore={handleRestore} onKill={handleKill} + onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined} />
+ {relaunchError ? ( +
+
+
+ Relaunch failed: {relaunchError} +
+ The previous orchestrator may already be terminated. Try again from the + project dashboard. +
+
+ +
+
+ ) : null}
{!showTerminal ? (
diff --git a/packages/web/src/components/SessionDetailHeader.tsx b/packages/web/src/components/SessionDetailHeader.tsx index f6090efcd..25432df01 100644 --- a/packages/web/src/components/SessionDetailHeader.tsx +++ b/packages/web/src/components/SessionDetailHeader.tsx @@ -36,6 +36,7 @@ interface SessionDetailHeaderProps { onToggleSidebar: () => void; onRestore: () => void; onKill: () => void; + onRelaunchClean?: () => void; } function normalizeActivityLabelForClass(activityLabel: string): string { @@ -80,6 +81,7 @@ export function SessionDetailHeader({ onToggleSidebar, onRestore, onKill, + onRelaunchClean, }: SessionDetailHeaderProps) { const pr = session.pr; const allGreen = pr ? isPRMergeReady(pr) : false; @@ -302,6 +304,30 @@ export function SessionDetailHeader({ ) : null} + {isOrchestrator && onRelaunchClean ? ( + + ) : null} + {orchestratorHref ? ( ({ - useRouter: () => ({ push: mockPush }), -})); - -const mockOrchestrators = [ - { - id: "app-orchestrator", - projectId: "my-project", - projectName: "My Project", - status: "working", - activity: "active", - createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago - lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago - }, -]; - -const defaultProps = { - orchestrators: mockOrchestrators, - projectId: "my-project", - projectName: "My Project", - error: null, -}; - -describe("OrchestratorSelector", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPush.mockClear(); - global.fetch = vi.fn(); - }); - - it("renders orchestrator list", () => { - render(); - - expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); - }); - - it("displays project name in header", () => { - render(); - - expect(screen.getByText("My Project")).toBeInTheDocument(); - expect(screen.getByText("Project orchestrator")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute( - "href", - "/projects/my-project", - ); - }); - - it("explains that orchestrator opening reuses the canonical session", () => { - render(); - - expect(screen.getByText(/one main orchestrator per project/i)).toBeInTheDocument(); - }); - - it("shows error state", () => { - render(); - - expect(screen.getByText("Error")).toBeInTheDocument(); - expect(screen.getByText("Project not found")).toBeInTheDocument(); - }); - - it("shows open orchestrator button", () => { - render(); - - expect(screen.getByRole("button", { name: /open orchestrator/i })).toBeInTheDocument(); - }); - - it("spawns new orchestrator on button click and navigates", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - orchestrator: { id: "app-orchestrator" }, - }), - }); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/orchestrators", expect.any(Object)); - }); - - await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator"); - }); - }); - - it("shows loading state while spawning", async () => { - const mockFetch = vi.fn().mockImplementation( - () => new Promise(() => {}), // Never resolves - ); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(screen.getByText(/opening orchestrator/i)).toBeInTheDocument(); - }); - }); - - it("shows error when spawn fails", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ error: "Failed to spawn" }), - }); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(screen.getByText("Failed to spawn")).toBeInTheDocument(); - }); - }); - - it("links to orchestrator session page", () => { - render(); - - const link = screen.getByRole("link", { name: /app-orchestrator/i }); - expect(link).toHaveAttribute("href", "/projects/my-project/sessions/app-orchestrator"); - }); - - it("displays status and activity for each orchestrator", () => { - render(); - - expect(screen.getByText("working")).toBeInTheDocument(); - expect(screen.getByText("Active")).toBeInTheDocument(); - }); - - it("covers relative time for days and status colors/labels", () => { - const wideOrchestrators = [ - { - id: "orch-2", - projectId: "my-project", - projectName: "My Project", - status: "ci_failed", - activity: "waiting_input", - createdAt: new Date(Date.now() - 3600000 * 50).toISOString(), // 2d ago - lastActivityAt: null, - }, - { - id: "orch-3", - projectId: "my-project", - projectName: "My Project", - status: "killed", - activity: "ready", - createdAt: new Date(Date.now() - 1000).toISOString(), // Just now - lastActivityAt: null, - }, - { - id: "orch-4", - projectId: "my-project", - projectName: "My Project", - status: "unknown", - activity: "blocked", - createdAt: new Date().toISOString(), - lastActivityAt: null, - }, - { - id: "orch-5", - projectId: "my-project", - projectName: "My Project", - status: "mergeable", - activity: "exited", - createdAt: new Date().toISOString(), - lastActivityAt: null, - }, - ]; - - render(); - - expect(screen.getByText(/2d ago/)).toBeInTheDocument(); - expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0); - expect(screen.getByText(/Waiting/)).toBeInTheDocument(); - expect(screen.getByText(/Ready/)).toBeInTheDocument(); - expect(screen.getByText(/Blocked/)).toBeInTheDocument(); - expect(screen.getByText(/Exited/)).toBeInTheDocument(); - expect(screen.getByText(/ci failed/i)).toBeInTheDocument(); - }); - - describe("formatRelativeTime edge cases", () => { - it("shows Unknown for invalid date strings", () => { - const orchestratorsWithInvalidDate = [ - { - ...mockOrchestrators[0], - createdAt: "not-a-valid-date", - lastActivityAt: null, - }, - ]; - render( - , - ); - - // The "Created Unknown" text should appear for invalid dates - expect(screen.getByText(/Created Unknown/)).toBeInTheDocument(); - }); - - it("shows Just now for future timestamps", () => { - const futureDate = new Date(Date.now() + 60000).toISOString(); // 1 minute in future - const orchestratorsWithFutureDate = [ - { - ...mockOrchestrators[0], - createdAt: futureDate, - lastActivityAt: null, - }, - ]; - render( - , - ); - - // Future timestamps should show "Just now" instead of negative values - expect(screen.getByText(/Created Just now/)).toBeInTheDocument(); - }); - - it("shows Unknown for null dates", () => { - const orchestratorsWithNullDate = [ - { - ...mockOrchestrators[0], - createdAt: null, - lastActivityAt: null, - }, - ]; - render(); - - expect(screen.getByText(/Created Unknown/)).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index d63ad18f8..e6df9645b 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -304,6 +304,161 @@ describe("SessionDetail desktop layout", () => { ).not.toBeInTheDocument(); }); + it("renders Relaunch (clean) on live orchestrator sessions and navigates to the new session", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { + ...window.location, + set href(value: string) { + hrefSetter(value); + }, + }, + writable: true, + }); + vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/orchestrators") { + return { + ok: true, + json: async () => ({ + orchestrator: { id: "my-app-orchestrator", projectId: "my-app" }, + }), + } as Response; + } + return { ok: true, json: async () => ({}), text: async () => "" } as Response; + }); + + render( + , + ); + + const relaunchBtn = within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }); + fireEvent.click(relaunchBtn); + + expect(confirmSpy).toHaveBeenCalled(); + await act(async () => {}); + + expect(global.fetch).toHaveBeenCalledWith("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: "my-app", clean: true }), + }); + expect(hrefSetter).toHaveBeenCalledWith("/projects/my-app/sessions/my-app-orchestrator"); + + confirmSpy.mockRestore(); + }); + + it("keeps Relaunch (clean) visible on terminated orchestrator sessions", () => { + render( + , + ); + + expect( + within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }), + ).toBeInTheDocument(); + }); + + it("surfaces a relaunch error banner when POST fails after confirm", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/orchestrators") { + return { + ok: false, + status: 500, + json: async () => ({ error: "kill+respawn failed" }), + text: async () => "kill+respawn failed", + } as Response; + } + return { ok: true, json: async () => ({}), text: async () => "" } as Response; + }); + + render( + , + ); + + fireEvent.click( + within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }), + ); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent(/kill\+respawn failed/i); + expect(alert).toHaveTextContent(/previous orchestrator may already be terminated/i); + + fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + confirmSpy.mockRestore(); + }); + + it("does not render Relaunch (clean) on worker sessions", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: /launch orchestrator \(clean context\)/i }), + ).not.toBeInTheDocument(); + }); + it("restores without using router refresh on the client-only session page", async () => { render( isOrchestratorSession(s, sessionPrefix, allSessionPrefixes) && !isTerminalSession(s)) - .sort((a, b) => { - if (a.id === canonicalId) return -1; - if (b.id === canonicalId) return 1; - return (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0); - }) - .map((s) => ({ - id: s.id, - projectId: s.projectId, - projectName, - status: s.status, - activity: s.activity, - createdAt: s.createdAt?.toISOString() ?? null, - lastActivityAt: s.lastActivityAt?.toISOString() ?? null, - })); -}