From 863ebfaa960bf3b884c5c6e38820a0597b1073b1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 14 Jun 2026 21:19:28 -0700 Subject: [PATCH] FN-6464: add CLI session relaunch route Enable exhausted CLI sessions to request a fresh task-backed relaunch from the dashboard. - Add an authenticated project-scoped relaunch endpoint for task-bound CLI sessions.\n- Wire relaunch intent through the dashboard server to clear resume linkage and re-enqueue the owning task.\n- Enable the session banner Relaunch fresh action and cover API, UI, transport, and runtime wiring behavior.\n- Document the CLI session action contract and add a published package changeset.\n\nFiles changed:\n .changeset/fn-6464-cli-relaunch-route.md | 5 ++ docs/agents.md | 4 + packages/cli/src/commands/dashboard.ts | 2 + packages/dashboard/app/App.tsx | 20 +++-- .../app/__tests__/app-cli-action-wiring.test.tsx | 31 +++++++- packages/dashboard/app/api/legacy.ts | 7 ++ .../__tests__/SessionNotificationBanner.test.tsx | 52 ++++++++++--- .../src/__tests__/cli-agent-runtime-wiring.test.ts | 49 +++++++++++- .../src/__tests__/cli-session-transport.test.ts | 36 +++++++++ .../src/__tests__/cli-sessions-routes.test.ts | 50 +++++++++++- packages/dashboard/src/cli-session-transport.ts | 38 +++++++++ packages/dashboard/src/index.ts | 3 +- packages/dashboard/src/routes/cli-sessions.ts | 26 ++++++- packages/dashboard/src/server.ts | 89 ++++++++++++++++++++++ 14 files changed, 390 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-6464 Fusion-Task-Lineage: f1ce171b-84a7-4893-9288-e1c8f01c3305 --- .changeset/fn-6464-cli-relaunch-route.md | 5 ++ docs/agents.md | 4 + packages/cli/src/commands/dashboard.ts | 2 + packages/dashboard/app/App.tsx | 20 +++-- .../__tests__/app-cli-action-wiring.test.tsx | 31 ++++++- packages/dashboard/app/api/legacy.ts | 7 ++ .../SessionNotificationBanner.test.tsx | 52 +++++++++-- .../cli-agent-runtime-wiring.test.ts | 49 +++++++++- .../__tests__/cli-session-transport.test.ts | 36 ++++++++ .../src/__tests__/cli-sessions-routes.test.ts | 50 ++++++++++- .../dashboard/src/cli-session-transport.ts | 38 ++++++++ packages/dashboard/src/index.ts | 3 +- packages/dashboard/src/routes/cli-sessions.ts | 26 +++++- packages/dashboard/src/server.ts | 89 +++++++++++++++++++ 14 files changed, 390 insertions(+), 22 deletions(-) create mode 100644 .changeset/fn-6464-cli-relaunch-route.md create mode 100644 packages/dashboard/src/__tests__/cli-session-transport.test.ts diff --git a/.changeset/fn-6464-cli-relaunch-route.md b/.changeset/fn-6464-cli-relaunch-route.md new file mode 100644 index 0000000000..b2b5aa849c --- /dev/null +++ b/.changeset/fn-6464-cli-relaunch-route.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a CLI session relaunch route and enable the dashboard's resume-exhausted "Relaunch fresh" action to re-enqueue the owning task for a fresh CLI-agent run. diff --git a/docs/agents.md b/docs/agents.md index bd61143c97..7d91809dea 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -4,6 +4,10 @@ Fusion uses multiple agent roles for planning, execution, review, and merge workflows. +## CLI session actions + +The dashboard's CLI session banner uses authenticated `POST /api/cli-sessions/:id/*` routes for task-bound CLI sessions. `POST /api/cli-sessions/:id/relaunch` is project-scoped, rejects sessions that do not have a `taskId`, records a relaunch intent, and lets the engine listener clear resume linkage before moving the owning task back to `todo` for a fresh executor launch. This route backs the `resume-exhausted` banner's **Relaunch fresh** action; when a session summary has no `cliSessionId`, the client does not call the route. + ## Interactive CLI Chat Use `fn chat` to message an agent from your terminal. diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 0e31c717d3..00d8dc4e43 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -30,6 +30,7 @@ import { AttachTicketStore, CliInputAttributionLog, CliConfirmAdvanceRegistry, + CliRelaunchRegistry, GitHubClient, createSkillsAdapter, getCliPackageVersion, @@ -1770,6 +1771,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ticketStore: new AttachTicketStore(), attributionLog: new CliInputAttributionLog(), confirmAdvance: new CliConfirmAdvanceRegistry(), + relaunch: new CliRelaunchRegistry(), } : undefined; diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index f354f583c0..ce408595b2 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -90,7 +90,7 @@ import { NativeShellConnectionManager } from "./components/NativeShellConnection import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native"; import type { AiSessionSummary, DashboardHealthResponse } from "./api"; -import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; +import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth, relaunchCliSession } from "./api"; import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; import { subscribeSse } from "./sse-bus"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; @@ -256,12 +256,9 @@ export function isSessionNeedingInputForBanner(session: AiSessionSummary): boole } export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null { - if (action === "advance" && !session.cliSessionId) { + if ((action === "advance" || action === "relaunch") && !session.cliSessionId) { return "CLI session id is missing."; } - if (action === "relaunch") { - return "Relaunch is not supported by the dashboard yet."; - } return null; } @@ -270,8 +267,9 @@ interface CliActionDeps { retryTask: (id: string) => Promise; moveTask: (id: string, column: "todo") => Promise; openAuthenticationSettings: () => void; - addToast: (message: string, type: "error") => void; + addToast: (message: string, type: "success" | "error") => void; apiClient?: typeof api; + relaunchCliSessionClient?: typeof relaunchCliSession; } export async function executeCliSessionBannerAction( @@ -283,6 +281,9 @@ export async function executeCliSessionBannerAction( /* * FNXC:SessionBanner 2026-06-14-19:32: * CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow. + * + * FNXC:SessionBanner 2026-06-14-20:16: + * `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason. */ if (action === "advance") { if (!session.cliSessionId) { @@ -295,6 +296,13 @@ export async function executeCliSessionBannerAction( return; } + if (action === "relaunch") { + if (!session.cliSessionId) return; + await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId); + deps.addToast("CLI session relaunch requested", "success"); + return; + } + if (action === "retry") { await deps.retryTask(session.id); return; diff --git a/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx b/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx index 4e768e3412..e925c68205 100644 --- a/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx +++ b/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx @@ -38,12 +38,14 @@ describe("App CLI session banner wiring", () => { ["retry", "retryTask"], ["cancel", "moveTask"], ["reauthenticate", "openSettings"], + ["relaunch", "relaunchCliSession"], ] as const)("maps %s to an observable existing route or flow", async (action, expected) => { const apiClient = vi.fn().mockResolvedValue({ ok: true }); const retryTask = vi.fn().mockResolvedValue({ id: "FN-6458" }); const moveTask = vi.fn().mockResolvedValue({ id: "FN-6458" }); const openAuthenticationSettings = vi.fn(); const addToast = vi.fn(); + const relaunchCliSessionClient = vi.fn().mockResolvedValue({ ok: true, taskId: "FN-6458" }); await executeCliSessionBannerAction(cliSession(), action, { currentProjectId: "proj-1", @@ -52,6 +54,7 @@ describe("App CLI session banner wiring", () => { openAuthenticationSettings, addToast, apiClient, + relaunchCliSessionClient, }); if (expected === "api") { @@ -66,21 +69,43 @@ describe("App CLI session banner wiring", () => { expect(retryTask).toHaveBeenCalledWith("FN-6458"); } else if (expected === "moveTask") { expect(moveTask).toHaveBeenCalledWith("FN-6458", "todo"); + } else if (expected === "relaunchCliSession") { + expect(relaunchCliSessionClient).toHaveBeenCalledWith("cli-session-1", "proj-1"); + expect(addToast).toHaveBeenCalledWith("CLI session relaunch requested", "success"); } else { expect(openAuthenticationSettings).toHaveBeenCalledTimes(1); } - expect(addToast).not.toHaveBeenCalled(); + if (expected !== "relaunchCliSession") { + expect(addToast).not.toHaveBeenCalled(); + } }); - it("marks unsupported or missing-id actions disabled so visible buttons are not silent no-ops", () => { + it("marks missing-id actions disabled so visible buttons are not silent no-ops", () => { const actions: CliActionId[] = ["advance", "retry", "cancel", "reauthenticate", "relaunch"]; const missingId = cliSession({ cliSessionId: undefined }); const withId = cliSession(); const disabled = new Map(actions.map((action) => [action, getCliActionDisabledReasonForBanner(withId, action)])); - expect(disabled.get("relaunch")).toMatch(/not supported/i); + expect(disabled.get("relaunch")).toBeNull(); expect(disabled.get("advance")).toBeNull(); expect(getCliActionDisabledReasonForBanner(missingId, "advance")).toMatch(/missing/i); + expect(getCliActionDisabledReasonForBanner(missingId, "relaunch")).toMatch(/missing/i); + }); + + it("does not fire a relaunch API call when the CLI session id is missing", async () => { + const relaunchCliSessionClient = vi.fn(); + const addToast = vi.fn(); + + await executeCliSessionBannerAction(cliSession({ cliSessionId: undefined }), "relaunch", { + retryTask: vi.fn(), + moveTask: vi.fn(), + openAuthenticationSettings: vi.fn(), + addToast, + relaunchCliSessionClient, + }); + + expect(relaunchCliSessionClient).not.toHaveBeenCalled(); + expect(addToast).not.toHaveBeenCalled(); }); it("toasts instead of silently failing if an enabled CLI action route rejects", async () => { diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 6805071c78..844f349497 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -724,6 +724,13 @@ export function retryTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/retry`, projectId), { method: "POST" }); } +export function relaunchCliSession(sessionId: string, projectId?: string): Promise<{ ok: boolean; taskId?: string }> { + return api<{ ok: boolean; taskId?: string }>( + withProjectId(`/cli-sessions/${encodeURIComponent(sessionId)}/relaunch`, projectId), + { method: "POST" }, + ); +} + export function recoverBranchBinding(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/recover-branch-binding`, projectId), { method: "POST" }); } diff --git a/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx b/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx index c7c89c9f92..0fbaec0893 100644 --- a/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx @@ -405,19 +405,55 @@ describe("SessionNotificationBanner — cli-agent (U11)", () => { expect(screen.getByText("Retry")).toBeInTheDocument(); }); - it("resume-exhausted renders Relaunch fresh / Cancel task", () => { + it.each(["desktop", "mobile"] as const)( + "resume-exhausted renders an enabled Relaunch fresh action at the %s breakpoint", + (breakpoint) => { + const onCliAction = vi.fn(); + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: breakpoint === "mobile" ? 390 : 1280, + }); + window.dispatchEvent(new Event("resize")); + + render( + , + ); + expect(screen.getByText("Couldn't resume the session")).toBeInTheDocument(); + const relaunchButton = screen.getByRole("button", { name: "Relaunch fresh" }); + expect(relaunchButton).not.toBeDisabled(); + expect(relaunchButton).not.toHaveAttribute("aria-disabled"); + expect(relaunchButton).not.toHaveAttribute("data-cli-action-disabled"); + fireEvent.click(relaunchButton); + expect(onCliAction).toHaveBeenCalledWith(expect.objectContaining({ id: "cli-1" }), "relaunch"); + expect(screen.getByText("Cancel task")).toBeInTheDocument(); + }, + ); + + it("renders relaunch disabled when the host reports a missing CLI session id", () => { + const onCliAction = vi.fn(); render( + action === "relaunch" && !session.cliSessionId ? "CLI session id is missing." : null + } />, ); - expect(screen.getByText("Couldn't resume the session")).toBeInTheDocument(); - expect(screen.getByText("Relaunch fresh")).toBeInTheDocument(); - expect(screen.getByText("Cancel task")).toBeInTheDocument(); + + const relaunchButton = screen.getByRole("button", { name: /Relaunch fresh unavailable: CLI session id is missing/i }); + expect(relaunchButton).toBeDisabled(); + fireEvent.click(relaunchButton); + expect(onCliAction).not.toHaveBeenCalled(); }); it.each([ @@ -436,9 +472,7 @@ describe("SessionNotificationBanner — cli-agent (U11)", () => { onDismissSession={onDismissSession} onDismissAll={vi.fn()} onCliAction={onCliAction} - getCliActionDisabledReason={(_session, candidate) => - candidate === "relaunch" ? "Relaunch is not supported by the dashboard yet." : null - } + getCliActionDisabledReason={() => null} />, ); diff --git a/packages/dashboard/src/__tests__/cli-agent-runtime-wiring.test.ts b/packages/dashboard/src/__tests__/cli-agent-runtime-wiring.test.ts index d5bf274ded..1e6e5d2ee3 100644 --- a/packages/dashboard/src/__tests__/cli-agent-runtime-wiring.test.ts +++ b/packages/dashboard/src/__tests__/cli-agent-runtime-wiring.test.ts @@ -16,7 +16,7 @@ */ import express from "express"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -28,8 +28,10 @@ import { AttachTicketStore, CliInputAttributionLog, CliConfirmAdvanceRegistry, + CliRelaunchRegistry, } from "../cli-session-transport.js"; import { createCliSessionsRouter } from "../routes/cli-sessions.js"; +import { wireCliRelaunchListener } from "../server.js"; import { request } from "../test-request.js"; function mockPty(): typeof import("node-pty") { @@ -104,6 +106,7 @@ describe("cli-agent runtime server wiring", () => { ticketStore: new AttachTicketStore(), attributionLog: new CliInputAttributionLog(), confirmAdvance: new CliConfirmAdvanceRegistry(), + relaunch: new CliRelaunchRegistry(), }; const app = express(); @@ -119,4 +122,48 @@ describe("cli-agent runtime server wiring", () => { const sessions = res.body.sessions as Array<{ taskId?: string }>; expect(sessions.some((s) => s.taskId === "FN-1")).toBe(true); }); + + it("wires relaunch events to clear resume linkage and re-enqueue the task", async () => { + const relaunch = new CliRelaunchRegistry(); + const updateSession = vi.fn(); + const taskStore = { + getTask: vi.fn().mockResolvedValue({ id: "FN-6464", column: "in-progress" }), + updateTask: vi.fn().mockResolvedValue(undefined), + moveTask: vi.fn().mockResolvedValue({ id: "FN-6464", column: "todo" }), + logEntry: vi.fn().mockResolvedValue(undefined), + }; + + wireCliRelaunchListener({ + relaunch, + cliSessionStore: { updateSession } as never, + engine: { + getProjectId: () => "proj-a", + getTaskStore: () => taskStore, + } as never, + }); + + relaunch.record("cli-dead", "proj-a", "FN-6464"); + await vi.waitFor(() => expect(taskStore.moveTask).toHaveBeenCalled()); + + expect(updateSession).toHaveBeenCalledWith("cli-dead", { + agentState: "dead", + terminationReason: "killed", + nativeSessionId: null, + resumeAttempts: 2, + }); + expect(taskStore.logEntry).toHaveBeenCalledWith( + "FN-6464", + expect.stringContaining("fresh executor run"), + ); + expect(taskStore.updateTask).toHaveBeenCalledWith("FN-6464", { + paused: false, + status: null, + error: null, + }); + expect(taskStore.moveTask).toHaveBeenCalledWith("FN-6464", "todo", { + preserveProgress: true, + moveSource: "engine", + recoveryRehome: true, + }); + }); }); diff --git a/packages/dashboard/src/__tests__/cli-session-transport.test.ts b/packages/dashboard/src/__tests__/cli-session-transport.test.ts new file mode 100644 index 0000000000..250342201d --- /dev/null +++ b/packages/dashboard/src/__tests__/cli-session-transport.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { CliRelaunchRegistry } from "../cli-session-transport.js"; + +describe("CliRelaunchRegistry", () => { + it("records the latest relaunch request and emits to subscribers", () => { + const registry = new CliRelaunchRegistry(); + const listener = vi.fn(); + + registry.on(listener); + registry.record("cli-1", "proj-a", "FN-6464"); + + expect(registry.getLatest("cli-1")).toEqual({ + sessionId: "cli-1", + projectId: "proj-a", + taskId: "FN-6464", + }); + expect(listener).toHaveBeenCalledWith({ + sessionId: "cli-1", + projectId: "proj-a", + taskId: "FN-6464", + }); + }); + + it("unsubscribes listeners", () => { + const registry = new CliRelaunchRegistry(); + const listener = vi.fn(); + + const unsubscribe = registry.on(listener); + unsubscribe(); + registry.record("cli-1", "proj-a", "FN-6464"); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/__tests__/cli-sessions-routes.test.ts b/packages/dashboard/src/__tests__/cli-sessions-routes.test.ts index 72275d25cb..4d23879623 100644 --- a/packages/dashboard/src/__tests__/cli-sessions-routes.test.ts +++ b/packages/dashboard/src/__tests__/cli-sessions-routes.test.ts @@ -20,6 +20,7 @@ import { AttachTicketStore, CliInputAttributionLog, CliConfirmAdvanceRegistry, + CliRelaunchRegistry, type CliSessionManagerLike, } from "../cli-session-transport.js"; @@ -68,6 +69,7 @@ function buildApp(opts: { ticketStore: AttachTicketStore; attributionLog: CliInputAttributionLog; confirmAdvance: CliConfirmAdvanceRegistry; + relaunch: CliRelaunchRegistry; }): express.Express { const app = express(); app.use(express.json()); @@ -79,6 +81,7 @@ function buildApp(opts: { ticketStore: opts.ticketStore, attributionLog: opts.attributionLog, confirmAdvance: opts.confirmAdvance, + relaunch: opts.relaunch, }), ); return app; @@ -91,6 +94,7 @@ describe("cli-sessions routes", () => { let ticketStore: AttachTicketStore; let attributionLog: CliInputAttributionLog; let confirmAdvance: CliConfirmAdvanceRegistry; + let relaunch: CliRelaunchRegistry; let app: express.Express; beforeEach(() => { @@ -112,7 +116,8 @@ describe("cli-sessions routes", () => { ticketStore = new AttachTicketStore(); attributionLog = new CliInputAttributionLog(); confirmAdvance = new CliConfirmAdvanceRegistry(); - app = buildApp({ store, manager, ticketStore, attributionLog, confirmAdvance }); + relaunch = new CliRelaunchRegistry(); + app = buildApp({ store, manager, ticketStore, attributionLog, confirmAdvance, relaunch }); }); afterEach(() => { @@ -208,4 +213,47 @@ describe("cli-sessions routes", () => { }); expect(res.status).toBe(400); }); + + it("records and emits a task-bound relaunch request", async () => { + const seen: string[] = []; + relaunch.on((info) => seen.push(`${info.sessionId}:${info.projectId}:${info.taskId}`)); + + const res = await postJson(app, "/api/cli-sessions/cli-1/relaunch", { + projectId: "proj-a", + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true, taskId: "FN-1" }); + expect(relaunch.getLatest("cli-1")).toEqual({ + sessionId: "cli-1", + projectId: "proj-a", + taskId: "FN-1", + }); + expect(seen).toContain("cli-1:proj-a:FN-1"); + }); + + it("404s relaunch for an unknown session", async () => { + const res = await postJson(app, "/api/cli-sessions/nope/relaunch", {}); + expect(res.status).toBe(404); + }); + + it("rejects relaunch across projects", async () => { + const res = await postJson(app, "/api/cli-sessions/cli-1/relaunch", { + projectId: "proj-b", + }); + expect(res.status).toBe(403); + expect(relaunch.getLatest("cli-1")).toBeUndefined(); + }); + + it("rejects relaunch for non-task-bound CLI sessions", async () => { + store._map.set("cli-chat", makeSession({ id: "cli-chat", taskId: null, chatSessionId: "chat-1" })); + + const res = await postJson(app, "/api/cli-sessions/cli-chat/relaunch", { + projectId: "proj-a", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not task-bound/i); + expect(relaunch.getLatest("cli-chat")).toBeUndefined(); + }); }); diff --git a/packages/dashboard/src/cli-session-transport.ts b/packages/dashboard/src/cli-session-transport.ts index 933422c7ec..e724964bd7 100644 --- a/packages/dashboard/src/cli-session-transport.ts +++ b/packages/dashboard/src/cli-session-transport.ts @@ -180,6 +180,12 @@ export type CliConfirmAdvanceListener = (info: { decision: "advance" | "not-yet"; }) => void; +export type CliRelaunchListener = (info: { + sessionId: string; + projectId: string; + taskId: string; +}) => void; + /** * The generic-tier "this session looks idle — advance to review?" affordance. * The engine pipeline layer acts on the event later; for now the transport @@ -207,6 +213,38 @@ export class CliConfirmAdvanceRegistry { } } +export interface CliRelaunchRequest { + sessionId: string; + projectId: string; + taskId: string; +} + +/** + * FNXC:CliRelaunch 2026-06-14-20:16: + * Relaunch uses the same decoupled transport contract as confirm-advance: the authenticated route records and emits intent, while the engine listener owns task lifecycle changes so REST handlers never spawn orphan CLI processes outside the scheduler. + */ +export class CliRelaunchRegistry { + private readonly latest = new Map(); + private readonly listeners = new Set(); + + on(listener: CliRelaunchListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + record(sessionId: string, projectId: string, taskId: string): void { + const request = { sessionId, projectId, taskId }; + this.latest.set(sessionId, request); + for (const listener of this.listeners) { + listener(request); + } + } + + getLatest(sessionId: string): CliRelaunchRequest | undefined { + return this.latest.get(sessionId); + } +} + // ── Read-only enforcement ──────────────────────────────────────────────────── /** diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 8d49616729..c7a64658d0 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -80,11 +80,12 @@ export { // CLI Agent Executor transport dependencies — re-exported so the CLI boot // (packages/cli dashboard command) can construct the per-session attach-ticket -// store, input-attribution log, and confirm-advance registry that the +// store, input-attribution log, and confirm-advance/relaunch registries that the // cli-sessions transport routes require, then thread them into ServerOptions. export { AttachTicketStore, CliInputAttributionLog, CliConfirmAdvanceRegistry, + CliRelaunchRegistry, type CliSessionTransportDeps, } from "./cli-session-transport.js"; diff --git a/packages/dashboard/src/routes/cli-sessions.ts b/packages/dashboard/src/routes/cli-sessions.ts index 39abdba010..03bdad9f35 100644 --- a/packages/dashboard/src/routes/cli-sessions.ts +++ b/packages/dashboard/src/routes/cli-sessions.ts @@ -10,6 +10,7 @@ * session-scoped attach ticket * - POST /api/cli-sessions/:id/inject inject text onto the session FIFO * - POST /api/cli-sessions/:id/confirm-advance generic-tier R20 affordance + * - POST /api/cli-sessions/:id/relaunch re-enqueue task for a fresh CLI run * * Attach tickets (KTD — attach auth): the long-lived daemon token never * authorizes PTY write access by itself. A surface mints a ticket here (gated by @@ -24,6 +25,7 @@ import { type AttachTicketStore, type CliInputAttributionLog, type CliConfirmAdvanceRegistry, + type CliRelaunchRegistry, type CliSessionTransportDeps, isReadOnlySession, } from "../cli-session-transport.js"; @@ -32,6 +34,7 @@ export interface CliSessionRoutesOptions extends CliSessionTransportDeps { ticketStore: AttachTicketStore; attributionLog: CliInputAttributionLog; confirmAdvance: CliConfirmAdvanceRegistry; + relaunch: CliRelaunchRegistry; /** Max inject body length (chars). Bounds a hostile body. */ maxInjectChars?: number; } @@ -53,7 +56,7 @@ function assertProjectScope(sessionProjectId: string, requested: unknown): void } export function createCliSessionsRouter(options: CliSessionRoutesOptions): Router { - const { manager, store, ticketStore, attributionLog, confirmAdvance } = options; + const { manager, store, ticketStore, attributionLog, confirmAdvance, relaunch } = options; const maxInjectChars = options.maxInjectChars ?? DEFAULT_MAX_INJECT_CHARS; const router = Router(); @@ -158,5 +161,26 @@ export function createCliSessionsRouter(options: CliSessionRoutesOptions): Route }), ); + // ── Relaunch (resume-exhausted task-bound CLI session) ───────────────────── + router.post( + "/:id/relaunch", + catchHandler(async (req: Request, res: Response) => { + const session = store.getSession(paramId(req.params.id)); + if (!session) throw notFound("Session not found"); + assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId); + + if (!session.taskId) { + /* + * FNXC:CliRelaunch 2026-06-14-20:16: + * Relaunch is a task lifecycle action: chat, validator, and other one-shot CLI sessions have no owning task to re-enqueue, so the route records no intent and returns a deterministic 400 instead of emitting an orphan relaunch event. + */ + throw badRequest("Session is not task-bound — cannot relaunch"); + } + + relaunch.record(session.id, session.projectId, session.taskId); + res.json({ ok: true, taskId: session.taskId }); + }), + ); + return router; } diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 34ac28a4f8..7a95bf7982 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -62,6 +62,7 @@ import type { SkillsAdapter } from "./skills-adapter.js"; import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js"; import { setupCliSessionWebSocket } from "./cli-session-ws.js"; import { createCliSessionsRouter } from "./routes/cli-sessions.js"; +import type { CliRelaunchRegistry } from "./cli-session-transport.js"; import { validateRemoteAuthToken } from "./remote-auth.js"; import { getCliPackageVersion } from "./cli-package-version.js"; import { @@ -249,6 +250,7 @@ export interface ServerOptions { ticketStore: import("./cli-session-transport.js").AttachTicketStore; attributionLog: import("./cli-session-transport.js").CliInputAttributionLog; confirmAdvance: import("./cli-session-transport.js").CliConfirmAdvanceRegistry; + relaunch: import("./cli-session-transport.js").CliRelaunchRegistry; extraAllowedOrigins?: string[]; }; /** Optional MissionAutopilot for autonomous mission progression */ @@ -569,6 +571,77 @@ export function loadTlsCredentialsFromEnv( return { cert, key, ca }; } +type CliRelaunchSessionStore = ServerOptions["cliSessionTransport"] extends infer T + ? T extends { store: infer S } + ? S & { + updateSession?: (id: string, input: { + agentState?: "dead"; + terminationReason?: "killed"; + nativeSessionId?: string | null; + resumeAttempts?: number; + }) => unknown; + } + : never + : never; + +interface CliRelaunchTaskStoreLike { + getTask(taskId: string): Promise; + updateTask(taskId: string, patch: Record): Promise; + moveTask(taskId: string, column: "todo", options?: Record): Promise; + logEntry(taskId: string, message: string, details?: string): Promise; +} + +export function wireCliRelaunchListener(options: { + relaunch: CliRelaunchRegistry; + cliSessionStore: CliRelaunchSessionStore; + engine?: Pick; + runtimeLogger?: RuntimeLogger; +}): (() => void) | undefined { + if (!options.engine) return undefined; + const taskStore = options.engine.getTaskStore() as unknown as CliRelaunchTaskStoreLike; + const engineProjectId = options.engine.getProjectId?.(); + + return options.relaunch.on((info) => { + void (async () => { + if (engineProjectId && info.projectId !== engineProjectId) return; + + /* + * FNXC:CliRelaunch 2026-06-14-20:16: + * The relaunch listener guarantees a fresh launch by clearing resume linkage on the dead CLI session, then re-enters the existing task retry lifecycle via `moveTask(todo)`; it never calls the CLI manager's spawn path directly, so the scheduler/executor remains the single task-run entrypoint. + */ + options.cliSessionStore.updateSession?.(info.sessionId, { + agentState: "dead", + terminationReason: "killed", + nativeSessionId: null, + resumeAttempts: 2, + }); + + const task = await taskStore.getTask(info.taskId); + if (!task) { + options.runtimeLogger?.warn?.("CLI session relaunch skipped; task not found", info); + return; + } + + await taskStore.logEntry( + info.taskId, + `CLI session relaunch requested from ${info.sessionId} — clearing resume linkage and re-enqueueing for a fresh executor run`, + ); + await taskStore.updateTask(info.taskId, { paused: false, status: null, error: null }); + await taskStore.moveTask(info.taskId, "todo", { + preserveProgress: true, + moveSource: "engine", + recoveryRehome: true, + }); + })().catch((err: unknown) => { + options.runtimeLogger?.warn?.("CLI session relaunch listener failed", { + sessionId: info.sessionId, + taskId: info.taskId, + message: err instanceof Error ? err.message : String(err), + }); + }); + }); +} + export function createServer(store: TaskStore, options?: ServerOptions): ReturnType { // Register the universal post-create hook so every task-creation path // (HTTP routes, CLI, pi extension, mission triage, etc.) triggers @@ -1143,6 +1216,21 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT // route the project hub's sanitized telemetry into the runner's transcript // handler. The listener is keyed per-session inside one closure so it composes // safely even if other taps exist. + if (options?.cliSessionTransport && options.engine) { + try { + wireCliRelaunchListener({ + relaunch: options.cliSessionTransport.relaunch, + cliSessionStore: options.cliSessionTransport.store as CliRelaunchSessionStore, + engine: options.engine, + runtimeLogger, + }); + } catch (err) { + runtimeLogger.warn?.("CLI-agent relaunch listener wiring failed", { + message: err instanceof Error ? err.message : String(err), + }); + } + } + if (options?.cliSessionTransport && options.cliAgentHubResolver) { try { const cliTransportStore = options.cliSessionTransport.store; @@ -1542,6 +1630,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT ticketStore: options.cliSessionTransport.ticketStore, attributionLog: options.cliSessionTransport.attributionLog, confirmAdvance: options.cliSessionTransport.confirmAdvance, + relaunch: options.cliSessionTransport.relaunch, }), ); }