fix: recognize terminated/done session states and hide terminal for dead sessions (#40)

* fix: recognize terminated/done session states and hide terminal for dead sessions

- Add "done" and "terminated" to VALID_STATUSES in session-manager so
  validateStatus() doesn't fall back to "spawning" for these states
- Hide terminal button for terminal-state sessions (no tmux to connect to)
- Hide "terminate session" button for already-terminated sessions
- Show "restore session" button for terminated/done sessions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: restore activity=exited check for crashed sessions

Bugbot caught that the refactor dropped the activity === "exited"
condition. When an agent crashes, status stays non-terminal (e.g.
"working") but activity becomes "exited" — these need the restore
button and should not show terminal/terminate buttons.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add terminated/done to backend RESTORABLE_STATUSES

Frontend shows restore button for terminated/done sessions but
the backend restore endpoint only accepted killed/cleanup, returning
409 "Session is not in a terminal state" for the new statuses.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add type annotations to fix implicit any errors in integration tests

Pre-existing issue from package rename — callback parameters in
.find() lost type inference. Add explicit type annotations.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: filter orchestrator session from SSR page

The API route filtered it but the SSR path in page.tsx did not,
causing the orchestrator to appear as a session card.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: make orchestrator session name dynamic using prefix convention

Use endsWith("-orchestrator") instead of hardcoded "orchestrator" to
support project-prefixed names like "ao-orchestrator". Pass orchestratorId
from SSR to Dashboard so the terminal button links to the correct session.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: update stale @agent-orchestrator/core imports to @composio/ao-core

Package was renamed in PR #32 but these two files were missed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-15 05:15:41 +05:30 committed by GitHub
parent 21335db8af
commit de662dc042
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 56 additions and 41 deletions

View File

@ -83,6 +83,8 @@ const VALID_STATUSES: ReadonlySet<string> = new Set([
"stuck",
"errored",
"killed",
"done",
"terminated",
]);
/** Validate and normalize a status string. */

View File

@ -231,7 +231,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => {
project,
);
const found = issues.find((i) => i.id === issueIdentifier);
const found = issues.find((i: { id: string }) => i.id === issueIdentifier);
expect(found).toBeDefined();
expect(found!.title).toContain("[AO Integration Test]");
});

View File

@ -95,7 +95,7 @@ describe("workspace-clone (integration)", () => {
it("lists the clone", async () => {
const list = await workspace.list("inttest");
expect(list.length).toBeGreaterThanOrEqual(1);
const found = list.find((w) => w.sessionId === "session-1");
const found = list.find((w: { sessionId: string }) => w.sessionId === "session-1");
expect(found).toBeDefined();
expect(found!.branch).toBe("feat/test-branch");
});
@ -119,7 +119,7 @@ describe("workspace-clone (integration)", () => {
it("list returns empty after destroy", async () => {
const list = await workspace.list("inttest");
const found = list.find((w) => w.sessionId === "session-1");
const found = list.find((w: { sessionId: string }) => w.sessionId === "session-1");
expect(found).toBeUndefined();
});
});

View File

@ -89,7 +89,7 @@ describe("workspace-worktree (integration)", () => {
it("lists the worktree", async () => {
const list = await workspace.list("inttest");
expect(list.length).toBeGreaterThanOrEqual(1);
const found = list.find((w) => w.sessionId === "session-1");
const found = list.find((w: { sessionId: string }) => w.sessionId === "session-1");
expect(found).toBeDefined();
expect(found!.branch).toBe("feat/test-branch");
});
@ -124,7 +124,7 @@ describe("workspace-worktree (integration)", () => {
it("list returns empty after destroy", async () => {
const list = await workspace.list("inttest");
const found = list.find((w) => w.sessionId === "session-1");
const found = list.find((w: { sessionId: string }) => w.sessionId === "session-1");
expect(found).toBeUndefined();
});
});

View File

@ -4,7 +4,7 @@ import { getServices } from "@/lib/services";
import { sessionToDashboard } from "@/lib/serialize";
/** Terminal states that can be restored */
const RESTORABLE_STATUSES = new Set(["killed", "cleanup"]);
const RESTORABLE_STATUSES = new Set(["killed", "cleanup", "terminated", "done"]);
const RESTORABLE_ACTIVITIES = new Set(["exited"]);
/** Statuses that must never be restored (e.g. already merged) */

View File

@ -1,4 +1,4 @@
import type { Session, ProjectConfig } from "@agent-orchestrator/core";
import type { Session, ProjectConfig } from "@composio/ao-core";
import { NextResponse } from "next/server";
import { getServices, getSCM, getTracker } from "@/lib/services";
import { sessionToDashboard, enrichSessionPR, enrichSessionIssue, computeStats } from "@/lib/serialize";
@ -29,8 +29,8 @@ export async function GET() {
const { config, registry, sessionManager } = await getServices();
const coreSessions = await sessionManager.list();
// Filter out special orchestrator session - it's not a worker session
const workerSessions = coreSessions.filter((s) => s.id !== "orchestrator");
// Filter out orchestrator sessions — they get their own button, not a card
const workerSessions = coreSessions.filter((s) => !s.id.endsWith("-orchestrator"));
const dashboardSessions = workerSessions.map(sessionToDashboard);
// Enrich issue labels using tracker plugin (synchronous)

View File

@ -8,9 +8,14 @@ export const dynamic = "force-dynamic";
export default async function Home() {
let sessions: DashboardSession[] = [];
let orchestratorId: string | null = null;
try {
const { config, registry, sessionManager } = await getServices();
const coreSessions = await sessionManager.list();
const allSessions = await sessionManager.list();
// Find and filter out orchestrator sessions — they get their own button, not a card
const orchSession = allSessions.find((s) => s.id.endsWith("-orchestrator"));
if (orchSession) orchestratorId = orchSession.id;
const coreSessions = allSessions.filter((s) => !s.id.endsWith("-orchestrator"));
sessions = coreSessions.map(sessionToDashboard);
// Enrich issue labels using tracker plugin (synchronous)
@ -97,5 +102,5 @@ export default async function Home() {
// Config not found or services unavailable — show empty dashboard
}
return <Dashboard sessions={sessions} stats={computeStats(sessions)} />;
return <Dashboard sessions={sessions} stats={computeStats(sessions)} orchestratorId={orchestratorId} />;
}

View File

@ -14,9 +14,10 @@ import { PRTableRow } from "./PRStatus";
interface DashboardProps {
sessions: DashboardSession[];
stats: DashboardStats;
orchestratorId?: string | null;
}
export function Dashboard({ sessions, stats }: DashboardProps) {
export function Dashboard({ sessions, stats, orchestratorId }: DashboardProps) {
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {
merge: [],
@ -86,12 +87,14 @@ export function Dashboard({ sessions, stats }: DashboardProps) {
<span className="text-[#7c8aff]">Agent</span> Orchestrator
</h1>
<div className="flex items-baseline gap-4">
<a
href="/sessions/orchestrator"
className="rounded-md border border-[var(--color-border-default)] px-3 py-1 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent-blue)] hover:text-[var(--color-accent-blue)]"
>
orchestrator terminal
</a>
{orchestratorId && (
<a
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
className="rounded-md border border-[var(--color-border-default)] px-3 py-1 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent-blue)] hover:text-[var(--color-accent-blue)]"
>
orchestrator terminal
</a>
)}
<ClientTimestamp />
</div>
</div>

View File

@ -53,11 +53,14 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
const alerts = getAlerts(session);
const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open";
const isRestorable =
(session.status === "killed" ||
session.status === "cleanup" ||
session.activity === "exited") &&
session.status !== "merged";
const isTerminal =
session.status === "killed" ||
session.status === "cleanup" ||
session.status === "terminated" ||
session.status === "done" ||
session.status === "merged" ||
session.activity === "exited";
const isRestorable = isTerminal && session.status !== "merged";
return (
<div
@ -101,22 +104,24 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
restore session
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "3001";
fetch(`http://localhost:${port}/terminal?session=${encodeURIComponent(session.id)}`)
.then((res) => res.json() as Promise<{ url: string }>)
.then((data) => window.open(data.url, `terminal-${session.id}`))
.catch(() => {
// Fall back to session detail page
window.location.href = `/sessions/${encodeURIComponent(session.id)}`;
});
}}
className="shrink-0 rounded-md border border-[var(--color-border-default)] px-2.5 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent-blue)] hover:text-[var(--color-accent-blue)]"
>
terminal
</button>
{!isTerminal && (
<button
onClick={(e) => {
e.stopPropagation();
const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "3001";
fetch(`http://localhost:${port}/terminal?session=${encodeURIComponent(session.id)}`)
.then((res) => res.json() as Promise<{ url: string }>)
.then((data) => window.open(data.url, `terminal-${session.id}`))
.catch(() => {
// Fall back to session detail page
window.location.href = `/sessions/${encodeURIComponent(session.id)}`;
});
}}
className="shrink-0 rounded-md border border-[var(--color-border-default)] px-2.5 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent-blue)] hover:text-[var(--color-accent-blue)]"
>
terminal
</button>
)}
</div>
{/* Meta row: branch + PR pills */}
@ -269,7 +274,7 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
restore session
</button>
)}
{!isRestorable && session.status !== "merged" && (
{!isTerminal && (
<button
onClick={(e) => {
e.stopPropagation();

View File

@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { Session, PRInfo, SCM } from "@agent-orchestrator/core";
import type { Session, PRInfo, SCM } from "@composio/ao-core";
import { sessionToDashboard, enrichSessionPR } from "../serialize";
import { prCache, prCacheKey } from "../cache";
import type { DashboardSession } from "../types";