diff --git a/src/logging/diagnostic-session-attention.test.ts b/src/logging/diagnostic-session-attention.test.ts new file mode 100644 index 00000000000..a19d0c64650 --- /dev/null +++ b/src/logging/diagnostic-session-attention.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { classifySessionAttention } from "./diagnostic-session-attention.js"; + +describe("classifySessionAttention", () => { + it.each([ + { + name: "stale state without queued work", + queueDepth: 0, + activity: {}, + expected: { + eventType: "session.stuck", + reason: "stale_session_state", + classification: "stale_session_state", + recoveryEligible: true, + }, + }, + { + name: "queued stale state without active work", + queueDepth: 1, + activity: {}, + expected: { + eventType: "session.stuck", + reason: "queued_work_without_active_run", + classification: "stale_session_state", + recoveryEligible: true, + }, + }, + { + name: "active embedded run making progress", + queueDepth: 0, + activity: { + activeWorkKind: "embedded_run" as const, + lastProgressAgeMs: 10_000, + }, + expected: { + eventType: "session.long_running", + reason: "active_work", + classification: "long_running", + activeWorkKind: "embedded_run", + recoveryEligible: false, + }, + }, + { + name: "queued behind active work", + queueDepth: 1, + activity: { + activeWorkKind: "embedded_run" as const, + lastProgressAgeMs: 10_000, + }, + expected: { + eventType: "session.long_running", + reason: "queued_behind_active_work", + classification: "long_running", + activeWorkKind: "embedded_run", + recoveryEligible: false, + }, + }, + { + name: "active work without progress", + queueDepth: 0, + activity: { + activeWorkKind: "model_call" as const, + lastProgressAgeMs: 31_000, + }, + expected: { + eventType: "session.stalled", + reason: "active_work_without_progress", + classification: "stalled_agent_run", + activeWorkKind: "model_call", + recoveryEligible: false, + }, + }, + { + name: "blocked tool call", + queueDepth: 0, + activity: { + activeWorkKind: "tool_call" as const, + activeToolAgeMs: 31_000, + lastProgressAgeMs: 31_000, + }, + expected: { + eventType: "session.stalled", + reason: "blocked_tool_call", + classification: "blocked_tool_call", + activeWorkKind: "tool_call", + recoveryEligible: false, + }, + }, + ])("$name", ({ activity, expected, queueDepth }) => { + expect( + classifySessionAttention({ + queueDepth, + activity, + staleMs: 30_000, + }), + ).toEqual(expected); + }); +}); diff --git a/src/logging/diagnostic-session-attention.ts b/src/logging/diagnostic-session-attention.ts new file mode 100644 index 00000000000..31a9b53a969 --- /dev/null +++ b/src/logging/diagnostic-session-attention.ts @@ -0,0 +1,70 @@ +import type { DiagnosticSessionActiveWorkKind } from "../infra/diagnostic-events.js"; +import type { DiagnosticSessionActivitySnapshot } from "./diagnostic-run-activity.js"; + +export type SessionAttentionClassification = + | { + eventType: "session.long_running"; + reason: string; + classification: "long_running"; + activeWorkKind?: DiagnosticSessionActiveWorkKind; + recoveryEligible: false; + } + | { + eventType: "session.stalled"; + reason: string; + classification: "blocked_tool_call" | "stalled_agent_run"; + activeWorkKind?: DiagnosticSessionActiveWorkKind; + recoveryEligible: false; + } + | { + eventType: "session.stuck"; + reason: string; + classification: "stale_session_state"; + activeWorkKind?: undefined; + recoveryEligible: true; + }; + +export function classifySessionAttention(params: { + queueDepth: number; + activity: DiagnosticSessionActivitySnapshot; + staleMs: number; +}): SessionAttentionClassification { + if (params.activity.activeWorkKind) { + if ( + params.activity.activeWorkKind === "tool_call" && + (params.activity.activeToolAgeMs ?? 0) > params.staleMs && + (params.activity.lastProgressAgeMs ?? 0) > params.staleMs + ) { + return { + eventType: "session.stalled", + reason: "blocked_tool_call", + classification: "blocked_tool_call", + activeWorkKind: params.activity.activeWorkKind, + recoveryEligible: false, + }; + } + if ((params.activity.lastProgressAgeMs ?? 0) > params.staleMs) { + return { + eventType: "session.stalled", + reason: "active_work_without_progress", + classification: "stalled_agent_run", + activeWorkKind: params.activity.activeWorkKind, + recoveryEligible: false, + }; + } + return { + eventType: "session.long_running", + reason: params.queueDepth > 0 ? "queued_behind_active_work" : "active_work", + classification: "long_running", + activeWorkKind: params.activity.activeWorkKind, + recoveryEligible: false, + }; + } + + return { + eventType: "session.stuck", + reason: params.queueDepth > 0 ? "queued_work_without_active_run" : "stale_session_state", + classification: "stale_session_state", + recoveryEligible: true, + }; +} diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index df563810223..23e5d913e7f 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -5,7 +5,6 @@ import { areDiagnosticsEnabledForProcess, emitDiagnosticEvent, isDiagnosticsEnabled, - type DiagnosticSessionActiveWorkKind, type DiagnosticLivenessWarningReason, } from "../infra/diagnostic-events.js"; import { emitDiagnosticMemorySample, resetDiagnosticMemoryForTest } from "./diagnostic-memory.js"; @@ -20,6 +19,10 @@ import { markDiagnosticActivity as markActivity, resetDiagnosticActivityForTest, } from "./diagnostic-runtime.js"; +import { + classifySessionAttention, + type SessionAttentionClassification, +} from "./diagnostic-session-attention.js"; import { diagnosticSessionStates, getDiagnosticSessionState, @@ -156,74 +159,6 @@ function hasRecentDiagnosticActivity(now: number): boolean { return lastActivityAt > 0 && now - lastActivityAt <= RECENT_DIAGNOSTIC_ACTIVITY_MS; } -type SessionAttentionClassification = - | { - eventType: "session.long_running"; - reason: string; - classification: "long_running"; - activeWorkKind?: DiagnosticSessionActiveWorkKind; - recoveryEligible: false; - } - | { - eventType: "session.stalled"; - reason: string; - classification: "blocked_tool_call" | "stalled_agent_run"; - activeWorkKind?: DiagnosticSessionActiveWorkKind; - recoveryEligible: false; - } - | { - eventType: "session.stuck"; - reason: string; - classification: "stale_session_state"; - activeWorkKind?: undefined; - recoveryEligible: true; - }; - -function classifySessionAttention(params: { - queueDepth: number; - activity: DiagnosticSessionActivitySnapshot; - staleMs: number; -}): SessionAttentionClassification { - if (params.activity.activeWorkKind) { - if ( - params.activity.activeWorkKind === "tool_call" && - (params.activity.activeToolAgeMs ?? 0) > params.staleMs && - (params.activity.lastProgressAgeMs ?? 0) > params.staleMs - ) { - return { - eventType: "session.stalled", - reason: "blocked_tool_call", - classification: "blocked_tool_call", - activeWorkKind: params.activity.activeWorkKind, - recoveryEligible: false, - }; - } - if ((params.activity.lastProgressAgeMs ?? 0) > params.staleMs) { - return { - eventType: "session.stalled", - reason: "active_work_without_progress", - classification: "stalled_agent_run", - activeWorkKind: params.activity.activeWorkKind, - recoveryEligible: false, - }; - } - return { - eventType: "session.long_running", - reason: params.queueDepth > 0 ? "queued_behind_active_work" : "active_work", - classification: "long_running", - activeWorkKind: params.activity.activeWorkKind, - recoveryEligible: false, - }; - } - - return { - eventType: "session.stuck", - reason: params.queueDepth > 0 ? "queued_work_without_active_run" : "stale_session_state", - classification: "stale_session_state", - recoveryEligible: true, - }; -} - function roundDiagnosticMetric(value: number, digits = 3): number { if (!Number.isFinite(value)) { return 0;