From a3f70192edf014225a34711f23800ef01ad28453 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 01:21:16 -0700 Subject: [PATCH] fix(web): chat and agents show Not running with a Restart action after a hub restart (CL-8507) --- apps/web/src/chat/threads-api.ts | 30 +++++++++- apps/web/src/pages/agents-page.tsx | 75 +++++++++++++++++++++---- apps/web/src/pages/chat-thread-page.tsx | 62 ++++++++++++++++++-- apps/web/src/routines-api.ts | 7 +++ 4 files changed, 155 insertions(+), 19 deletions(-) diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index f4e8371f8..9d4696b67 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -42,9 +42,18 @@ export type ChatAgent = { /** The address of the agent's currently live run, or null when none is * live (mid-redeploy). */ readonly liveAddress: string | null; + /** The newest deployment's status for this agent's asset — `undefined` + * when it has never been deployed. `"pending"`/`"recovering"` mean a run + * is on the way up; a terminal status (`released`, `failed`, + * `destroy_failed`, `stopped`) means nothing is running and nothing is + * coming unless someone restarts it. */ + readonly latestStatus: string | undefined; }; const LIVE_DEPLOYMENT_STATUSES = new Set(["deployed", "pending", "recovering"]); +/** A deployment status that means a run is on the way up but not live yet + * — distinct from a terminal status, which means nothing is running. */ +export const STARTING_DEPLOYMENT_STATUSES = new Set(["pending", "recovering"]); const DeploymentsSchema = WorkflowDeploymentResponse.array(); const WorkflowAssetSchema = type({ id: "string", name: "string" }).array(); @@ -121,17 +130,22 @@ export async function listChatAgents(tenantId: string): Promise [run.id, run.address])); const nameByAssetId = new Map(assets.map((asset) => [asset.id, asset.name])); - const byAsset = new Map; liveAddress: string | null }>(); + const byAsset = new Map< + string, + { addresses: Set; liveAddress: string | null; latestStatus: string | undefined } + >(); for (const deployment of deployments) { const address = addressByRunId.get(deployment.id); if (address === undefined || address.length === 0) continue; const entry = byAsset.get(deployment.definitionAssetId) ?? { addresses: new Set(), liveAddress: null, + latestStatus: undefined, }; entry.addresses.add(address); - // Deployments come back newest-first, so the first live one seen per - // asset is the current one. + // Deployments come back newest-first, so the first one seen per asset + // is the latest, and the first live one seen is the current one. + if (entry.latestStatus === undefined) entry.latestStatus = deployment.status; if (entry.liveAddress === null && LIVE_DEPLOYMENT_STATUSES.has(deployment.status)) { entry.liveAddress = address; } @@ -146,10 +160,20 @@ export async function listChatAgents(tenantId: string): Promise): boolean { + if (agent.liveAddress !== null) return false; + return agent.latestStatus === undefined || !STARTING_DEPLOYMENT_STATUSES.has(agent.latestStatus); +} + /** The agent an `@name` first message picks, matched case-insensitively * against the agent's display name with spaces removed. */ export function agentFromMention( diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index efcd46785..e9f54442f 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -19,24 +19,45 @@ import { TableRow, } from "@corbits/react-ui"; import { Robot } from "@/lib/icons"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { QueryView } from "@/lib/api-query"; import { chatKeys, chatPath } from "../chat-path"; -import { listChatAgents, type ChatAgent } from "@/chat/threads-api"; +import { isAgentNotRunning, listChatAgents, type ChatAgent } from "@/chat/threads-api"; +import { redeployRoomAgent } from "../workbench-create"; import { useBench } from "../bench-context"; import { Link } from "../navigation"; import { useTenantQuery } from "../routines-api"; import { StageTopBar } from "../shell/stage-top-bar"; /** - * One agent's roster row: `Live` once it has a live run address, `Starting` - * while a deploy is still landing one (mid-first-deploy or mid-redeploy). + * One agent's roster row: `Live` once it has a live run address, `starting` + * while a deploy is still landing one (mid-first-deploy or mid-redeploy), + * `not-running` once its latest deployment has gone terminal (a hub + * restart releases every prior allocation) — a restart is then the only + * way forward. */ -export function agentRosterStatus(agent: Pick): "live" | "starting" { - return agent.liveAddress === null ? "starting" : "live"; +export function agentRosterStatus( + agent: Pick, +): "live" | "starting" | "not-running" { + if (agent.liveAddress !== null) return "live"; + return isAgentNotRunning(agent) ? "not-running" : "starting"; } -export function AgentsRosterList({ agents }: { readonly agents: readonly ChatAgent[] }) { +export function AgentsRosterList({ + tenantId, + agents, +}: { + readonly tenantId: string; + readonly agents: readonly ChatAgent[]; +}) { + const queryClient = useQueryClient(); + const restart = useMutation({ + mutationFn: (agent: ChatAgent) => redeployRoomAgent(tenantId, agent), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); + }, + }); if (agents.length === 0) { return ( ) : null} - - {status === "live" ? "Live" : "Starting"} + + {status === "live" + ? "Live" + : restart.isPending && restart.variables?.id === agent.id + ? "Starting…" + : status === "not-running" + ? "Not running" + : "Starting"} - + + {status === "not-running" ? ( + + ) : null} + + ); @@ -92,6 +140,9 @@ export function AgentsRoute() { chatKeys.agents(selectedTenantId ?? "none"), selectedTenantId !== null, () => listChatAgents(selectedTenantId as string), + // Keep polling while any agent is not live, so a released→deployed + // (or a restart landing) transition is seen without a reload. + (agents) => ((agents?.some((agent) => agent.liveAddress === null) ?? false) ? 3000 : false), ); return ( @@ -111,7 +162,7 @@ export function AgentsRoute() { ) : (
- {(agents) => } + {(agents) => }
)} diff --git a/apps/web/src/pages/chat-thread-page.tsx b/apps/web/src/pages/chat-thread-page.tsx index 84f749f66..6dc5cfe19 100644 --- a/apps/web/src/pages/chat-thread-page.tsx +++ b/apps/web/src/pages/chat-thread-page.tsx @@ -14,6 +14,7 @@ import { Markdown } from "@/chat/markdown"; import { MessageAttachments } from "@/chat/message-attachments"; import { agentFromMention, + isAgentNotRunning, listChatAgents, markChatSeen, readChat, @@ -23,6 +24,7 @@ import { type ChatAgent, } from "@/chat/threads-api"; import { MYRA_SOURCE_CONFIG } from "../myra-source"; +import { redeployRoomAgent } from "../workbench-create"; import { useBench } from "../bench-context"; import { chatIdFromPath, chatKeys, chatPath, NEW_CHAT_PATH } from "../chat-path"; import { usePendingApprovals } from "../pending-approvals"; @@ -66,10 +68,19 @@ function NewChat({ }, }); + const restart = useMutation({ + mutationFn: (agent: ChatAgent) => redeployRoomAgent(tenantId, agent), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); + }, + }); + const defaultAgent = agents.find((agent) => agent.name === MYRA_SOURCE_CONFIG.displayName); const chosen = agents.find((agent) => agent.id === selected) ?? defaultAgent ?? agents[0]; const error: unknown = start.error ?? agentsQuery.error; const isLive = chosen?.liveAddress !== null && chosen?.liveAddress !== undefined; + const notRunning = chosen !== undefined && isAgentNotRunning(chosen); + const restarting = restart.isPending && restart.variables?.id === chosen?.id; return ( @@ -96,11 +107,26 @@ function NewChat({ {error === null || error === undefined ? null : (

{errorText(error)}

)} + {restart.error === null || restart.error === undefined ? null : ( +

{errorText(restart.error)}

+ )} + {notRunning && !restarting ? ( + + ) : null} queryClient.invalidateQueries({ queryKey: chatKeys.scope(tenantId) }), }); + const restart = useMutation({ + mutationFn: () => { + if (chat === undefined) throw new Error("no agent to restart"); + return redeployRoomAgent(tenantId, chat.agent); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); + void queryClient.invalidateQueries({ queryKey: chatKeys.one(tenantId, chatId) }); + }, + }); + if (chatQuery.isError && chat === undefined) { return ( @@ -217,11 +254,28 @@ function ChatTranscript({ )} {reply.error === null ? null :

{errorText(reply.error)}

} + {restart.error === null ? null : ( +

{errorText(restart.error)}

+ )} + {isAgentNotRunning(chat.agent) && !restart.isPending ? ( + + ) : null} ( key: readonly unknown[], enabled: boolean, fetcher: () => Promise, + refetchInterval?: (data: T | undefined) => number | false, ): APIQuery { const result = useQuery({ queryKey: key, @@ -152,6 +153,12 @@ export function useTenantQuery( throw cause; } }, + ...(refetchInterval !== undefined + ? { + refetchInterval: (query: { state: { data: T | undefined } }) => + refetchInterval(query.state.data), + } + : {}), }); return toAPIQuery(result); }