diff --git a/.changeset/pr1300-review-followups.md b/.changeset/pr1300-review-followups.md new file mode 100644 index 000000000..034bc7d88 --- /dev/null +++ b/.changeset/pr1300-review-followups.md @@ -0,0 +1,7 @@ +--- +"@aoagents/ao-core": patch +"@aoagents/ao-cli": patch +"@aoagents/ao-web": patch +--- + +Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, and adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels. diff --git a/packages/cli/__tests__/commands/report.test.ts b/packages/cli/__tests__/commands/report.test.ts new file mode 100644 index 000000000..7d3d5e6ba --- /dev/null +++ b/packages/cli/__tests__/commands/report.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import type * as CoreModule from "@aoagents/ao-core"; + +const { mockConfigRef, mockSessionManager, mockApplyAgentReport, mockGetSessionsDir } = vi.hoisted( + () => ({ + mockConfigRef: { current: null as Record | null }, + mockSessionManager: { + get: vi.fn(), + }, + mockApplyAgentReport: vi.fn(), + mockGetSessionsDir: vi.fn(), + }), +); + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = (await importOriginal()) as typeof CoreModule; + return { + ...actual, + loadConfig: () => mockConfigRef.current, + getSessionsDir: (...args: unknown[]) => mockGetSessionsDir(...args), + applyAgentReport: (...args: unknown[]) => mockApplyAgentReport(...args), + }; +}); + +vi.mock("../../src/lib/create-session-manager.js", () => ({ + getSessionManager: async () => mockSessionManager, +})); + +import { registerAcknowledge, registerReport } from "../../src/commands/report.js"; + +describe("report commands", () => { + let program: Command; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let exitSpy: ReturnType; + const originalEnv = { ...process.env }; + + beforeEach(() => { + program = new Command(); + program.exitOverride(); + registerAcknowledge(program); + registerReport(program); + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + + process.env = { ...originalEnv }; + delete process.env["AO_SESSION_ID"]; + process.env["USER"] = "codex"; + + mockConfigRef.current = { + configPath: "/tmp/agent-orchestrator.yaml", + projects: { + app: { + name: "app", + path: "/tmp/app", + }, + }, + }; + mockSessionManager.get.mockReset(); + mockApplyAgentReport.mockReset(); + mockGetSessionsDir.mockReset(); + mockGetSessionsDir.mockReturnValue("/tmp/sessions"); + mockSessionManager.get.mockResolvedValue({ + id: "app-1", + projectId: "app", + }); + mockApplyAgentReport.mockReturnValue({ + previousState: "working", + nextState: "started", + }); + }); + + afterEach(() => { + process.env = originalEnv; + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("uses AO_SESSION_ID for acknowledge when no explicit session is provided", async () => { + process.env["AO_SESSION_ID"] = "app-1"; + + await program.parseAsync(["node", "test", "acknowledge", "--note", "picked up"]); + + expect(mockSessionManager.get).toHaveBeenCalledWith("app-1"); + expect(mockApplyAgentReport).toHaveBeenCalledWith( + "/tmp/sessions", + "app-1", + expect.objectContaining({ + state: "started", + note: "picked up", + source: "acknowledge", + actor: "codex", + }), + ); + }); + + it("prefers explicit --session over AO_SESSION_ID", async () => { + process.env["AO_SESSION_ID"] = "wrong-session"; + + await program.parseAsync(["node", "test", "report", "working", "--session", "app-2"]); + + expect(mockSessionManager.get).toHaveBeenCalledWith("app-2"); + }); + + it("rejects unknown states before touching the session manager", async () => { + await expect(program.parseAsync(["node", "test", "report", "bogus-state"])).rejects.toThrow( + "process.exit(1)", + ); + + expect(mockSessionManager.get).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown state")); + }); + + it("rejects invalid PR numbers", async () => { + await expect( + program.parseAsync(["node", "test", "report", "pr-created", "--pr-number", "abc"]), + ).rejects.toThrow("process.exit(1)"); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid PR number")); + }); + + it("rejects PR metadata flags for non-PR workflow states", async () => { + await expect( + program.parseAsync(["node", "test", "report", "working", "--pr-url", "https://example.com"]), + ).rejects.toThrow("process.exit(1)"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("PR metadata flags are only valid"), + ); + }); + + it("surfaces session-not-found errors", async () => { + mockSessionManager.get.mockResolvedValue(null); + + await expect(program.parseAsync(["node", "test", "report", "working", "--session", "app-1"])) + .rejects.toThrow("process.exit(1)"); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Session not found")); + }); + + it("surfaces project-not-found errors", async () => { + mockConfigRef.current = { + configPath: "/tmp/agent-orchestrator.yaml", + projects: {}, + }; + + await expect(program.parseAsync(["node", "test", "report", "working", "--session", "app-1"])) + .rejects.toThrow("process.exit(1)"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Project not found for session"), + ); + }); + + it("surfaces rejected reports from applyAgentReport", async () => { + mockApplyAgentReport.mockImplementation(() => { + throw new Error("PR number 7 does not match PR URL"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "report", + "pr-created", + "--session", + "app-1", + "--pr-number", + "7", + "--pr-url", + "https://github.com/acme/app/pull/9", + ]), + ).rejects.toThrow("process.exit(1)"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Report rejected: PR number 7 does not match PR URL"), + ); + }); +}); diff --git a/packages/core/src/__tests__/activity-signal.test.ts b/packages/core/src/__tests__/activity-signal.test.ts new file mode 100644 index 000000000..301a42287 --- /dev/null +++ b/packages/core/src/__tests__/activity-signal.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + ACTIVITY_STRONG_WINDOW_MS, + ACTIVITY_WEAK_WINDOW_MS, + classifyActivitySignal, + createActivitySignal, + hasPositiveIdleEvidence, + isWeakActivityEvidence, + summarizeActivityFreshness, + supportsRecentLiveness, +} from "../activity-signal.js"; + +describe("summarizeActivityFreshness", () => { + const now = new Date("2025-01-01T12:00:00.000Z"); + + it("returns none without a timestamp", () => { + expect(summarizeActivityFreshness(undefined, now)).toBe("none"); + }); + + it("classifies exact strong and weak boundaries correctly", () => { + expect( + summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_STRONG_WINDOW_MS), now), + ).toBe("strong"); + expect( + summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS), now), + ).toBe("weak"); + }); + + it("treats future timestamps as strong instead of going negative", () => { + expect(summarizeActivityFreshness(new Date("2025-01-01T12:01:00.000Z"), now)).toBe("strong"); + }); + + it("returns stale when the timestamp is older than the weak window", () => { + expect( + summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS - 1), now), + ).toBe("stale"); + }); +}); + +describe("classifyActivitySignal", () => { + const now = new Date("2025-01-01T12:00:00.000Z"); + + it("keeps fresh active activity as valid", () => { + expect( + classifyActivitySignal( + { + state: "active", + timestamp: new Date("2025-01-01T11:59:30.000Z"), + }, + "native", + now, + ), + ).toEqual({ + state: "valid", + activity: "active", + timestamp: new Date("2025-01-01T11:59:30.000Z"), + source: "native", + detail: undefined, + }); + }); + + it("marks idle-without-timestamp as stale missing_timestamp evidence", () => { + expect(classifyActivitySignal({ state: "idle" }, "native", now)).toEqual({ + state: "stale", + activity: "idle", + timestamp: undefined, + source: "native", + detail: "missing_timestamp", + }); + }); + + it("marks blocked-without-timestamp as stale missing_timestamp evidence", () => { + expect(classifyActivitySignal({ state: "blocked" }, "terminal", now)).toEqual({ + state: "stale", + activity: "blocked", + timestamp: undefined, + source: "terminal", + detail: "missing_timestamp", + }); + }); + + it("keeps active-without-timestamp valid because it is positive liveness evidence", () => { + expect(classifyActivitySignal({ state: "active" }, "native", now).state).toBe("valid"); + }); + + it("marks stale active evidence as stale_timestamp", () => { + expect( + classifyActivitySignal( + { + state: "active", + timestamp: new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS - 1), + }, + "native", + now, + ), + ).toMatchObject({ + state: "stale", + activity: "active", + detail: "stale_timestamp", + }); + }); + + it("keeps exited activity valid even without timestamp", () => { + expect(classifyActivitySignal({ state: "exited" }, "runtime", now)).toEqual({ + state: "valid", + activity: "exited", + timestamp: undefined, + source: "runtime", + detail: undefined, + }); + }); +}); + +describe("activity signal helpers", () => { + const now = new Date("2025-01-01T12:00:00.000Z"); + + it("detects positive idle evidence only for valid idle states with timestamps", () => { + expect( + hasPositiveIdleEvidence( + createActivitySignal("valid", { + activity: "idle", + timestamp: new Date("2025-01-01T11:59:00.000Z"), + source: "native", + }), + ), + ).toBe(true); + expect( + hasPositiveIdleEvidence(createActivitySignal("valid", { activity: "idle", source: "native" })), + ).toBe(false); + expect( + hasPositiveIdleEvidence( + createActivitySignal("stale", { + activity: "idle", + timestamp: new Date("2025-01-01T11:59:00.000Z"), + source: "native", + }), + ), + ).toBe(false); + }); + + it("supports recent liveness only for valid active/ready timestamps within the weak window", () => { + expect( + supportsRecentLiveness( + createActivitySignal("valid", { + activity: "ready", + timestamp: new Date("2025-01-01T11:56:00.000Z"), + source: "native", + }), + now, + ), + ).toBe(true); + expect( + supportsRecentLiveness( + createActivitySignal("valid", { + activity: "active", + timestamp: new Date("2025-01-01T11:54:59.999Z"), + source: "native", + }), + now, + ), + ).toBe(false); + }); + + it("treats all non-valid signals as weak evidence", () => { + expect(isWeakActivityEvidence(createActivitySignal("valid", { activity: "active" }))).toBe( + false, + ); + expect(isWeakActivityEvidence(createActivitySignal("stale", { activity: "active" }))).toBe( + true, + ); + expect(isWeakActivityEvidence(createActivitySignal("unavailable"))).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/agent-report.test.ts b/packages/core/src/__tests__/agent-report.test.ts index 94d1309ad..61a8269ef 100644 --- a/packages/core/src/__tests__/agent-report.test.ts +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS, AGENT_REPORTED_STATES, AGENT_REPORT_FRESHNESS_MS, AGENT_REPORT_METADATA_KEYS, @@ -265,6 +266,24 @@ describe("applyAgentReport", () => { expect(payload.pr.url).toBe("https://github.com/test/repo/pull/42"); }); + it("does not invent an open PR without a URL or number", () => { + const now = new Date("2025-01-02T09:35:00.000Z"); + + const result = applyAgentReport(dataDir, sessionId, { + state: "pr_created", + now, + }); + + expect(result.legacyStatus).toBe("idle"); + + const meta = readMetadataRaw(dataDir, sessionId)!; + const payload = JSON.parse(meta["statePayload"]); + expect(payload.pr.state).toBe("none"); + expect(payload.pr.reason).toBe("not_created"); + expect(meta[AGENT_REPORT_METADATA_KEYS.PR_URL]).toBeUndefined(); + expect(meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]).toBeUndefined(); + }); + it("keeps draft PR creation in working and marks the report as draft", () => { const now = new Date("2025-01-02T10:00:00.000Z"); const result = applyAgentReport(dataDir, sessionId, { @@ -502,12 +521,24 @@ describe("readAgentReport + isAgentReportFresh", () => { expect(isAgentReportFresh(stale, now)).toBe(false); }); - it("rejects future timestamps (clock skew must not appear forever-fresh)", () => { + it("accepts small future skew inside the tolerance window", () => { + const now = new Date("2025-01-01T12:00:00.000Z"); + const slightlyFuture = readAgentReport({ + [AGENT_REPORT_METADATA_KEYS.STATE]: "working", + [AGENT_REPORT_METADATA_KEYS.AT]: new Date( + now.getTime() + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS - 1, + ).toISOString(), + })!; + expect(isAgentReportFresh(slightlyFuture, now)).toBe(true); + }); + + it("rejects future timestamps outside the skew tolerance", () => { const now = new Date("2025-01-01T12:00:00.000Z"); - const futureAt = "2025-01-01T12:10:00.000Z"; // 10m in the future const future = readAgentReport({ [AGENT_REPORT_METADATA_KEYS.STATE]: "working", - [AGENT_REPORT_METADATA_KEYS.AT]: futureAt, + [AGENT_REPORT_METADATA_KEYS.AT]: new Date( + now.getTime() + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS + 1, + ).toISOString(), })!; expect(isAgentReportFresh(future, now)).toBe(false); }); diff --git a/packages/core/src/__tests__/recovery-validator.test.ts b/packages/core/src/__tests__/recovery-validator.test.ts index 2a666c11d..5a99cbf6b 100644 --- a/packages/core/src/__tests__/recovery-validator.test.ts +++ b/packages/core/src/__tests__/recovery-validator.test.ts @@ -311,6 +311,99 @@ describe("recovery validator", () => { expect(assessment.reason).toContain("Signal disagreement"); }); + it("uses agent activity as liveness fallback when process probing says not running", async () => { + rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + const projectPath = join(rootDir, "project"); + mkdirSync(projectPath, { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const mockRuntime: Runtime = { + name: "tmux", + create: vi.fn(), + destroy: vi.fn(), + sendMessage: vi.fn(), + getOutput: vi.fn(), + isAlive: vi.fn().mockResolvedValue(true), + }; + const mockWorkspace: Workspace = { + name: "worktree", + create: vi.fn(), + destroy: vi.fn(), + list: vi.fn(), + exists: vi.fn().mockResolvedValue(true), + }; + const mockAgent: Agent = { + name: "mock-agent", + processName: "mock-agent", + getLaunchCommand: vi.fn(), + getEnvironment: vi.fn(), + detectActivity: vi.fn(), + getActivityState: vi.fn().mockResolvedValue({ state: "active" }), + isProcessRunning: vi.fn().mockResolvedValue(false), + getSessionInfo: vi.fn(), + }; + const registry: PluginRegistry = { + register: vi.fn(), + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "workspace") return mockWorkspace; + if (slot === "agent") return mockAgent; + return null; + }), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; + const config: OrchestratorConfig = { + configPath: join(rootDir, "agent-orchestrator.yaml"), + port: 3000, + readyThresholdMs: 300_000, + power: { preventIdleSleep: false }, + defaults: { + runtime: "tmux", + agent: "mock-agent", + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + app: { + name: "app", + repo: "org/repo", + path: projectPath, + defaultBranch: "main", + sessionPrefix: "app", + }, + }, + notifiers: {}, + notificationRouting: { + urgent: ["desktop"], + action: ["desktop"], + warning: [], + info: [], + }, + reactions: {}, + }; + const scanned: ScannedSession = { + sessionId: "app-1", + projectId: "app", + project: config.projects.app, + sessionsDir: getSessionsDir(config.configPath, projectPath), + rawMetadata: { + worktree: projectPath, + status: "working", + runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }), + }, + }; + + const assessment = await validateSession(scanned, config, registry); + + expect(mockAgent.getActivityState).toHaveBeenCalled(); + expect(assessment.agentActivity).toBe("active"); + expect(assessment.agentProcessRunning).toBe(true); + expect(assessment.classification).toBe("live"); + }); + it("keeps terminal metadata unrecoverable even when probes are uncertain", async () => { rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`); mkdirSync(rootDir, { recursive: true }); diff --git a/packages/core/src/__tests__/report-watcher.test.ts b/packages/core/src/__tests__/report-watcher.test.ts index 25fd6a6fb..29542de0f 100644 --- a/packages/core/src/__tests__/report-watcher.test.ts +++ b/packages/core/src/__tests__/report-watcher.test.ts @@ -199,7 +199,7 @@ describe("checkBlockedAgent", () => { expect(result?.message).toContain("Need API key"); }); - it("ignores stale reports", () => { + it("keeps needs_input visible even after the freshness window", () => { const now = new Date(); const oldReportTime = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago const session = createMockSession(); @@ -209,7 +209,8 @@ describe("checkBlockedAgent", () => { }; const result = checkBlockedAgent(session, report, now, config); - expect(result).toBeNull(); + expect(result?.trigger).toBe("agent_needs_input"); + expect(result?.timeSinceReportMs).toBeGreaterThan(0); }); }); diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts index 961ece001..747930be9 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -115,6 +115,7 @@ export const AGENT_REPORT_METADATA_KEYS = { /** Freshness window — agent reports older than this are ignored. */ export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes +export const AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS = 60_000; // 60 seconds /** * CLI surface accepts these hyphen/underscore aliases for convenience. @@ -432,9 +433,16 @@ export function applyAgentReport( const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl; const effectivePrNumber = prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number; - current.pr.state = "open"; - current.pr.reason = input.state === "ready_for_review" ? "review_pending" : "in_progress"; - current.pr.lastObservedAt = now; + const canAdvancePrState = + effectivePrUrl !== undefined || + effectivePrNumber !== undefined || + current.pr.state !== "none"; + if (canAdvancePrState) { + current.pr.state = "open"; + current.pr.reason = + input.state === "ready_for_review" ? "review_pending" : "in_progress"; + current.pr.lastObservedAt = now; + } if (effectivePrUrl) { current.pr.url = effectivePrUrl; } @@ -550,10 +558,11 @@ export function isAgentReportFresh( report: AgentReport, now: Date = new Date(), windowMs: number = AGENT_REPORT_FRESHNESS_MS, + clockSkewToleranceMs: number = AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS, ): boolean { const reportedAt = Date.parse(report.timestamp); if (Number.isNaN(reportedAt)) return false; const currentTime = now.getTime(); - if (reportedAt > currentTime) return false; + if (reportedAt > currentTime + clockSkewToleranceMs) return false; return currentTime - reportedAt <= windowMs; } diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 3c7833fb6..b11844016 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -40,6 +40,7 @@ import { import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; import { updateMetadata } from "./metadata.js"; import { getSessionsDir } from "./paths.js"; +import { applyDecisionToLifecycle as commitLifecycleDecisionInPlace } from "./lifecycle-transition.js"; import { classifyActivitySignal, createActivitySignal, @@ -235,31 +236,6 @@ interface ProbeResult { state: "alive" | "dead" | "unknown"; failed: boolean; } -function applyLifecycleDecision( - lifecycle: CanonicalSessionLifecycle, - decision: LifecycleDecision, - nowIso: string, -): void { - if (decision.prState && decision.prReason) { - lifecycle.pr.state = decision.prState; - lifecycle.pr.reason = decision.prReason; - } - - if (decision.sessionState && decision.sessionReason) { - lifecycle.session.state = decision.sessionState; - lifecycle.session.reason = decision.sessionReason; - lifecycle.session.lastTransitionAt = nowIso; - if (decision.sessionState === "working" && lifecycle.session.startedAt === null) { - lifecycle.session.startedAt = nowIso; - } - if (decision.sessionState === "done") { - lifecycle.session.completedAt = nowIso; - } - if (decision.sessionState === "terminated") { - lifecycle.session.terminatedAt = nowIso; - } - } -} function splitEvidenceSignals(evidence: string): string[] { return evidence @@ -536,7 +512,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan detectingAttempts: currentDetectingAttempts, }, ): DeterminedStatus => { - applyLifecycleDecision(lifecycle, decision, nowIso); + commitLifecycleDecisionInPlace(lifecycle, decision, nowIso); session.lifecycle = lifecycle; session.status = decision.status; session.activitySignal = activitySignal; @@ -1848,7 +1824,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }); // Execute reaction if configured - if (reactionConfig && reactionConfig.auto !== false) { + if (isNewTrigger && reactionConfig && reactionConfig.auto !== false) { await executeReaction(session.id, session.projectId, reactionKey, reactionConfig); } } diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts index df97eb8f6..c06725997 100644 --- a/packages/core/src/recovery/validator.ts +++ b/packages/core/src/recovery/validator.ts @@ -9,6 +9,7 @@ import { type RuntimeHandle, type SessionStatus, type ActivityState, + type Session, } from "../types.js"; import { safeJsonParse, validateStatus } from "../utils/validation.js"; import type { ScannedSession } from "./scanner.js"; @@ -20,6 +21,18 @@ import { type RecoveryConfig, } from "./types.js"; import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.js"; +import { createInitialCanonicalLifecycle } from "../lifecycle-state.js"; +import { createActivitySignal } from "../activity-signal.js"; + +function indicatesLiveAgentActivity(activity: ActivityState | null): boolean { + return ( + activity === "active" || + activity === "ready" || + activity === "idle" || + activity === "waiting_input" || + activity === "blocked" + ); +} export async function validateSession( scanned: ScannedSession, @@ -86,7 +99,7 @@ export async function validateSession( let agentProcessRunning = false; let processProbeSucceeded = false; - const agentActivity: ActivityState | null = null; + let agentActivity: ActivityState | null = null; if (agent && runtimeHandle) { try { agentProcessRunning = await agent.isProcessRunning(runtimeHandle); @@ -95,6 +108,37 @@ export async function validateSession( agentProcessRunning = false; processProbeSucceeded = false; } + + try { + const lifecycle = createInitialCanonicalLifecycle("worker"); + const probeSession: Session = { + id: sessionId, + projectId, + status: metadataStatus, + activity: null, + activitySignal: createActivitySignal("unavailable"), + lifecycle, + branch: rawMetadata["branch"] ?? null, + issueId: rawMetadata["issue"] ?? null, + pr: null, + workspacePath, + runtimeHandle, + agentInfo: null, + createdAt: new Date(rawMetadata["createdAt"] ?? Date.now()), + lastActivityAt: new Date(rawMetadata["lastActivityAt"] ?? Date.now()), + metadata: rawMetadata, + }; + const detection = await agent.getActivityState(probeSession, config.readyThresholdMs); + agentActivity = detection?.state ?? null; + if (!agentProcessRunning && indicatesLiveAgentActivity(agentActivity)) { + agentProcessRunning = true; + } + if (agentActivity === "exited") { + agentProcessRunning = false; + } + } catch { + agentActivity = null; + } } const metadataValid = Object.keys(rawMetadata).length > 0; diff --git a/packages/core/src/report-watcher.ts b/packages/core/src/report-watcher.ts index 7f86cb533..727329df2 100644 --- a/packages/core/src/report-watcher.ts +++ b/packages/core/src/report-watcher.ts @@ -10,7 +10,7 @@ */ import type { Session, SessionStatus } from "./types.js"; -import { readAgentReport, isAgentReportFresh, type AgentReport } from "./agent-report.js"; +import { readAgentReport, type AgentReport } from "./agent-report.js"; /** * Report watcher trigger types. @@ -174,15 +174,15 @@ export function checkBlockedAgent( // If no report, nothing to check if (!report) return null; - // Only check fresh reports (don't re-trigger on old blocked states) - if (!isAgentReportFresh(report, now)) return null; - if (report.state === "needs_input") { + const reportTime = Date.parse(report.timestamp); + const timeSinceReportMs = Number.isNaN(reportTime) ? undefined : now.getTime() - reportTime; return { trigger: "agent_needs_input", message: `Agent needs input: ${report.note ?? "waiting for user decision"}`, checkedAt: now.toISOString(), report, + timeSinceReportMs, }; } diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index c618db86d..150fa8e16 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -1,15 +1,23 @@ -import type { CSSProperties, ReactNode } from "react"; import type { Metadata, Viewport } from "next"; +import type { ReactNode } from "react"; +import { Geist, JetBrains_Mono } from "next/font/google"; import { getProjectName } from "@/lib/project-name"; import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import { Providers } from "@/app/providers"; import "./globals.css"; -const rootFontVariables = { - "--font-geist-sans": '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - "--font-jetbrains-mono": - '"SF Mono", "JetBrains Mono", "Menlo", "Consolas", "Liberation Mono", monospace', -} as CSSProperties; +const geistSans = Geist({ + subsets: ["latin"], + variable: "--font-geist-sans", + display: "swap", +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-jetbrains-mono", + display: "swap", + weight: ["400", "500"], +}); export const viewport: Viewport = { width: "device-width", @@ -41,8 +49,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 0ade1279c..4e8ee42b9 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -4,23 +4,13 @@ import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } fro import { useSearchParams } from "next/navigation"; import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery"; import { - type DashboardAgentReportAuditEntry, type DashboardSession, type DashboardPR, NON_RESTORABLE_STATUSES, + getActivitySignalLabel, isPRMergeReady, isPRRateLimited, isPRUnenriched, - getSessionTruthLabel, - getSessionTruthReasonLabel, - getPRTruthLabel, - getPRTruthReasonLabel, - getRuntimeTruthLabel, - getRuntimeTruthReasonLabel, - getLifecycleGuidance, - getLifecycleEvidence, - getActivitySignalLabel, - getActivitySignalReasonLabel, isDashboardRuntimeEnded, isDashboardSessionRestorable, } from "@/lib/types"; @@ -32,6 +22,8 @@ import type { ProjectInfo } from "@/lib/project-name"; import { MobileBottomNav } from "./MobileBottomNav"; import { ProjectSidebar } from "./ProjectSidebar"; +import { SessionTruthPanel } from "./SessionTruthPanel"; +import { SessionReportAuditPanel } from "./SessionReportAuditPanel"; const DirectTerminal = dynamic( () => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })), @@ -64,18 +56,6 @@ interface SessionDetailProps { onRetrySidebar?: () => void; } -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 { @@ -152,209 +132,6 @@ function activityStateClass(activityLabel: string): string { return "session-detail-status-pill--neutral"; } -function SessionTruthPanel({ session }: { session: DashboardSession }) { - if (!session.lifecycle) return null; - - const facts = [ - { - heading: "Activity", - label: getActivitySignalLabel(session), - reason: getActivitySignalReasonLabel(session), - }, - { - 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 formatAuditTimestamp(isoDate: string): string { - const parsed = Date.parse(isoDate); - if (Number.isNaN(parsed)) return isoDate; - return new Date(parsed).toLocaleString(); -} - -function getAuditCommandLabel(entry: DashboardAgentReportAuditEntry): string { - return entry.source === "acknowledge" ? "ao acknowledge" : `ao report ${entry.reportState}`; -} - -function SessionReportAuditPanel({ - sessionId, - entries, -}: { - sessionId: string; - entries: DashboardAgentReportAuditEntry[]; -}) { - const [isExpanded, setIsExpanded] = useState(true); - - if (entries.length === 0) { - return null; - } - - return ( -
- - {isExpanded ? ( -
- {entries.map((entry, index) => ( -
-
- - {entry.accepted ? "Accepted" : "Rejected"} - - - {formatAuditTimestamp(entry.timestamp)} - -
-
-
-
- Session -
-
- {sessionId} -
-
-
-
- Actor -
-
{entry.actor}
-
-
-
- Source -
-
- {getAuditCommandLabel(entry)} -
-
-
-
- {entry.before.legacyStatus} / {entry.before.sessionState} - {" -> "} - {entry.after.legacyStatus} / {entry.after.sessionState} -
- {entry.note ? ( -

- Note: {entry.note} -

- ) : null} - {entry.rejectionReason ? ( -

- Rejection: {entry.rejectionReason} -

- ) : null} -
- ))} -
- ) : null} -
- ); -} - function SessionTopStrip({ headline, crumbId, diff --git a/packages/web/src/components/SessionReportAuditPanel.tsx b/packages/web/src/components/SessionReportAuditPanel.tsx new file mode 100644 index 000000000..b98d7ed04 --- /dev/null +++ b/packages/web/src/components/SessionReportAuditPanel.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/cn"; +import type { DashboardAgentReportAuditEntry } from "@/lib/types"; + +const RECENT_AUDIT_LIMIT = 10; + +function formatAuditTimestamp(isoDate: string): string { + const parsed = Date.parse(isoDate); + if (Number.isNaN(parsed)) return isoDate; + return new Date(parsed).toLocaleString(); +} + +function getAuditCommandLabel(entry: DashboardAgentReportAuditEntry): string { + return entry.source === "acknowledge" ? "ao acknowledge" : `ao report ${entry.reportState}`; +} + +export function SessionReportAuditPanel({ + sessionId, + entries, +}: { + sessionId: string; + entries: DashboardAgentReportAuditEntry[]; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const [showOlderEntries, setShowOlderEntries] = useState(false); + const panelId = `${sessionId}-report-audit-panel`; + const hasOlderEntries = entries.length > RECENT_AUDIT_LIMIT; + const visibleEntries = + showOlderEntries || !hasOlderEntries ? entries : entries.slice(0, RECENT_AUDIT_LIMIT); + + if (entries.length === 0) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/packages/web/src/components/SessionTruthPanel.tsx b/packages/web/src/components/SessionTruthPanel.tsx new file mode 100644 index 000000000..fde420f02 --- /dev/null +++ b/packages/web/src/components/SessionTruthPanel.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { + getActivitySignalLabel, + getActivitySignalReasonLabel, + getLifecycleEvidence, + getLifecycleGuidance, + getPRTruthLabel, + getPRTruthReasonLabel, + getRuntimeTruthLabel, + getRuntimeTruthReasonLabel, + getSessionTruthLabel, + getSessionTruthReasonLabel, + type DashboardSession, +} from "@/lib/types"; + +function getActivityFactToneClass(session: DashboardSession): string { + if (!session.activitySignal || session.activitySignal.state !== "valid") { + return "text-[var(--color-text-secondary)]"; + } + + switch (session.activitySignal.activity) { + case "active": + return "text-[var(--color-status-working)]"; + case "ready": + return "text-[var(--color-status-ready)]"; + case "idle": + return "text-[var(--color-status-idle)]"; + case "waiting_input": + return "text-[var(--color-status-attention)]"; + case "blocked": + case "exited": + return "text-[var(--color-status-error)]"; + default: + return "text-[var(--color-text-secondary)]"; + } +} + +function getSessionFactToneClass(session: DashboardSession): string { + switch (session.lifecycle?.session.state) { + case "detecting": + case "needs_input": + case "stuck": + return "text-[var(--color-status-attention)]"; + case "terminated": + return "text-[var(--color-status-error)]"; + case "done": + return "text-[var(--color-status-ready)]"; + case "working": + return "text-[var(--color-status-working)]"; + case "idle": + return "text-[var(--color-status-pending)]"; + default: + return "text-[var(--color-text-secondary)]"; + } +} + +function getPrFactToneClass(session: DashboardSession): string { + switch (session.lifecycle?.pr.state) { + case "merged": + return "text-[var(--color-status-ready)]"; + case "closed": + return "text-[var(--color-status-error)]"; + case "open": + return "text-[var(--color-accent)]"; + default: + return "text-[var(--color-text-secondary)]"; + } +} + +function getRuntimeFactToneClass(session: DashboardSession): string { + switch (session.lifecycle?.runtime.state) { + case "alive": + return "text-[var(--color-status-ready)]"; + case "probe_failed": + return "text-[var(--color-status-attention)]"; + case "missing": + case "exited": + return "text-[var(--color-status-error)]"; + default: + return "text-[var(--color-text-secondary)]"; + } +} + +export function SessionTruthPanel({ session }: { session: DashboardSession }) { + if (!session.lifecycle) return null; + + const facts = [ + { + heading: "Activity", + label: getActivitySignalLabel(session), + reason: getActivitySignalReasonLabel(session), + toneClassName: getActivityFactToneClass(session), + }, + { + heading: "Session", + label: getSessionTruthLabel(session), + reason: getSessionTruthReasonLabel(session), + toneClassName: getSessionFactToneClass(session), + }, + { + heading: "PR", + label: getPRTruthLabel(session), + reason: getPRTruthReasonLabel(session), + toneClassName: getPrFactToneClass(session), + }, + { + heading: "Runtime", + label: getRuntimeTruthLabel(session), + reason: getRuntimeTruthReasonLabel(session), + toneClassName: getRuntimeFactToneClass(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} +
+ ); +} diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index f928540f5..911ced5c6 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -149,8 +149,9 @@ describe("SessionDetail desktop layout", () => { expect(screen.getByText("The empty state text needs to be shorter.")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Agent Reports/i })).toHaveAttribute( "aria-expanded", - "true", + "false", ); + fireEvent.click(screen.getByRole("button", { name: /Agent Reports/i })); expect(screen.getAllByText("worker-desktop").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("ao report working")).toBeInTheDocument(); expect(screen.getByText("codex")).toBeInTheDocument(); @@ -189,15 +190,15 @@ describe("SessionDetail desktop layout", () => { ); const toggle = screen.getByRole("button", { name: /Agent Reports/i }); - expect(screen.getByText("ao acknowledge")).toBeInTheDocument(); - - fireEvent.click(toggle); - expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(screen.queryByText("ao acknowledge")).not.toBeInTheDocument(); + expect(screen.getByText("ao acknowledge")).not.toBeVisible(); fireEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "true"); expect(screen.getByText("ao acknowledge")).toBeInTheDocument(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByText("ao acknowledge")).not.toBeVisible(); }); it("sends unresolved comments back to the agent and shows sent state", async () => { diff --git a/packages/web/src/lib/__tests__/types.test.ts b/packages/web/src/lib/__tests__/types.test.ts index 3083a3906..a7f5dca55 100644 --- a/packages/web/src/lib/__tests__/types.test.ts +++ b/packages/web/src/lib/__tests__/types.test.ts @@ -611,6 +611,47 @@ describe("getAttentionLevel", () => { }); expect(getAttentionLevel(session)).toBe("working"); }); + + it("should keep idle sessions with open PRs and in-flight CI in working", () => { + const session = createSession({ + status: "working", + lifecycle: { + sessionState: "idle", + sessionReason: "task_in_progress", + prState: "open", + prReason: "in_progress", + runtimeState: "alive", + runtimeReason: "process_running", + session: { + state: "idle", + reason: "task_in_progress", + label: "idle", + reasonLabel: "task in progress", + }, + pr: { + state: "open", + reason: "in_progress", + label: "open", + reasonLabel: "in progress", + }, + runtime: { + state: "alive", + reason: "process_running", + label: "alive", + reasonLabel: "process running", + }, + legacyStatus: "working", + evidence: null, + detectingAttempts: 0, + detectingEscalatedAt: null, + summary: "Session idle while CI is still running", + guidance: null, + }, + pr: null, + }); + + expect(getAttentionLevel(session)).toBe("working"); + }); }); describe("working state", () => { diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index a9eab69e7..07f484cc6 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -535,8 +535,8 @@ function getDetailedAttentionLevel(session: DashboardSession): AttentionLevel { // ── Pending: waiting on external (reviewer, CI) ─────────────────── if ( - session.lifecycle?.sessionState === "idle" || session.lifecycle?.prReason === "review_pending" || + session.lifecycle?.prReason === "closed_unmerged" || session.status === "review_pending" ) { return "pending";