diff --git a/.changeset/stage5-ui-api-rollout.md b/.changeset/stage5-ui-api-rollout.md new file mode 100644 index 000000000..0dc2948fc --- /dev/null +++ b/.changeset/stage5-ui-api-rollout.md @@ -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. diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 006553729..132c1480f 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -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; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index d88cc068e..b3ff86d88 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -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 { + 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), + }); } } diff --git a/packages/core/src/lifecycle-state.ts b/packages/core/src/lifecycle-state.ts index a347d768b..5f14de93f 100644 --- a/packages/core/src/lifecycle-state.ts +++ b/packages/core/src/lifecycle-state.ts @@ -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": diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index b8c98ff2f..48bfec114 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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"}`; diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 04181e43b..2f496b807 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -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: ( 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 )} + {truthLine && ( +
+

+ {truthLine} +

+
+ )} + + {lifecycleGuidance && ( +
+

+ {lifecycleGuidance} +

+
+ )} + {rateLimited && pr?.state === "open" && (
@@ -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) { diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 984a7949a..7e74f2441 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -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 ( +
+

+ Lifecycle Truth +

+

+ {session.lifecycle.summary} +

+
+ {facts.map((fact) => ( +
+
+ {fact.heading} +
+
+ {fact.label} +
+ {fact.reason ? ( +
+ {fact.reason} +
+ ) : null} +
+ ))} +
+ {guidance ? ( +

+ {guidance} +

+ ) : null} + {evidence ? ( +

+ Evidence: {evidence} +

+ ) : null} +
+ ); +} + 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} /> ) : null} + +