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
This commit is contained in:
gsxdsm
2026-06-14 21:19:28 -07:00
parent a9815fb1ff
commit 863ebfaa96
14 changed files with 390 additions and 22 deletions

View File

@@ -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.

View File

@@ -4,6 +4,10 @@
Fusion uses multiple agent roles for planning, execution, review, and merge workflows. 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 ## Interactive CLI Chat
Use `fn chat` to message an agent from your terminal. Use `fn chat` to message an agent from your terminal.

View File

@@ -30,6 +30,7 @@ import {
AttachTicketStore, AttachTicketStore,
CliInputAttributionLog, CliInputAttributionLog,
CliConfirmAdvanceRegistry, CliConfirmAdvanceRegistry,
CliRelaunchRegistry,
GitHubClient, GitHubClient,
createSkillsAdapter, createSkillsAdapter,
getCliPackageVersion, getCliPackageVersion,
@@ -1770,6 +1771,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
ticketStore: new AttachTicketStore(), ticketStore: new AttachTicketStore(),
attributionLog: new CliInputAttributionLog(), attributionLog: new CliInputAttributionLog(),
confirmAdvance: new CliConfirmAdvanceRegistry(), confirmAdvance: new CliConfirmAdvanceRegistry(),
relaunch: new CliRelaunchRegistry(),
} }
: undefined; : undefined;

View File

@@ -90,7 +90,7 @@ import { NativeShellConnectionManager } from "./components/NativeShellConnection
import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native"; import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
import type { AiSessionSummary, DashboardHealthResponse } from "./api"; 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 { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage";
import { subscribeSse } from "./sse-bus"; import { subscribeSse } from "./sse-bus";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; 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 { 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."; return "CLI session id is missing.";
} }
if (action === "relaunch") {
return "Relaunch is not supported by the dashboard yet.";
}
return null; return null;
} }
@@ -270,8 +267,9 @@ interface CliActionDeps {
retryTask: (id: string) => Promise<unknown>; retryTask: (id: string) => Promise<unknown>;
moveTask: (id: string, column: "todo") => Promise<unknown>; moveTask: (id: string, column: "todo") => Promise<unknown>;
openAuthenticationSettings: () => void; openAuthenticationSettings: () => void;
addToast: (message: string, type: "error") => void; addToast: (message: string, type: "success" | "error") => void;
apiClient?: typeof api; apiClient?: typeof api;
relaunchCliSessionClient?: typeof relaunchCliSession;
} }
export async function executeCliSessionBannerAction( export async function executeCliSessionBannerAction(
@@ -283,6 +281,9 @@ export async function executeCliSessionBannerAction(
/* /*
* FNXC:SessionBanner 2026-06-14-19:32: * 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. * 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 (action === "advance") {
if (!session.cliSessionId) { if (!session.cliSessionId) {
@@ -295,6 +296,13 @@ export async function executeCliSessionBannerAction(
return; 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") { if (action === "retry") {
await deps.retryTask(session.id); await deps.retryTask(session.id);
return; return;

View File

@@ -38,12 +38,14 @@ describe("App CLI session banner wiring", () => {
["retry", "retryTask"], ["retry", "retryTask"],
["cancel", "moveTask"], ["cancel", "moveTask"],
["reauthenticate", "openSettings"], ["reauthenticate", "openSettings"],
["relaunch", "relaunchCliSession"],
] as const)("maps %s to an observable existing route or flow", async (action, expected) => { ] as const)("maps %s to an observable existing route or flow", async (action, expected) => {
const apiClient = vi.fn().mockResolvedValue({ ok: true }); const apiClient = vi.fn().mockResolvedValue({ ok: true });
const retryTask = vi.fn().mockResolvedValue({ id: "FN-6458" }); const retryTask = vi.fn().mockResolvedValue({ id: "FN-6458" });
const moveTask = vi.fn().mockResolvedValue({ id: "FN-6458" }); const moveTask = vi.fn().mockResolvedValue({ id: "FN-6458" });
const openAuthenticationSettings = vi.fn(); const openAuthenticationSettings = vi.fn();
const addToast = vi.fn(); const addToast = vi.fn();
const relaunchCliSessionClient = vi.fn().mockResolvedValue({ ok: true, taskId: "FN-6458" });
await executeCliSessionBannerAction(cliSession(), action, { await executeCliSessionBannerAction(cliSession(), action, {
currentProjectId: "proj-1", currentProjectId: "proj-1",
@@ -52,6 +54,7 @@ describe("App CLI session banner wiring", () => {
openAuthenticationSettings, openAuthenticationSettings,
addToast, addToast,
apiClient, apiClient,
relaunchCliSessionClient,
}); });
if (expected === "api") { if (expected === "api") {
@@ -66,21 +69,43 @@ describe("App CLI session banner wiring", () => {
expect(retryTask).toHaveBeenCalledWith("FN-6458"); expect(retryTask).toHaveBeenCalledWith("FN-6458");
} else if (expected === "moveTask") { } else if (expected === "moveTask") {
expect(moveTask).toHaveBeenCalledWith("FN-6458", "todo"); 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 { } else {
expect(openAuthenticationSettings).toHaveBeenCalledTimes(1); 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 actions: CliActionId[] = ["advance", "retry", "cancel", "reauthenticate", "relaunch"];
const missingId = cliSession({ cliSessionId: undefined }); const missingId = cliSession({ cliSessionId: undefined });
const withId = cliSession(); const withId = cliSession();
const disabled = new Map(actions.map((action) => [action, getCliActionDisabledReasonForBanner(withId, action)])); 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(disabled.get("advance")).toBeNull();
expect(getCliActionDisabledReasonForBanner(missingId, "advance")).toMatch(/missing/i); 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 () => { it("toasts instead of silently failing if an enabled CLI action route rejects", async () => {

View File

@@ -724,6 +724,13 @@ export function retryTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/retry`, projectId), { method: "POST" }); return api<Task>(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<RecoverBranchBindingOutcome> { export function recoverBranchBinding(id: string, projectId?: string): Promise<RecoverBranchBindingOutcome> {
return api<RecoverBranchBindingOutcome>(withProjectId(`/tasks/${id}/recover-branch-binding`, projectId), { method: "POST" }); return api<RecoverBranchBindingOutcome>(withProjectId(`/tasks/${id}/recover-branch-binding`, projectId), { method: "POST" });
} }

View File

@@ -405,19 +405,55 @@ describe("SessionNotificationBanner — cli-agent (U11)", () => {
expect(screen.getByText("Retry")).toBeInTheDocument(); 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(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "resume-exhausted" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
onCliAction={onCliAction}
/>,
);
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( render(
<SessionNotificationBanner <SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "resume-exhausted" })]} sessions={[buildCliSession({ status: "needs_attention", cliVariant: "resume-exhausted", cliSessionId: undefined })]}
onResumeSession={vi.fn()} onResumeSession={vi.fn()}
onDismissSession={vi.fn()} onDismissSession={vi.fn()}
onDismissAll={vi.fn()} onDismissAll={vi.fn()}
onCliAction={vi.fn()} onCliAction={onCliAction}
getCliActionDisabledReason={(session, action) =>
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(); const relaunchButton = screen.getByRole("button", { name: /Relaunch fresh unavailable: CLI session id is missing/i });
expect(screen.getByText("Cancel task")).toBeInTheDocument(); expect(relaunchButton).toBeDisabled();
fireEvent.click(relaunchButton);
expect(onCliAction).not.toHaveBeenCalled();
}); });
it.each([ it.each([
@@ -436,9 +472,7 @@ describe("SessionNotificationBanner — cli-agent (U11)", () => {
onDismissSession={onDismissSession} onDismissSession={onDismissSession}
onDismissAll={vi.fn()} onDismissAll={vi.fn()}
onCliAction={onCliAction} onCliAction={onCliAction}
getCliActionDisabledReason={(_session, candidate) => getCliActionDisabledReason={() => null}
candidate === "relaunch" ? "Relaunch is not supported by the dashboard yet." : null
}
/>, />,
); );

View File

@@ -16,7 +16,7 @@
*/ */
import express from "express"; 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 { mkdtempSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -28,8 +28,10 @@ import {
AttachTicketStore, AttachTicketStore,
CliInputAttributionLog, CliInputAttributionLog,
CliConfirmAdvanceRegistry, CliConfirmAdvanceRegistry,
CliRelaunchRegistry,
} from "../cli-session-transport.js"; } from "../cli-session-transport.js";
import { createCliSessionsRouter } from "../routes/cli-sessions.js"; import { createCliSessionsRouter } from "../routes/cli-sessions.js";
import { wireCliRelaunchListener } from "../server.js";
import { request } from "../test-request.js"; import { request } from "../test-request.js";
function mockPty(): typeof import("node-pty") { function mockPty(): typeof import("node-pty") {
@@ -104,6 +106,7 @@ describe("cli-agent runtime server wiring", () => {
ticketStore: new AttachTicketStore(), ticketStore: new AttachTicketStore(),
attributionLog: new CliInputAttributionLog(), attributionLog: new CliInputAttributionLog(),
confirmAdvance: new CliConfirmAdvanceRegistry(), confirmAdvance: new CliConfirmAdvanceRegistry(),
relaunch: new CliRelaunchRegistry(),
}; };
const app = express(); const app = express();
@@ -119,4 +122,48 @@ describe("cli-agent runtime server wiring", () => {
const sessions = res.body.sessions as Array<{ taskId?: string }>; const sessions = res.body.sessions as Array<{ taskId?: string }>;
expect(sessions.some((s) => s.taskId === "FN-1")).toBe(true); 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,
});
});
}); });

View File

@@ -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();
});
});

View File

@@ -20,6 +20,7 @@ import {
AttachTicketStore, AttachTicketStore,
CliInputAttributionLog, CliInputAttributionLog,
CliConfirmAdvanceRegistry, CliConfirmAdvanceRegistry,
CliRelaunchRegistry,
type CliSessionManagerLike, type CliSessionManagerLike,
} from "../cli-session-transport.js"; } from "../cli-session-transport.js";
@@ -68,6 +69,7 @@ function buildApp(opts: {
ticketStore: AttachTicketStore; ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog; attributionLog: CliInputAttributionLog;
confirmAdvance: CliConfirmAdvanceRegistry; confirmAdvance: CliConfirmAdvanceRegistry;
relaunch: CliRelaunchRegistry;
}): express.Express { }): express.Express {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@@ -79,6 +81,7 @@ function buildApp(opts: {
ticketStore: opts.ticketStore, ticketStore: opts.ticketStore,
attributionLog: opts.attributionLog, attributionLog: opts.attributionLog,
confirmAdvance: opts.confirmAdvance, confirmAdvance: opts.confirmAdvance,
relaunch: opts.relaunch,
}), }),
); );
return app; return app;
@@ -91,6 +94,7 @@ describe("cli-sessions routes", () => {
let ticketStore: AttachTicketStore; let ticketStore: AttachTicketStore;
let attributionLog: CliInputAttributionLog; let attributionLog: CliInputAttributionLog;
let confirmAdvance: CliConfirmAdvanceRegistry; let confirmAdvance: CliConfirmAdvanceRegistry;
let relaunch: CliRelaunchRegistry;
let app: express.Express; let app: express.Express;
beforeEach(() => { beforeEach(() => {
@@ -112,7 +116,8 @@ describe("cli-sessions routes", () => {
ticketStore = new AttachTicketStore(); ticketStore = new AttachTicketStore();
attributionLog = new CliInputAttributionLog(); attributionLog = new CliInputAttributionLog();
confirmAdvance = new CliConfirmAdvanceRegistry(); confirmAdvance = new CliConfirmAdvanceRegistry();
app = buildApp({ store, manager, ticketStore, attributionLog, confirmAdvance }); relaunch = new CliRelaunchRegistry();
app = buildApp({ store, manager, ticketStore, attributionLog, confirmAdvance, relaunch });
}); });
afterEach(() => { afterEach(() => {
@@ -208,4 +213,47 @@ describe("cli-sessions routes", () => {
}); });
expect(res.status).toBe(400); 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();
});
}); });

View File

@@ -180,6 +180,12 @@ export type CliConfirmAdvanceListener = (info: {
decision: "advance" | "not-yet"; decision: "advance" | "not-yet";
}) => void; }) => void;
export type CliRelaunchListener = (info: {
sessionId: string;
projectId: string;
taskId: string;
}) => void;
/** /**
* The generic-tier "this session looks idle — advance to review?" affordance. * The generic-tier "this session looks idle — advance to review?" affordance.
* The engine pipeline layer acts on the event later; for now the transport * 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<string, CliRelaunchRequest>();
private readonly listeners = new Set<CliRelaunchListener>();
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 ──────────────────────────────────────────────────── // ── Read-only enforcement ────────────────────────────────────────────────────
/** /**

View File

@@ -80,11 +80,12 @@ export {
// CLI Agent Executor transport dependencies — re-exported so the CLI boot // CLI Agent Executor transport dependencies — re-exported so the CLI boot
// (packages/cli dashboard command) can construct the per-session attach-ticket // (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. // cli-sessions transport routes require, then thread them into ServerOptions.
export { export {
AttachTicketStore, AttachTicketStore,
CliInputAttributionLog, CliInputAttributionLog,
CliConfirmAdvanceRegistry, CliConfirmAdvanceRegistry,
CliRelaunchRegistry,
type CliSessionTransportDeps, type CliSessionTransportDeps,
} from "./cli-session-transport.js"; } from "./cli-session-transport.js";

View File

@@ -10,6 +10,7 @@
* session-scoped attach ticket * session-scoped attach ticket
* - POST /api/cli-sessions/:id/inject inject text onto the session FIFO * - 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/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 * 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 * authorizes PTY write access by itself. A surface mints a ticket here (gated by
@@ -24,6 +25,7 @@ import {
type AttachTicketStore, type AttachTicketStore,
type CliInputAttributionLog, type CliInputAttributionLog,
type CliConfirmAdvanceRegistry, type CliConfirmAdvanceRegistry,
type CliRelaunchRegistry,
type CliSessionTransportDeps, type CliSessionTransportDeps,
isReadOnlySession, isReadOnlySession,
} from "../cli-session-transport.js"; } from "../cli-session-transport.js";
@@ -32,6 +34,7 @@ export interface CliSessionRoutesOptions extends CliSessionTransportDeps {
ticketStore: AttachTicketStore; ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog; attributionLog: CliInputAttributionLog;
confirmAdvance: CliConfirmAdvanceRegistry; confirmAdvance: CliConfirmAdvanceRegistry;
relaunch: CliRelaunchRegistry;
/** Max inject body length (chars). Bounds a hostile body. */ /** Max inject body length (chars). Bounds a hostile body. */
maxInjectChars?: number; maxInjectChars?: number;
} }
@@ -53,7 +56,7 @@ function assertProjectScope(sessionProjectId: string, requested: unknown): void
} }
export function createCliSessionsRouter(options: CliSessionRoutesOptions): Router { 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 maxInjectChars = options.maxInjectChars ?? DEFAULT_MAX_INJECT_CHARS;
const router = Router(); 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; return router;
} }

View File

@@ -62,6 +62,7 @@ import type { SkillsAdapter } from "./skills-adapter.js";
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js"; import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
import { setupCliSessionWebSocket } from "./cli-session-ws.js"; import { setupCliSessionWebSocket } from "./cli-session-ws.js";
import { createCliSessionsRouter } from "./routes/cli-sessions.js"; import { createCliSessionsRouter } from "./routes/cli-sessions.js";
import type { CliRelaunchRegistry } from "./cli-session-transport.js";
import { validateRemoteAuthToken } from "./remote-auth.js"; import { validateRemoteAuthToken } from "./remote-auth.js";
import { getCliPackageVersion } from "./cli-package-version.js"; import { getCliPackageVersion } from "./cli-package-version.js";
import { import {
@@ -249,6 +250,7 @@ export interface ServerOptions {
ticketStore: import("./cli-session-transport.js").AttachTicketStore; ticketStore: import("./cli-session-transport.js").AttachTicketStore;
attributionLog: import("./cli-session-transport.js").CliInputAttributionLog; attributionLog: import("./cli-session-transport.js").CliInputAttributionLog;
confirmAdvance: import("./cli-session-transport.js").CliConfirmAdvanceRegistry; confirmAdvance: import("./cli-session-transport.js").CliConfirmAdvanceRegistry;
relaunch: import("./cli-session-transport.js").CliRelaunchRegistry;
extraAllowedOrigins?: string[]; extraAllowedOrigins?: string[];
}; };
/** Optional MissionAutopilot for autonomous mission progression */ /** Optional MissionAutopilot for autonomous mission progression */
@@ -569,6 +571,77 @@ export function loadTlsCredentialsFromEnv(
return { cert, key, ca }; 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<Task | null>;
updateTask(taskId: string, patch: Record<string, unknown>): Promise<unknown>;
moveTask(taskId: string, column: "todo", options?: Record<string, unknown>): Promise<unknown>;
logEntry(taskId: string, message: string, details?: string): Promise<unknown>;
}
export function wireCliRelaunchListener(options: {
relaunch: CliRelaunchRegistry;
cliSessionStore: CliRelaunchSessionStore;
engine?: Pick<import("@fusion/engine").ProjectEngine, "getTaskStore" | "getProjectId">;
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<typeof express> { export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
// Register the universal post-create hook so every task-creation path // Register the universal post-create hook so every task-creation path
// (HTTP routes, CLI, pi extension, mission triage, etc.) triggers // (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 // 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 // handler. The listener is keyed per-session inside one closure so it composes
// safely even if other taps exist. // 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) { if (options?.cliSessionTransport && options.cliAgentHubResolver) {
try { try {
const cliTransportStore = options.cliSessionTransport.store; const cliTransportStore = options.cliSessionTransport.store;
@@ -1542,6 +1630,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
ticketStore: options.cliSessionTransport.ticketStore, ticketStore: options.cliSessionTransport.ticketStore,
attributionLog: options.cliSessionTransport.attributionLog, attributionLog: options.cliSessionTransport.attributionLog,
confirmAdvance: options.cliSessionTransport.confirmAdvance, confirmAdvance: options.cliSessionTransport.confirmAdvance,
relaunch: options.cliSessionTransport.relaunch,
}), }),
); );
} }