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:
parent
21335db8af
commit
de662dc042
|
|
@ -83,6 +83,8 @@ const VALID_STATUSES: ReadonlySet<string> = new Set([
|
|||
"stuck",
|
||||
"errored",
|
||||
"killed",
|
||||
"done",
|
||||
"terminated",
|
||||
]);
|
||||
|
||||
/** Validate and normalize a status string. */
|
||||
|
|
|
|||
|
|
@ -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]");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue