diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 60ff21c15..84f769e97 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -315,6 +315,37 @@ describe("API Routes", () => { vi.useRealTimers(); }); + it("activeOnly keeps working sessions with stale terminatedAt annotations", async () => { + const staleLifecycle = createInitialCanonicalLifecycle( + "worker", + new Date("2026-05-13T19:13:20.146Z"), + ); + staleLifecycle.session.state = "working"; + staleLifecycle.session.reason = "task_in_progress"; + staleLifecycle.session.terminatedAt = "2026-05-13T19:13:20.146Z"; + staleLifecycle.runtime.state = "alive"; + staleLifecycle.runtime.reason = "process_running"; + + (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce([ + makeSession({ + id: "worker-restored-stale-terminal-marker", + status: "terminated", + activity: "exited", + lifecycle: staleLifecycle, + }), + ]); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions?active=true")); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.sessions.map((session: { id: string }) => session.id)).toEqual([ + "worker-restored-stale-terminal-marker", + ]); + expect(data.sessions[0].lifecycle.sessionState).toBe("working"); + expect(data.sessions[0].lifecycle.session.terminatedAt).toBe("2026-05-13T19:13:20.146Z"); + }); + it("returns per-project orchestrators and excludes them from worker sessions", async () => { (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce( multiProjectSessions, diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 77a3f1ec2..539740f16 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,4 +1,4 @@ -import { ACTIVITY_STATE, isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core"; +import { isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { sessionToDashboard, @@ -10,11 +10,14 @@ import { import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; import { filterProjectSessions } from "@/lib/project-utils"; import { settlesWithin } from "@/lib/async-utils"; -import type { DashboardOrchestratorLink } from "@/lib/types"; +import { isDashboardSessionTerminal, type DashboardOrchestratorLink } from "@/lib/types"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; -function compareOrchestratorRecency(a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }): number { +function compareOrchestratorRecency( + a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, + b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, +): number { return ( (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0) || (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) || @@ -54,7 +57,7 @@ function selectPreferredOrchestratorId( function listPreferredProjectOrchestrators( sessions: Parameters[0], projects: Parameters[1], -) : DashboardOrchestratorLink[] { +): DashboardOrchestratorLink[] { const preferredOrchestrators = listProjectOrchestratorSessions(sessions, projects); return preferredOrchestrators @@ -90,7 +93,9 @@ export async function GET(request: Request) { : listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = requestedProjectId ? selectPreferredOrchestratorId(visibleSessions, config.projects) - : (orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null); + : orchestrators.length === 1 + ? (orchestrators[0]?.id ?? null) + : null; if (orchestratorOnly) { recordApiObservation({ @@ -132,7 +137,7 @@ export async function GET(request: Request) { if (activeOnly) { const activeIndices = dashboardSessions - .map((session, index) => (session.activity !== ACTIVITY_STATE.EXITED ? index : -1)) + .map((session, index) => (!isDashboardSessionTerminal(session) ? index : -1)) .filter((index) => index !== -1); workerSessions = activeIndices.map((index) => workerSessions[index]); dashboardSessions = activeIndices.map((index) => dashboardSessions[index]); diff --git a/packages/web/src/components/BottomSheet.tsx b/packages/web/src/components/BottomSheet.tsx index 5860efe49..5f09d33d0 100644 --- a/packages/web/src/components/BottomSheet.tsx +++ b/packages/web/src/components/BottomSheet.tsx @@ -1,7 +1,14 @@ "use client"; import { useEffect, useRef } from "react"; -import { getAttentionLevel, isPRRateLimited, isPRUnenriched, type DashboardSession } from "@/lib/types"; +import { + getAttentionLevel, + isDashboardSessionTerminated, + isDashboardSessionTerminal, + isPRRateLimited, + isPRUnenriched, + type DashboardSession, +} from "@/lib/types"; import { getSessionTitle } from "@/lib/format"; import { projectSessionPath } from "@/lib/routes"; @@ -24,12 +31,10 @@ function formatTagLabel(value: string): string { } function isTag( - value: - | { - label: string; - tone: "accent" | "neutral" | "mono"; - } - | null, + value: { + label: string; + tone: "accent" | "neutral" | "mono"; + } | null, ): value is { label: string; tone: "accent" | "neutral" | "mono" } { return value !== null; } @@ -76,7 +81,7 @@ export function BottomSheet({ if (!sheetRef.current) return; const focusable = sheetRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (focusable.length === 0) return; @@ -122,13 +127,12 @@ export function BottomSheet({ const title = getSessionTitle(session); const attention = getAttentionLevel(session); - const summary = - session.summary && !session.summaryIsFallback ? session.summary : null; + const summary = session.summary && !session.summaryIsFallback ? session.summary : null; const hasLiveTerminateAction = - attention !== "done" && attention !== "merge" && session.status !== "terminated"; + attention !== "done" && attention !== "merge" && !isDashboardSessionTerminated(session); const pr = session.pr; const showLivePrData = Boolean(pr && !isPRRateLimited(pr) && !isPRUnenriched(pr)); - const showTerminalStatePills = attention === "done" || session.status === "terminated" || session.activity === "exited"; + const showTerminalStatePills = attention === "done" || isDashboardSessionTerminal(session); const tags = [ { label: formatTagLabel(attention), tone: "accent" as const }, { label: formatTagLabel(session.status), tone: "neutral" as const }, @@ -141,11 +145,7 @@ export function BottomSheet({ return ( <> {/* Backdrop */} -