fix(web): authoritative session.state for terminated UI rendering (closes #1832) (#1833)

* fix(web): honor lifecycle state for terminal session UI

* fix(web): address terminal state review feedback

* chore: retrigger integration ci

* fix(web): trim terminal state change scope

* fix(web): preserve legacy terminated helper behavior

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
This commit is contained in:
i-trytoohard 2026-05-15 04:30:44 +05:30 committed by GitHub
parent 7d324b537d
commit 6fb18cb47e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 130 additions and 35 deletions

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(
multiProjectSessions,

View File

@ -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<typeof listDashboardOrchestrators>[0],
projects: Parameters<typeof listDashboardOrchestrators>[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]);

View File

@ -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<HTMLElement>(
'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 */}
<div
className="bottom-sheet-backdrop"
onClick={onCancel}
aria-hidden="true"
/>
<div className="bottom-sheet-backdrop" onClick={onCancel} aria-hidden="true" />
{/* Sheet */}
<div
@ -191,7 +191,9 @@ export function BottomSheet({
<div className="bottom-sheet__preview-content">
<div className="bottom-sheet__preview-header">
<span className="bottom-sheet__preview-id">{session.id}</span>
<span className="bottom-sheet__preview-time">{getRelativeTime(session.lastActivityAt)}</span>
<span className="bottom-sheet__preview-time">
{getRelativeTime(session.lastActivityAt)}
</span>
</div>
<h2 id="bottom-sheet-title" className="bottom-sheet__title">
{title}
@ -207,8 +209,7 @@ export function BottomSheet({
{pr ? <span className="bottom-sheet__preview-pr">#{pr.number}</span> : null}
{showLivePrData && pr ? (
<span className="bottom-sheet__preview-diff">
<span className="bottom-sheet__preview-diff-add">+{pr.additions}</span>
{" "}
<span className="bottom-sheet__preview-diff-add">+{pr.additions}</span>{" "}
<span className="bottom-sheet__preview-diff-del">-{pr.deletions}</span>
</span>
) : null}
@ -228,7 +229,7 @@ export function BottomSheet({
? "approved"
: pr.reviewDecision === "changes_requested"
? "changes requested"
: "needs review"}
: "needs review"}
</span>
{showTerminalStatePills ? (
<span className="bottom-sheet__tag bottom-sheet__tag--accent">

View File

@ -5,13 +5,14 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import {
NON_RESTORABLE_STATUSES,
type DashboardSession,
type AttentionLevel,
type DashboardOrchestratorLink,
type DashboardAttentionZoneMode,
getAttentionLevel,
isPRRateLimited,
isDashboardSessionRestorable,
isDashboardSessionTerminated,
} from "@/lib/types";
import { AttentionZone } from "./AttentionZone";
import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon";
@ -89,8 +90,8 @@ function DoneCard({
session.summary ||
session.id;
const isMerged = session.pr?.state === "merged" || session.status === "merged";
const isTerminated = session.status === "killed" || session.status === "terminated";
const canRestore = !NON_RESTORABLE_STATUSES.has(session.status);
const isTerminated = isDashboardSessionTerminated(session);
const canRestore = isDashboardSessionRestorable(session);
const badgeLabel = isMerged ? "merged" : isTerminated ? "terminated" : "done";
const badgeClass = `done-card__badge ${isTerminated ? "done-card__badge--terminated" : "done-card__badge--merged"}`;

View File

@ -3,7 +3,11 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import { type DashboardSession, TERMINAL_STATUSES, NON_RESTORABLE_STATUSES } from "@/lib/types";
import {
type DashboardSession,
isDashboardSessionRestorable,
isDashboardSessionTerminal,
} from "@/lib/types";
import dynamic from "next/dynamic";
import { getSessionTitle } from "@/lib/format";
import type { ProjectInfo } from "@/lib/project-name";
@ -63,8 +67,8 @@ export function SessionDetail({
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const pr = session.pr;
const terminalEnded = TERMINAL_STATUSES.has(session.status);
const isRestorable = terminalEnded && !NON_RESTORABLE_STATUSES.has(session.status);
const terminalEnded = isDashboardSessionTerminal(session);
const isRestorable = isDashboardSessionRestorable(session);
const activity = (session.activity && sessionActivityMeta[session.activity]) ?? {
label: session.activity ?? "unknown",
color: "var(--color-text-muted)",

View File

@ -225,6 +225,52 @@ describe("SessionDetail desktop layout", () => {
expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument();
});
it("keeps restored working sessions live when terminatedAt is stale", () => {
const base = makeSession({
id: "worker-restored-stale-terminal-marker",
projectId: "my-app",
status: "terminated",
activity: "active",
summary: "Restored worker is live",
pr: null,
});
const staleLifecycle = {
...base.lifecycle!,
sessionState: "working" as const,
sessionReason: "task_in_progress" as const,
runtimeState: "alive" as const,
runtimeReason: "process_running" as const,
session: {
...base.lifecycle!.session,
state: "working" as const,
reason: "task_in_progress" as const,
label: "working",
reasonLabel: "task in progress",
terminatedAt: "2026-05-13T19:13:20.146Z",
},
runtime: {
...base.lifecycle!.runtime,
state: "alive" as const,
reason: "process_running" as const,
label: "alive",
reasonLabel: "process running",
},
legacyStatus: "terminated" as const,
summary: "Session working (task in progress)",
};
render(<SessionDetail session={{ ...base, lifecycle: staleLifecycle }} />);
expect(screen.queryByRole("region", { name: "Session ended summary" })).not.toBeInTheDocument();
expect(screen.queryByText("Terminal ended")).not.toBeInTheDocument();
expect(
within(screen.getByRole("banner")).queryByRole("button", { name: "Restore" }),
).not.toBeInTheDocument();
expect(screen.getByTestId("direct-terminal")).toHaveTextContent(
"worker-restored-stale-terminal-marker",
);
});
it("shows restore for restorable orchestrator sessions", () => {
render(
<SessionDetail

View File

@ -376,11 +376,18 @@ export function getActivitySignalReasonLabel(session: DashboardSession): string
return parts.length > 0 ? parts.join(" • ") : null;
}
export function isDashboardSessionTerminated(session: DashboardSession): boolean {
if (session.lifecycle) {
return session.lifecycle.sessionState === "terminated";
}
return session.status === "terminated" || session.status === "killed";
}
export function isDashboardSessionDone(session: DashboardSession): boolean {
if (session.lifecycle) {
return (
session.lifecycle.sessionState === "done" ||
session.lifecycle.sessionState === "terminated" ||
isDashboardSessionTerminated(session) ||
session.lifecycle.prState === "merged"
);
}
@ -426,7 +433,7 @@ export function isDashboardSessionRestorable(session: DashboardSession): boolean
if (session.lifecycle) {
const terminalByCoreTruth =
session.lifecycle.sessionState === "done" ||
session.lifecycle.sessionState === "terminated" ||
isDashboardSessionTerminated(session) ||
session.lifecycle.runtimeState === "missing" ||
session.lifecycle.runtimeState === "exited";
return (