* 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:
parent
7d324b537d
commit
6fb18cb47e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in New Issue