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:
5
.changeset/fn-6464-cli-relaunch-route.md
Normal file
5
.changeset/fn-6464-cli-relaunch-route.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<unknown>;
|
||||
moveTask: (id: string, column: "todo") => Promise<unknown>;
|
||||
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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -724,6 +724,13 @@ export function retryTask(id: string, projectId?: string): Promise<Task> {
|
||||
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> {
|
||||
return api<RecoverBranchBindingOutcome>(withProjectId(`/tasks/${id}/recover-branch-binding`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<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(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "resume-exhausted" })]}
|
||||
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "resume-exhausted", cliSessionId: undefined })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={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();
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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> {
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user