feat: expose split lifecycle truth in dashboard

Implement the Stage 5 rollout so API and UI consumers stop depending on one overloaded status and operators can see structured transition evidence without breaking older session metadata.
This commit is contained in:
harshitsinghbhandari 2026-04-17 13:00:26 +05:30
parent afc3bc3e0d
commit 7072143138
11 changed files with 649 additions and 66 deletions

View File

@ -0,0 +1,6 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
---
Expose split session, PR, and runtime lifecycle truth in dashboard API payloads, render that truth directly in session cards and detail views, and extend lifecycle observability with structured transition evidence, reasons, and recovery context while preserving legacy metadata compatibility.

View File

@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createLifecycleManager } from "../lifecycle-manager.js";
import { createSessionManager } from "../session-manager.js";
import { writeMetadata, readMetadataRaw } from "../metadata.js";
import { readObservabilitySummary } from "../observability.js";
import type {
OrchestratorConfig,
PluginRegistry,
@ -114,6 +115,35 @@ describe("check (single session)", () => {
expect(meta!["status"]).toBe("working");
});
it("records split lifecycle observability for transitions", async () => {
const lm = setupCheck("app-1", {
session: makeSession({ status: "spawning" }),
});
await lm.check("app-1");
const summary = readObservabilitySummary(config);
const trace = summary.projects["my-app"]?.recentTraces.find(
(entry) => entry.operation === "lifecycle.transition" && entry.sessionId === "app-1",
);
expect(trace?.reason).toBe("task_in_progress");
expect(trace?.data).toMatchObject({
oldStatus: "spawning",
newStatus: "working",
previousSessionState: "not_started",
newSessionState: "working",
previousPRState: "none",
newPRState: "none",
previousRuntimeState: "alive",
newRuntimeState: "alive",
primaryReason: "task_in_progress",
evidence: "activity:active",
signalsConsulted: ["activity:active"],
recoveryAction: null,
});
});
it("clears stale lifecycle compatibility metadata in memory and on disk", async () => {
const session = makeSession({
status: "working",
@ -375,7 +405,7 @@ describe("check (single session)", () => {
runtime: plugins.runtime,
agent: plugins.agent,
});
vi.mocked(registryWithoutAgent.get).mockImplementation((slot: string, name?: string) => {
vi.mocked(registryWithoutAgent.get).mockImplementation((slot: string, _name?: string) => {
if (slot === "runtime") return plugins.runtime;
if (slot === "agent") return null;
return null;

View File

@ -31,6 +31,7 @@ import {
type SCM,
type Notifier,
type Session,
type CanonicalSessionLifecycle,
type EventPriority,
type ProjectConfig as _ProjectConfig,
type PREnrichmentData,
@ -178,6 +179,59 @@ function transitionLogLevel(status: SessionStatus): "info" | "warn" | "error" {
return "info";
}
function splitEvidenceSignals(evidence: string): string[] {
return evidence
.split(/\s+/)
.map((signal) => signal.trim())
.filter((signal) => signal.length > 0);
}
function primaryLifecycleReason(lifecycle: CanonicalSessionLifecycle): string {
if (lifecycle.session.state === "detecting") return lifecycle.session.reason;
if (lifecycle.pr.reason !== "not_created" && lifecycle.pr.reason !== "in_progress") {
return lifecycle.pr.reason;
}
if (lifecycle.runtime.reason !== "process_running") {
return lifecycle.runtime.reason;
}
return lifecycle.session.reason;
}
function buildTransitionObservabilityData(
previous: CanonicalSessionLifecycle,
next: CanonicalSessionLifecycle,
oldStatus: SessionStatus,
newStatus: SessionStatus,
evidence: string,
detectingAttempts: number,
reaction?: { key: string; result: ReactionResult | null },
): Record<string, unknown> {
return {
oldStatus,
newStatus,
previousSessionState: previous.session.state,
newSessionState: next.session.state,
previousSessionReason: previous.session.reason,
newSessionReason: next.session.reason,
previousPRState: previous.pr.state,
newPRState: next.pr.state,
previousPRReason: previous.pr.reason,
newPRReason: next.pr.reason,
previousRuntimeState: previous.runtime.state,
newRuntimeState: next.runtime.state,
previousRuntimeReason: previous.runtime.reason,
newRuntimeReason: next.runtime.reason,
primaryReason: primaryLifecycleReason(next),
evidence,
signalsConsulted: splitEvidenceSignals(evidence),
detectingAttempts,
recoveryAction: reaction?.result?.action ?? null,
reactionKey: reaction?.key ?? null,
reactionSuccess: reaction?.result?.success ?? null,
escalated: reaction?.result?.escalated ?? null,
};
}
export interface LifecycleManagerDeps {
config: OrchestratorConfig;
registry: PluginRegistry;
@ -1438,6 +1492,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const tracked = states.get(session.id);
const oldStatus =
tracked ?? ((session.metadata?.["status"] as SessionStatus | undefined) || session.status);
const previousLifecycle = cloneLifecycle(session.lifecycle);
const assessment = await determineStatus(session);
const newStatus = assessment.status;
const lifecycleChanged = session.metadata["statePayload"] !== JSON.stringify(session.lifecycle);
@ -1479,7 +1534,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
correlationId,
projectId: session.projectId,
sessionId: session.id,
data: { oldStatus, newStatus },
reason: primaryLifecycleReason(session.lifecycle),
data: buildTransitionObservabilityData(
previousLifecycle,
session.lifecycle,
oldStatus,
newStatus,
assessment.evidence,
assessment.detectingAttempts,
),
level: transitionLogLevel(newStatus),
});
@ -1516,6 +1579,25 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
reactionConfig,
);
transitionReaction = { key: reactionKey, result: reactionResult };
observer.recordOperation({
metric: "lifecycle_poll",
operation: "lifecycle.transition.reaction",
outcome: reactionResult.success ? "success" : "failure",
correlationId,
projectId: session.projectId,
sessionId: session.id,
reason: primaryLifecycleReason(session.lifecycle),
data: buildTransitionObservabilityData(
previousLifecycle,
session.lifecycle,
oldStatus,
newStatus,
assessment.evidence,
assessment.detectingAttempts,
transitionReaction,
),
level: reactionResult.success ? "info" : "warn",
});
// Reaction is handling this event — suppress immediate human notification.
// "send-to-agent" retries + escalates on its own; "notify"/"auto-merge"
// already call notifyHuman internally. Notifying here would bypass the
@ -1544,6 +1626,24 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
states.set(session.id, newStatus);
if (lifecycleChanged) {
updateSessionMetadata(session, { status: newStatus });
observer.recordOperation({
metric: "lifecycle_poll",
operation: "lifecycle.transition",
outcome: "success",
correlationId: createCorrelationId("lifecycle-sync"),
projectId: session.projectId,
sessionId: session.id,
reason: primaryLifecycleReason(session.lifecycle),
data: buildTransitionObservabilityData(
previousLifecycle,
session.lifecycle,
oldStatus,
newStatus,
assessment.evidence,
assessment.detectingAttempts,
),
level: transitionLogLevel(newStatus),
});
}
}

View File

@ -250,7 +250,7 @@ export function parseCanonicalLifecycle(
export function deriveLegacyStatus(
lifecycle: CanonicalSessionLifecycle,
previousStatus: SessionStatus = "working",
_previousStatus: SessionStatus = "working",
): SessionStatus {
switch (lifecycle.session.state) {
case "not_started":

View File

@ -93,8 +93,11 @@ function DoneCard({
session.issueTitle ||
session.summary ||
session.id;
const isMerged = session.pr?.state === "merged";
const isTerminated = session.status === "killed" || session.status === "terminated";
const isMerged = session.lifecycle?.prState === "merged" || session.pr?.state === "merged";
const isTerminated =
session.lifecycle?.sessionState === "terminated" ||
session.status === "killed" ||
session.status === "terminated";
const badgeLabel = isMerged ? "merged" : isTerminated ? "terminated" : "done";
const badgeClass = `done-card__badge ${isTerminated ? "done-card__badge--terminated" : "done-card__badge--merged"}`;

View File

@ -6,9 +6,14 @@ import {
getAttentionLevel,
isPRRateLimited,
isPRUnenriched,
TERMINAL_STATUSES,
TERMINAL_ACTIVITIES,
CI_STATUS,
getSessionTruthLabel,
getPRTruthLabel,
getRuntimeTruthLabel,
getLifecycleGuidance,
isDashboardSessionDone,
isDashboardSessionTerminal,
isDashboardSessionRestorable,
} from "@/lib/types";
import { cn } from "@/lib/cn";
import { getSessionTitle } from "@/lib/format";
@ -33,7 +38,7 @@ function getDoneStatusInfo(session: DashboardSession): {
} {
const activity = session.activity;
const status = session.status;
const prState = session.pr?.state;
const prState = session.lifecycle?.prState ?? session.pr?.state;
if (prState === "merged" || status === "merged") {
return {
@ -53,9 +58,9 @@ function getDoneStatusInfo(session: DashboardSession): {
};
}
if (status === "killed" || status === "terminated") {
if (session.lifecycle?.sessionState === "terminated" || status === "killed" || status === "terminated") {
return {
label: status,
label: getSessionTruthLabel(session),
pillClass: "done-status-pill--killed",
icon: (
<svg
@ -72,7 +77,7 @@ function getDoneStatusInfo(session: DashboardSession): {
}
// Default: exited / done / cleanup / closed PR
const label = activity === "exited" ? "exited" : status;
const label = activity === "exited" ? "exited" : getSessionTruthLabel(session);
return {
label,
pillClass: "done-status-pill--exited",
@ -160,17 +165,23 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
const prUnenriched = pr ? isPRUnenriched(pr) : false;
const alerts = getAlerts(session);
const isReadyToMerge = !rateLimited && pr?.mergeability.mergeable && pr.state === "open";
const isTerminal =
TERMINAL_STATUSES.has(session.status) ||
(session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity));
const isRestorable = isTerminal && session.status !== "merged";
const isTerminal = isDashboardSessionTerminal(session);
const isRestorable = isDashboardSessionRestorable(session);
const title = getSessionTitle(session);
const footerStatus = getFooterStatusLabel(session, level, Boolean(isReadyToMerge));
const visiblePassingChecks = !rateLimited && pr && !prUnenriched
? pr.ciChecks.filter((check) => check.status === "passed").slice(0, 3)
: [];
const isDone = level === "done";
const isDone = isDashboardSessionDone(session) || level === "done";
const truthLine = session.lifecycle
? [
`Session ${getSessionTruthLabel(session)}`,
`PR ${getPRTruthLabel(session)}`,
`Runtime ${getRuntimeTruthLabel(session)}`,
].join(" · ")
: null;
const lifecycleGuidance = getLifecycleGuidance(session);
const secondaryText = session.issueLabel
? `${session.issueLabel}${session.issueTitle ? ` · ${session.issueTitle}` : ""}`
: (session.issueTitle ??
@ -551,6 +562,22 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
</div>
)}
{truthLine && (
<div className="px-[10px] pb-[5px]">
<p className="text-[10px] leading-relaxed text-[var(--color-text-tertiary)]">
{truthLine}
</p>
</div>
)}
{lifecycleGuidance && (
<div className="px-[10px] pb-[6px]">
<p className="inline-flex items-center gap-1 rounded-full border border-[color-mix(in_srgb,var(--color-status-attention)_35%,transparent)] bg-[color-mix(in_srgb,var(--color-status-attention)_9%,transparent)] px-2 py-1 text-[10px] leading-none text-[var(--color-status-attention)]">
{lifecycleGuidance}
</p>
</div>
)}
{rateLimited && pr?.state === "open" && (
<div className="px-[10px] pb-[5px]">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
@ -803,11 +830,12 @@ function getFooterStatusLabel(
isReadyToMerge: boolean,
): string {
if (isReadyToMerge || level === "merge") return "mergeable";
if (level === "respond") return "waiting_input";
if (session.status === "ci_failed") return "ci_failed";
if (level === "review") return "review_pending";
if (level === "working") return "working";
return session.status;
if (session.lifecycle?.sessionState === "detecting") return "detecting";
if (level === "respond") return getSessionTruthLabel(session);
if (session.lifecycle?.prReason === "ci_failing" || session.status === "ci_failed") return "ci failing";
if (level === "review") return getPRTruthLabel(session);
if (level === "working") return getSessionTruthLabel(session);
return getSessionTruthLabel(session);
}
interface Alert {
@ -834,11 +862,17 @@ function getAlerts(session: DashboardSession): Alert[] {
// The lifecycle manager's status is the most up-to-date source of truth.
// PR enrichment data can be stale (5-min cache) or unavailable (rate limit/timeout).
// Use lifecycle status as fallback when PR data hasn't caught up yet.
const lifecyclePrReason = session.lifecycle?.prReason ?? null;
const lifecycleStatus = meta["status"];
const ciIsFailing = pr.ciStatus === CI_STATUS.FAILING || lifecycleStatus === "ci_failed";
const ciIsFailing =
pr.ciStatus === CI_STATUS.FAILING ||
lifecyclePrReason === "ci_failing" ||
lifecycleStatus === "ci_failed";
const hasChangesRequested =
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
pr.reviewDecision === "changes_requested" ||
lifecyclePrReason === "changes_requested" ||
lifecycleStatus === "changes_requested";
const hasConflicts = !pr.mergeability.noConflicts;
if (ciIsFailing) {

View File

@ -6,11 +6,19 @@ import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import {
type DashboardSession,
type DashboardPR,
TERMINAL_STATUSES,
NON_RESTORABLE_STATUSES,
isPRMergeReady,
isPRRateLimited,
isPRUnenriched,
getSessionTruthLabel,
getSessionTruthReasonLabel,
getPRTruthLabel,
getPRTruthReasonLabel,
getRuntimeTruthLabel,
getRuntimeTruthReasonLabel,
getLifecycleGuidance,
getLifecycleEvidence,
isDashboardSessionTerminal,
} from "@/lib/types";
import { CI_STATUS } from "@aoagents/ao-core/types";
import { cn } from "@/lib/cn";
@ -49,6 +57,18 @@ interface SessionDetailProps {
sidebarSessions?: DashboardSession[];
}
function truthBadgeTone(label: string): string {
const normalized = label.toLowerCase();
if (normalized.includes("detect")) return "var(--color-status-attention)";
if (normalized.includes("terminated") || normalized.includes("missing")) {
return "var(--color-status-error)";
}
if (normalized.includes("merged") || normalized.includes("alive")) {
return "var(--color-status-ready)";
}
return "var(--color-accent)";
}
// ── Helpers ──────────────────────────────────────────────────────────
function formatTimeCompact(isoDate: string | null): string {
@ -125,6 +145,74 @@ function activityStateClass(activityLabel: string): string {
return "session-detail-status-pill--neutral";
}
function SessionTruthPanel({ session }: { session: DashboardSession }) {
if (!session.lifecycle) return null;
const facts = [
{
heading: "Session",
label: getSessionTruthLabel(session),
reason: getSessionTruthReasonLabel(session),
},
{
heading: "PR",
label: getPRTruthLabel(session),
reason: getPRTruthReasonLabel(session),
},
{
heading: "Runtime",
label: getRuntimeTruthLabel(session),
reason: getRuntimeTruthReasonLabel(session),
},
];
const guidance = getLifecycleGuidance(session);
const evidence = getLifecycleEvidence(session);
return (
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Lifecycle Truth
</p>
<p className="mt-1 text-[13px] text-[var(--color-text-secondary)]">
{session.lifecycle.summary}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{facts.map((fact) => (
<div
key={fact.heading}
className="rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-1.5"
>
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{fact.heading}
</div>
<div
className="mt-0.5 text-[12px] font-medium"
style={{ color: truthBadgeTone(fact.label) }}
>
{fact.label}
</div>
{fact.reason ? (
<div className="mt-0.5 text-[11px] text-[var(--color-text-tertiary)]">
{fact.reason}
</div>
) : null}
</div>
))}
</div>
{guidance ? (
<p className="mt-3 text-[12px] text-[var(--color-status-attention)]">
{guidance}
</p>
) : null}
{evidence ? (
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
Evidence: {evidence}
</p>
) : null}
</section>
);
}
function SessionTopStrip({
headline,
crumbId,
@ -458,7 +546,7 @@ export function SessionDetail({
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const pr = session.pr;
const terminalEnded = TERMINAL_STATUSES.has(session.status);
const terminalEnded = isDashboardSessionTerminal(session);
const isRestorable = terminalEnded && !NON_RESTORABLE_STATUSES.has(session.status);
const activity = (session.activity && activityMeta[session.activity]) ?? {
label: session.activity ?? "unknown",
@ -626,10 +714,13 @@ export function SessionDetail({
pr={pr}
sessionId={session.id}
metadata={session.metadata}
lifecyclePrReason={session.lifecycle?.prReason}
/>
</section>
) : null}
<SessionTruthPanel session={session} />
<section className="session-detail-terminal-wrap">
<div id="session-terminal-section" aria-hidden="true" />
<div className="session-detail-section-label">
@ -765,7 +856,17 @@ export function SessionDetail({
// ── Session detail PR card ────────────────────────────────────────────
function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; sessionId: string; metadata: Record<string, string> }) {
function SessionDetailPRCard({
pr,
sessionId,
metadata,
lifecyclePrReason,
}: {
pr: DashboardPR;
sessionId: string;
metadata: Record<string, string>;
lifecyclePrReason?: string;
}) {
const [sendingComments, setSendingComments] = useState<Set<string>>(new Set());
const [sentComments, setSentComments] = useState<Set<string>>(new Set());
const [errorComments, setErrorComments] = useState<Set<string>>(new Set());
@ -836,7 +937,7 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
};
const allGreen = isPRMergeReady(pr);
const blockerIssues = buildBlockerChips(pr, metadata);
const blockerIssues = buildBlockerChips(pr, metadata, lifecyclePrReason);
const fileCount = pr.changedFiles ?? 0;
return (
@ -1029,7 +1130,11 @@ interface BlockerChip {
notified?: boolean;
}
function buildBlockerChips(pr: DashboardPR, metadata: Record<string, string>): BlockerChip[] {
function buildBlockerChips(
pr: DashboardPR,
metadata: Record<string, string>,
lifecyclePrReason?: string,
): BlockerChip[] {
const chips: BlockerChip[] = [];
const ciNotified = Boolean(metadata["lastCIFailureDispatchHash"]);
@ -1037,9 +1142,14 @@ function buildBlockerChips(pr: DashboardPR, metadata: Record<string, string>): B
const reviewNotified = Boolean(metadata["lastPendingReviewDispatchHash"]);
const lifecycleStatus = metadata["status"];
const ciIsFailing = pr.ciStatus === CI_STATUS.FAILING || lifecycleStatus === "ci_failed";
const ciIsFailing =
pr.ciStatus === CI_STATUS.FAILING ||
lifecyclePrReason === "ci_failing" ||
lifecycleStatus === "ci_failed";
const hasChangesRequested =
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
pr.reviewDecision === "changes_requested" ||
lifecyclePrReason === "changes_requested" ||
lifecycleStatus === "changes_requested";
const hasConflicts = pr.state !== "merged" && !pr.mergeability.noConflicts;
if (ciIsFailing) {

View File

@ -139,14 +139,39 @@ describe("sessionToDashboard", () => {
const coreSession = createCoreSession();
const dashboard = sessionToDashboard(coreSession);
expect(dashboard.lifecycle).toEqual({
sessionState: "working",
sessionReason: "task_in_progress",
prState: "none",
prReason: "not_created",
runtimeState: "alive",
runtimeReason: "process_running",
expect(dashboard.lifecycle?.sessionState).toBe("working");
expect(dashboard.lifecycle?.sessionReason).toBe("task_in_progress");
expect(dashboard.lifecycle?.prState).toBe("none");
expect(dashboard.lifecycle?.prReason).toBe("not_created");
expect(dashboard.lifecycle?.runtimeState).toBe("alive");
expect(dashboard.lifecycle?.runtimeReason).toBe("process_running");
expect(dashboard.lifecycle?.session.label).toBe("working");
expect(dashboard.lifecycle?.pr.label).toBe("not created");
expect(dashboard.lifecycle?.runtime.label).toBe("alive");
expect(dashboard.lifecycle?.summary).toContain("Session working");
expect(dashboard.attentionLevel).toBe("working");
});
it("should expose detecting guidance and evidence from legacy metadata", () => {
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z"));
lifecycle.session.state = "detecting";
lifecycle.session.reason = "probe_failure";
lifecycle.runtime.state = "probe_failed";
lifecycle.runtime.reason = "probe_error";
const coreSession = createCoreSession({
lifecycle,
status: "detecting",
metadata: {
lifecycleEvidence: "signal_disagreement runtime_alive process_unknown",
detectingAttempts: "2",
},
});
const dashboard = sessionToDashboard(coreSession);
expect(dashboard.lifecycle?.guidance).toContain("Retry 2");
expect(dashboard.lifecycle?.evidence).toContain("signal_disagreement");
expect(dashboard.attentionLevel).toBe("respond");
});
it("should use agentInfo summary with summaryIsFallback false", () => {

View File

@ -70,6 +70,23 @@ describe("getAttentionLevel", () => {
it("should return 'done' for merged PR regardless of session status", () => {
const session = createSession({
status: "working",
lifecycle: {
sessionState: "idle",
sessionReason: "merged_waiting_decision",
prState: "merged",
prReason: "merged",
runtimeState: "alive",
runtimeReason: "process_running",
session: { state: "idle", reason: "merged_waiting_decision", label: "merged, waiting decision", reasonLabel: "merged waiting decision" },
pr: { state: "merged", reason: "merged", label: "merged", reasonLabel: "merged" },
runtime: { state: "alive", reason: "process_running", label: "alive", reasonLabel: "process running" },
legacyStatus: "merged",
evidence: null,
detectingAttempts: 0,
detectingEscalatedAt: null,
summary: "PR merged; worker is still available for a keep-or-kill decision",
guidance: null,
},
pr: {
number: 1,
url: "https://github.com/test/repo/pull/1",
@ -198,6 +215,30 @@ describe("getAttentionLevel", () => {
expect(getAttentionLevel(session)).toBe("respond");
});
it("should return 'respond' for detecting lifecycle state", () => {
const session = createSession({
status: "detecting",
lifecycle: {
sessionState: "detecting",
sessionReason: "probe_failure",
prState: "open",
prReason: "in_progress",
runtimeState: "probe_failed",
runtimeReason: "probe_error",
session: { state: "detecting", reason: "probe_failure", label: "detecting", reasonLabel: "probe failure" },
pr: { state: "open", reason: "in_progress", label: "open", reasonLabel: "in progress" },
runtime: { state: "probe_failed", reason: "probe_error", label: "probe failed", reasonLabel: "probe error" },
legacyStatus: "detecting",
evidence: "signal_disagreement",
detectingAttempts: 1,
detectingEscalatedAt: null,
summary: "Detecting runtime truth (probe failure)",
guidance: "Checking runtime and process evidence now. Retry 1 is in progress.",
},
});
expect(getAttentionLevel(session)).toBe("respond");
});
it("should return 'respond' for blocked activity", () => {
const session = createSession({ activity: "blocked" });
expect(getAttentionLevel(session)).toBe("respond");

View File

@ -24,6 +24,7 @@ import type {
DashboardStats,
DashboardOrchestratorLink,
} from "./types.js";
import { getAttentionLevel } from "@/lib/types";
import { TTLCache, prCache, prCacheKey, type PREnrichmentData } from "./cache";
/** Cache for issue titles (5 min TTL — issue titles rarely change) */
@ -47,24 +48,125 @@ export function resolveProject(
return firstKey ? projects[firstKey] : undefined;
}
function humanizeLifecycleToken(token: string): string {
return token.replace(/_/g, " ");
}
function buildLifecycleLabel(state: string, reason: string): string {
if (state === "idle" && reason === "merged_waiting_decision") {
return "merged, waiting decision";
}
if (state === "none") {
return "not created";
}
if (state === "alive") {
return "alive";
}
if (state === "missing") {
return "missing";
}
return humanizeLifecycleToken(state);
}
function buildLifecycleSummary(session: Session): string {
const { lifecycle } = session;
if (lifecycle.session.state === "detecting") {
return `Detecting runtime truth (${humanizeLifecycleToken(lifecycle.session.reason)})`;
}
if (lifecycle.pr.state === "merged") {
return "PR merged; worker is still available for a keep-or-kill decision";
}
if (lifecycle.pr.state === "closed") {
return "PR closed without merge";
}
if (lifecycle.pr.reason === "ci_failing") {
return "PR is open and CI is failing";
}
if (lifecycle.pr.reason === "changes_requested") {
return "PR is open with requested changes";
}
if (lifecycle.pr.reason === "review_pending") {
return "PR is open and waiting on review";
}
return `Session ${humanizeLifecycleToken(lifecycle.session.state)} (${humanizeLifecycleToken(lifecycle.session.reason)})`;
}
function buildLifecycleGuidance(session: Session): string | null {
const { lifecycle, metadata } = session;
if (lifecycle.session.state !== "detecting") {
return null;
}
const attempts = Number.parseInt(metadata["detectingAttempts"] ?? "0", 10);
const normalizedAttempts = Number.isFinite(attempts) ? attempts : 0;
if (metadata["detectingEscalatedAt"]) {
return "Detection retries exhausted. Inspect runtime evidence or restore the session manually.";
}
if (normalizedAttempts > 0) {
return `Checking runtime and process evidence now. Retry ${normalizedAttempts} is in progress.`;
}
return "Checking runtime and process evidence now.";
}
function buildDashboardLifecycle(session: Session): NonNullable<DashboardSession["lifecycle"]> {
const lifecycle = session.lifecycle;
return {
sessionState: lifecycle.session.state,
sessionReason: lifecycle.session.reason,
prState: lifecycle.pr.state,
prReason: lifecycle.pr.reason,
runtimeState: lifecycle.runtime.state,
runtimeReason: lifecycle.runtime.reason,
session: {
state: lifecycle.session.state,
reason: lifecycle.session.reason,
label: buildLifecycleLabel(lifecycle.session.state, lifecycle.session.reason),
reasonLabel: humanizeLifecycleToken(lifecycle.session.reason),
startedAt: lifecycle.session.startedAt,
completedAt: lifecycle.session.completedAt,
terminatedAt: lifecycle.session.terminatedAt,
lastTransitionAt: lifecycle.session.lastTransitionAt,
},
pr: {
state: lifecycle.pr.state,
reason: lifecycle.pr.reason,
label: buildLifecycleLabel(lifecycle.pr.state, lifecycle.pr.reason),
reasonLabel: humanizeLifecycleToken(lifecycle.pr.reason),
number: lifecycle.pr.number,
url: lifecycle.pr.url,
lastObservedAt: lifecycle.pr.lastObservedAt,
},
runtime: {
state: lifecycle.runtime.state,
reason: lifecycle.runtime.reason,
label: buildLifecycleLabel(lifecycle.runtime.state, lifecycle.runtime.reason),
reasonLabel: humanizeLifecycleToken(lifecycle.runtime.reason),
lastObservedAt: lifecycle.runtime.lastObservedAt,
},
legacyStatus: session.status,
evidence: session.metadata["lifecycleEvidence"] ?? null,
detectingAttempts: Number.parseInt(session.metadata["detectingAttempts"] ?? "0", 10) || 0,
detectingEscalatedAt: session.metadata["detectingEscalatedAt"] ?? null,
summary: buildLifecycleSummary(session),
guidance: buildLifecycleGuidance(session),
};
}
export function refreshDashboardSessionDerivedFields(session: DashboardSession): DashboardSession {
session.attentionLevel = getAttentionLevel(session);
return session;
}
/** Convert a core Session to a DashboardSession (without PR/issue enrichment). */
export function sessionToDashboard(session: Session): DashboardSession {
const agentSummary = session.agentInfo?.summary;
const summary = agentSummary ?? session.metadata["summary"] ?? null;
return {
return refreshDashboardSessionDerivedFields({
id: session.id,
projectId: session.projectId,
status: session.status,
activity: session.activity,
lifecycle: {
sessionState: session.lifecycle.session.state,
sessionReason: session.lifecycle.session.reason,
prState: session.lifecycle.pr.state,
prReason: session.lifecycle.pr.reason,
runtimeState: session.lifecycle.runtime.state,
runtimeReason: session.lifecycle.runtime.reason,
},
lifecycle: buildDashboardLifecycle(session),
branch: session.branch,
issueId: session.issueId, // Deprecated: kept for backwards compatibility
issueUrl: session.issueId, // issueId is actually the full URL
@ -77,7 +179,7 @@ export function sessionToDashboard(session: Session): DashboardSession {
lastActivityAt: session.lastActivityAt.toISOString(),
pr: session.pr ? basicPRToDashboard(session.pr) : null,
metadata: session.metadata,
};
});
}
export function listDashboardOrchestrators(
@ -165,6 +267,7 @@ export async function enrichSessionPR(
dashboard.pr.unresolvedThreads = cached.unresolvedThreads;
dashboard.pr.unresolvedComments = cached.unresolvedComments;
dashboard.pr.enriched = true;
refreshDashboardSessionDerivedFields(dashboard);
return true;
}
@ -276,6 +379,7 @@ export async function enrichSessionPR(
unresolvedComments: dashboard.pr.unresolvedComments,
};
prCache.set(cacheKey, rateLimitedData, 60 * 60_000); // 60 min — GitHub rate limit resets hourly
refreshDashboardSessionDerivedFields(dashboard);
return true;
}
@ -292,6 +396,7 @@ export async function enrichSessionPR(
unresolvedComments: dashboard.pr.unresolvedComments,
};
prCache.set(cacheKey, cacheData);
refreshDashboardSessionDerivedFields(dashboard);
return true;
}

View File

@ -15,6 +15,12 @@ export type {
ReviewDecision,
MergeReadiness,
PRState,
CanonicalSessionState,
CanonicalSessionReason,
CanonicalPRState,
CanonicalPRReason,
CanonicalRuntimeState,
CanonicalRuntimeReason,
} from "@aoagents/ao-core/types";
import {
@ -30,6 +36,12 @@ import {
type SessionStatus,
type ActivityState,
type ReviewDecision,
type CanonicalSessionState,
type CanonicalSessionReason,
type CanonicalPRState,
type CanonicalPRReason,
type CanonicalRuntimeState,
type CanonicalRuntimeReason,
} from "@aoagents/ao-core/types";
// Re-export for use in client components
@ -60,14 +72,7 @@ export interface DashboardSession {
projectId: string;
status: SessionStatus;
activity: ActivityState | null;
lifecycle?: {
sessionState: string;
sessionReason: string;
prState: string;
prReason: string;
runtimeState: string;
runtimeReason: string;
};
lifecycle?: DashboardLifecycle;
branch: string | null;
issueId: string | null; // Deprecated: use issueUrl instead
issueUrl: string | null; // Full issue URL
@ -81,6 +86,46 @@ export interface DashboardSession {
lastActivityAt: string;
pr: DashboardPR | null;
metadata: Record<string, string>;
attentionLevel?: AttentionLevel;
}
export interface DashboardLifecycleFacet<
TState extends string = string,
TReason extends string = string,
> {
state: TState;
reason: TReason;
label: string;
reasonLabel: string;
}
export interface DashboardLifecycle {
sessionState: CanonicalSessionState;
sessionReason: CanonicalSessionReason;
prState: CanonicalPRState;
prReason: CanonicalPRReason;
runtimeState: CanonicalRuntimeState;
runtimeReason: CanonicalRuntimeReason;
session: DashboardLifecycleFacet<CanonicalSessionState, CanonicalSessionReason> & {
startedAt?: string | null;
completedAt?: string | null;
terminatedAt?: string | null;
lastTransitionAt?: string | null;
};
pr: DashboardLifecycleFacet<CanonicalPRState, CanonicalPRReason> & {
number?: number | null;
url?: string | null;
lastObservedAt?: string | null;
};
runtime: DashboardLifecycleFacet<CanonicalRuntimeState, CanonicalRuntimeReason> & {
lastObservedAt?: string | null;
};
legacyStatus: SessionStatus;
evidence: string | null;
detectingAttempts: number;
detectingEscalatedAt: string | null;
summary: string;
guidance: string | null;
}
/**
@ -199,9 +244,52 @@ export function isPRMergeReady(pr: DashboardPR): boolean {
);
}
/** Determines which attention zone a session belongs to */
export function getAttentionLevel(session: DashboardSession): AttentionLevel {
// ── Done: terminal states ─────────────────────────────────────────
function humanizeLifecycleToken(token: string): string {
return token.replace(/_/g, " ");
}
export function getSessionTruthLabel(session: DashboardSession): string {
return session.lifecycle?.session.label ?? humanizeLifecycleToken(session.status);
}
export function getSessionTruthReasonLabel(session: DashboardSession): string | null {
return session.lifecycle?.session.reasonLabel ?? null;
}
export function getPRTruthLabel(session: DashboardSession): string {
if (session.lifecycle) return session.lifecycle.pr.label;
return session.pr?.state ? humanizeLifecycleToken(session.pr.state) : "not created";
}
export function getPRTruthReasonLabel(session: DashboardSession): string | null {
return session.lifecycle?.pr.reasonLabel ?? null;
}
export function getRuntimeTruthLabel(session: DashboardSession): string {
return session.lifecycle?.runtime.label ?? "unknown";
}
export function getRuntimeTruthReasonLabel(session: DashboardSession): string | null {
return session.lifecycle?.runtime.reasonLabel ?? null;
}
export function getLifecycleGuidance(session: DashboardSession): string | null {
return session.lifecycle?.guidance ?? null;
}
export function getLifecycleEvidence(session: DashboardSession): string | null {
return session.lifecycle?.evidence ?? session.metadata["lifecycleEvidence"] ?? null;
}
export function isDashboardSessionDone(session: DashboardSession): boolean {
if (session.lifecycle) {
return (
session.lifecycle.sessionState === "done" ||
session.lifecycle.sessionState === "terminated" ||
session.lifecycle.prState === "merged" ||
session.lifecycle.prState === "closed"
);
}
if (
session.status === "merged" ||
session.status === "killed" ||
@ -209,18 +297,46 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
session.status === "done" ||
session.status === "terminated"
) {
return "done";
return true;
}
if (session.pr) {
if (session.pr.state === "merged" || session.pr.state === "closed") {
return "done";
}
return session.pr?.state === "merged" || session.pr?.state === "closed";
}
export function isDashboardSessionTerminal(session: DashboardSession): boolean {
if (session.lifecycle) {
return (
isDashboardSessionDone(session) ||
session.lifecycle.runtimeState === "missing" ||
session.lifecycle.runtimeState === "exited"
);
}
return (
TERMINAL_STATUSES.has(session.status) ||
(session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity))
);
}
export function isDashboardSessionRestorable(session: DashboardSession): boolean {
if (!isDashboardSessionTerminal(session)) return false;
return session.lifecycle?.prState !== "merged" && session.status !== "merged";
}
/** Determines which attention zone a session belongs to */
export function getAttentionLevel(session: DashboardSession): AttentionLevel {
// ── Done: terminal states ─────────────────────────────────────────
if (isDashboardSessionDone(session)) {
return "done";
}
// ── Merge: PR is ready — one click to clear ───────────────────────
// Check this early: if the PR is mergeable, that's the most valuable
// action for the human regardless of agent activity.
if (session.status === "mergeable" || session.status === "approved") {
if (
session.lifecycle?.prReason === "merge_ready" ||
session.lifecycle?.prReason === "approved" ||
session.status === "mergeable" ||
session.status === "approved"
) {
return "merge";
}
if (session.pr && !isPRUnenriched(session.pr) && session.pr.mergeability.mergeable) {
@ -231,6 +347,9 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
// Check status-based error conditions first — these are authoritative
// and should not be masked by a stale activity value.
if (
session.lifecycle?.sessionState === "detecting" ||
session.lifecycle?.sessionState === "needs_input" ||
session.lifecycle?.sessionState === "stuck" ||
session.status === SESSION_STATUS.ERRORED ||
session.status === SESSION_STATUS.NEEDS_INPUT ||
session.status === SESSION_STATUS.STUCK
@ -249,7 +368,12 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
}
// ── Review: problems that need investigation ──────────────────────
if (session.status === "ci_failed" || session.status === "changes_requested") {
if (
session.lifecycle?.prReason === "ci_failing" ||
session.lifecycle?.prReason === "changes_requested" ||
session.status === "ci_failed" ||
session.status === "changes_requested"
) {
return "review";
}
if (session.pr && !isPRRateLimited(session.pr) && !isPRUnenriched(session.pr)) {
@ -260,7 +384,12 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel {
}
// ── Pending: waiting on external (reviewer, CI) ───────────────────
if (session.status === "review_pending") {
if (
session.lifecycle?.sessionState === "idle" ||
session.lifecycle?.prReason === "review_pending" ||
session.lifecycle?.prReason === "in_progress" ||
session.status === "review_pending"
) {
return "pending";
}
if (session.pr && !isPRRateLimited(session.pr) && !isPRUnenriched(session.pr)) {