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:
parent
afc3bc3e0d
commit
7072143138
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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"}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue