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.
|
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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user