diff --git a/.changeset/lifecycle-activity-signal-contract.md b/.changeset/lifecycle-activity-signal-contract.md new file mode 100644 index 000000000..2779b0c9b --- /dev/null +++ b/.changeset/lifecycle-activity-signal-contract.md @@ -0,0 +1,5 @@ +"@aoagents/ao-core": patch +"@aoagents/ao-web": patch +--- + +Model activity evidence explicitly across lifecycle inference and dashboard rendering so missing or failed probes cannot spuriously produce idle or stuck interpretations. This also stabilizes repeated polls by preserving stronger prior lifecycle states when the only new evidence is weak or unavailable. diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 5c3521795..5666380da 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import type { Command } from "commander"; import { createInitialCanonicalLifecycle, + createActivitySignal, type Agent, type SCM, type Session, @@ -488,6 +489,7 @@ async function showFallbackStatus(): Promise { projectId: "", status: "working", activity: null, + activitySignal: createActivitySignal("unavailable"), lifecycle, branch: null, issueId: null, diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 5869af993..e8c103617 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -138,8 +138,8 @@ describe("check (single session)", () => { previousRuntimeState: "alive", newRuntimeState: "alive", primaryReason: "task_in_progress", - evidence: "activity:active", - signalsConsulted: ["activity:active"], + evidence: "activity_signal=valid via_native activity=active", + signalsConsulted: ["activity_signal=valid", "via_native", "activity=active"], recoveryAction: null, }); }); @@ -488,6 +488,60 @@ describe("check (single session)", () => { expect(lm.getStates().get("app-1")).toBe("working"); }); + it("does not mark a session stuck from terminal-only idle evidence without a timestamp", async () => { + config.reactions = { + "agent-stuck": { auto: true, action: "notify", threshold: "1m" }, + }; + + vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null); + vi.mocked(plugins.agent.detectActivity).mockReturnValue("idle"); + vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(true); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "working" }), + }); + + await lm.check("app-1"); + + expect(lm.getStates().get("app-1")).toBe("working"); + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=stale"); + expect(meta?.["lifecycleEvidence"]).toContain("activity=idle"); + }); + + it("does not treat stale activity as recent liveness evidence during runtime-loss detection", async () => { + vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false); + vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ + state: "active", + timestamp: new Date(Date.now() - 10 * 60_000), + }); + vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(false); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "working" }), + }); + + await lm.check("app-1"); + + expect(lm.getStates().get("app-1")).toBe("killed"); + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=stale"); + }); + + it("records explicit probe-failure activity evidence", async () => { + vi.mocked(plugins.agent.getActivityState).mockRejectedValue(new Error("boom")); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "working" }), + }); + + await lm.check("app-1"); + + expect(lm.getStates().get("app-1")).toBe("detecting"); + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=probe_failure"); + }); + it("detects needs_input from agent", async () => { vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ state: "waiting_input" }); @@ -621,6 +675,48 @@ describe("check (single session)", () => { expect(lm.getStates().get("app-1")).toBe("stuck"); }); + it("preserves needs_input state when getActivityState returns null with no terminal evidence", async () => { + vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null); + vi.mocked(plugins.runtime.getOutput).mockResolvedValue(""); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "needs_input" }), + }); + + await lm.check("app-1"); + expect(lm.getStates().get("app-1")).toBe("needs_input"); + }); + + it("preserves stuck state across repeated polls with unchanged weak evidence", async () => { + vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null); + vi.mocked(plugins.runtime.getOutput).mockResolvedValue(""); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "stuck" }), + }); + + await lm.check("app-1"); + expect(lm.getStates().get("app-1")).toBe("stuck"); + + await lm.check("app-1"); + expect(lm.getStates().get("app-1")).toBe("stuck"); + }); + + it("preserves needs_input across repeated polls with unchanged weak evidence", async () => { + vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null); + vi.mocked(plugins.runtime.getOutput).mockResolvedValue(""); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "needs_input" }), + }); + + await lm.check("app-1"); + expect(lm.getStates().get("app-1")).toBe("needs_input"); + + await lm.check("app-1"); + expect(lm.getStates().get("app-1")).toBe("needs_input"); + }); + it("detects PR states from SCM", async () => { const mockSCM = createMockSCM({ getCISummary: vi.fn().mockResolvedValue("failing") }); const registry = createMockRegistry({ diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index 21a8e4eea..0b9c397a2 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -315,6 +315,7 @@ describe("list", () => { // Should keep null (absent) when getActivityState fails expect(sessions[0].activity).toBeNull(); + expect(sessions[0].activitySignal.state).toBe("probe_failure"); }); it("keeps existing activity when getActivityState returns null", async () => { @@ -345,6 +346,36 @@ describe("list", () => { // null = "I don't know" — activity stays null (absent) expect(agentWithNull.getActivityState).toHaveBeenCalled(); expect(sessions[0].activity).toBeNull(); + expect(sessions[0].activitySignal.state).toBe("null"); + }); + + it("marks terminal fallback-free stale activity explicitly when timing is missing", async () => { + const agentWithIdleNoTimestamp: Agent = { + ...mockAgent, + getActivityState: vi.fn().mockResolvedValue({ state: "idle" }), + }; + const registryWithStaleSignal: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithIdleNoTimestamp; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "a", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: registryWithStaleSignal }); + const sessions = await sm.list(); + + expect(sessions[0].activity).toBe("idle"); + expect(sessions[0].activitySignal.state).toBe("stale"); }); it("updates lastActivityAt when detection timestamp is newer", async () => { diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index e1ccafd9f..8d98a43c7 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { getSessionsDir, getProjectBaseDir } from "../paths.js"; import { createInitialCanonicalLifecycle, deriveLegacyStatus } from "../lifecycle-state.js"; +import { createActivitySignal } from "../activity-signal.js"; import type { OrchestratorConfig, PluginRegistry, @@ -86,6 +87,11 @@ export function makeSession(overrides: Partial = {}): Session { projectId: "my-app", status: deriveLegacyStatus(lifecycle), activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), lifecycle, branch: "feat/test", issueId: null, diff --git a/packages/core/src/activity-signal.ts b/packages/core/src/activity-signal.ts new file mode 100644 index 000000000..95ed80aff --- /dev/null +++ b/packages/core/src/activity-signal.ts @@ -0,0 +1,125 @@ +import type { ActivityDetection, ActivitySignal, ActivitySignalSource, ActivityState } from "./types.js"; + +const TIMING_SENSITIVE_ACTIVITY_STATES: ReadonlySet = new Set([ + "active", + "ready", + "idle", + "blocked", +]); + +const LIVENESS_ACTIVITY_STATES: ReadonlySet = new Set(["active", "ready"]); +const IDLE_ACTIVITY_STATES: ReadonlySet = new Set(["idle", "blocked"]); + +export const ACTIVITY_STRONG_WINDOW_MS = 60_000; +export const ACTIVITY_WEAK_WINDOW_MS = 5 * 60_000; + +export function summarizeActivityFreshness( + timestamp: Date | undefined, + now: Date = new Date(), +): "strong" | "weak" | "stale" | "none" { + if (!timestamp) return "none"; + const ageMs = Math.max(0, now.getTime() - timestamp.getTime()); + if (ageMs <= ACTIVITY_STRONG_WINDOW_MS) return "strong"; + if (ageMs <= ACTIVITY_WEAK_WINDOW_MS) return "weak"; + return "stale"; +} + +export function createActivitySignal( + state: ActivitySignal["state"], + options: { + activity?: ActivityState | null; + timestamp?: Date; + source?: ActivitySignalSource; + detail?: string; + } = {}, +): ActivitySignal { + return { + state, + activity: options.activity ?? null, + timestamp: options.timestamp, + source: options.source ?? "none", + detail: options.detail, + }; +} + +export function classifyActivitySignal( + detection: ActivityDetection, + source: ActivitySignalSource, + now: Date = new Date(), +): ActivitySignal { + if (!TIMING_SENSITIVE_ACTIVITY_STATES.has(detection.state)) { + return createActivitySignal("valid", { + activity: detection.state, + timestamp: detection.timestamp, + source, + }); + } + + if (!detection.timestamp && IDLE_ACTIVITY_STATES.has(detection.state)) { + return createActivitySignal("stale", { + activity: detection.state, + source, + detail: "missing_timestamp", + }); + } + + if (!detection.timestamp) { + return createActivitySignal("valid", { + activity: detection.state, + source, + }); + } + + if ( + LIVENESS_ACTIVITY_STATES.has(detection.state) && + summarizeActivityFreshness(detection.timestamp, now) === "stale" + ) { + return createActivitySignal("stale", { + activity: detection.state, + timestamp: detection.timestamp, + source, + detail: "stale_timestamp", + }); + } + + return createActivitySignal("valid", { + activity: detection.state, + timestamp: detection.timestamp, + source, + }); +} + +export function hasPositiveIdleEvidence(signal: ActivitySignal): signal is ActivitySignal & { + activity: "idle" | "blocked"; + timestamp: Date; + state: "valid"; +} { + return ( + signal.state === "valid" && + signal.timestamp instanceof Date && + signal.activity !== null && + IDLE_ACTIVITY_STATES.has(signal.activity) + ); +} + +export function supportsRecentLiveness(signal: ActivitySignal, now: Date = new Date()): boolean { + return ( + signal.state === "valid" && + signal.timestamp instanceof Date && + signal.activity !== null && + LIVENESS_ACTIVITY_STATES.has(signal.activity) && + summarizeActivityFreshness(signal.timestamp, now) !== "stale" + ); +} + +export function isWeakActivityEvidence(signal: ActivitySignal): boolean { + return signal.state !== "valid"; +} + +export function formatActivitySignalEvidence(signal: ActivitySignal): string { + const source = signal.source === "none" ? "" : ` via_${signal.source}`; + const activity = signal.activity ? ` activity=${signal.activity}` : ""; + const timing = signal.timestamp ? ` at=${signal.timestamp.toISOString()}` : ""; + const detail = signal.detail ? ` detail=${signal.detail}` : ""; + return `activity_signal=${signal.state}${source}${activity}${timing}${detail}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c0897e8e..3069a95c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,6 +122,17 @@ export { classifyTerminalActivity, recordTerminalActivity, } from "./activity-log.js"; +export { + ACTIVITY_STRONG_WINDOW_MS, + ACTIVITY_WEAK_WINDOW_MS, + classifyActivitySignal, + createActivitySignal, + formatActivitySignalEvidence, + hasPositiveIdleEvidence, + isWeakActivityEvidence, + summarizeActivityFreshness, + supportsRecentLiveness, +} from "./activity-signal.js"; // Agent workspace hooks — shared PATH-wrapper setup for non-Claude agents export { diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 7245684e6..223f7c564 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -40,6 +40,14 @@ import { import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; import { updateMetadata } from "./metadata.js"; import { getSessionsDir } from "./paths.js"; +import { + classifyActivitySignal, + createActivitySignal, + formatActivitySignalEvidence, + hasPositiveIdleEvidence, + isWeakActivityEvidence, + supportsRecentLiveness, +} from "./activity-signal.js"; import { isAgentReportFresh, mapAgentReportToLifecycle, @@ -433,8 +441,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return idleMs > stuckThresholdMs; } - const ACTIVITY_STRONG_WINDOW_MS = 60_000; - const ACTIVITY_WEAK_WINDOW_MS = 5 * 60_000; const DETECTING_MAX_ATTEMPTS = 3; type ProbeState = "alive" | "dead" | "unknown"; @@ -456,14 +462,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } - function summarizeActivityFreshness(timestamp: Date | undefined): "strong" | "weak" | "stale" | "none" { - if (!timestamp) return "none"; - const ageMs = Date.now() - timestamp.getTime(); - if (ageMs <= ACTIVITY_STRONG_WINDOW_MS) return "strong"; - if (ageMs <= ACTIVITY_WEAK_WINDOW_MS) return "weak"; - return "stale"; - } - /** Determine current status for a session by polling plugins. */ async function determineStatus(session: Session): Promise { const project = config.projects[session.projectId]; @@ -502,6 +500,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan ): DeterminedStatus => { session.lifecycle = lifecycle; session.status = status; + session.activitySignal = activitySignal; return { status, evidence, @@ -568,9 +567,9 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } - let activityState: Awaited> | null = null; + let activitySignal = createActivitySignal("unavailable"); let processProbe: ProbeResult = { state: "unknown", failed: false }; - let activityEvidence = "activity_unavailable"; + let activityEvidence = formatActivitySignalEvidence(activitySignal); if (agent && (session.runtimeHandle || session.workspacePath)) { try { @@ -591,37 +590,38 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } - activityState = await agent.getActivityState(session, config.readyThresholdMs); - if (activityState) { - activityEvidence = `activity:${activityState.state}`; + const detectedActivity = await agent.getActivityState(session, config.readyThresholdMs); + if (detectedActivity) { + activitySignal = classifyActivitySignal(detectedActivity, "native"); + activityEvidence = formatActivitySignalEvidence(activitySignal); lifecycle.runtime.lastObservedAt = nowIso; if (lifecycle.runtime.state !== "missing" && lifecycle.runtime.state !== "probe_failed") { lifecycle.runtime.state = "alive"; lifecycle.runtime.reason = "process_running"; } - if (activityState.state === "waiting_input") { + if (detectedActivity.state === "waiting_input") { setSessionState("needs_input", "awaiting_user_input"); return commit(SESSION_STATUS.NEEDS_INPUT, activityEvidence, 0); } - if (activityState.state === "exited" && canProbeRuntimeIdentity) { + if (detectedActivity.state === "exited" && canProbeRuntimeIdentity) { processProbe = { state: "dead", failed: false }; lifecycle.runtime.state = "exited"; lifecycle.runtime.reason = "process_missing"; } - if ( - (activityState.state === "idle" || activityState.state === "blocked") && - activityState.timestamp - ) { - detectedIdleTimestamp = activityState.timestamp; - idleWasBlocked = activityState.state === "blocked"; + if (hasPositiveIdleEvidence(activitySignal)) { + detectedIdleTimestamp = activitySignal.timestamp; + idleWasBlocked = activitySignal.activity === "blocked"; } } else if (session.runtimeHandle && canProbeRuntimeIdentity) { + activitySignal = createActivitySignal("null", { source: "native" }); + activityEvidence = formatActivitySignalEvidence(activitySignal); const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); const terminalOutput = runtime ? await runtime.getOutput(session.runtimeHandle, 10) : ""; if (terminalOutput) { const activity = agent.detectActivity(terminalOutput); - activityEvidence = `terminal:${activity}`; + activitySignal = classifyActivitySignal({ state: activity }, "terminal"); + activityEvidence = formatActivitySignalEvidence(activitySignal); if (activity === "waiting_input") { setSessionState("needs_input", "awaiting_user_input"); return commit(SESSION_STATUS.NEEDS_INPUT, activityEvidence, 0); @@ -639,16 +639,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan processProbe = { state: "unknown", failed: true }; } } + } else { + activitySignal = createActivitySignal("null", { source: "native" }); + activityEvidence = formatActivitySignalEvidence(activitySignal); } } catch { + activitySignal = createActivitySignal("probe_failure", { source: "native" }); + activityEvidence = formatActivitySignalEvidence(activitySignal); if ( lifecycle.session.state === "stuck" || lifecycle.session.state === "needs_input" || lifecycle.session.state === "detecting" ) { - return commit(session.status, "activity_probe_failed_preserved"); + return commit(session.status, activityEvidence); } - return buildDetectingAssessment("activity_probe_failed"); + return buildDetectingAssessment(activityEvidence); } } @@ -671,9 +676,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } - const activityFreshness = summarizeActivityFreshness(activityState?.timestamp); - const recentActivitySupportsLiveness = - activityFreshness === "strong" || activityFreshness === "weak"; + const recentActivitySupportsLiveness = supportsRecentLiveness(activitySignal); if (runtimeProbe.failed || processProbe.failed) { return buildDetectingAssessment( @@ -687,7 +690,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan (runtimeProbe.state === "dead" && recentActivitySupportsLiveness) ) { return buildDetectingAssessment( - `signal_disagreement runtime=${runtimeProbe.state} process=${processProbe.state} activity=${activityFreshness}`, + `signal_disagreement runtime=${runtimeProbe.state} process=${processProbe.state} ${activityEvidence}`, runtimeProbe.state === "dead" ? "runtime_lost" : "agent_process_exited", ); } @@ -698,7 +701,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan canProbeRuntimeIdentity ) { return buildDetectingAssessment( - `runtime_dead process_unknown activity=${activityFreshness}`, + `runtime_dead process_unknown ${activityEvidence}`, "runtime_lost", ); } @@ -709,7 +712,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan !recentActivitySupportsLiveness ) { setSessionState("terminated", "runtime_lost"); - return commit(SESSION_STATUS.KILLED, `runtime_dead process_dead activity=${activityFreshness}`, 0); + return commit(SESSION_STATUS.KILLED, `runtime_dead process_dead ${activityEvidence}`, 0); } if ( @@ -788,10 +791,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return commit(SESSION_STATUS.REVIEW_PENDING, "review_pending", 0); } - if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) { + if ( + detectedIdleTimestamp && + hasPositiveIdleEvidence(activitySignal) && + isIdleBeyondThreshold(session, detectedIdleTimestamp) + ) { lifecycle.pr.reason = "in_progress"; setSessionState("stuck", idleWasBlocked ? "error_in_process" : "probe_failure"); - return commit(SESSION_STATUS.STUCK, "idle_beyond_threshold", 0); + return commit(SESSION_STATUS.STUCK, `idle_beyond_threshold ${activityEvidence}`, 0); } lifecycle.pr.reason = "in_progress"; @@ -846,10 +853,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return commit(SESSION_STATUS.REVIEW_PENDING, "review_pending", 0); } - if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) { + if ( + detectedIdleTimestamp && + hasPositiveIdleEvidence(activitySignal) && + isIdleBeyondThreshold(session, detectedIdleTimestamp) + ) { lifecycle.pr.reason = "in_progress"; setSessionState("stuck", idleWasBlocked ? "error_in_process" : "probe_failure"); - return commit(SESSION_STATUS.STUCK, "idle_beyond_threshold", 0); + return commit(SESSION_STATUS.STUCK, `idle_beyond_threshold ${activityEvidence}`, 0); } lifecycle.pr.reason = "in_progress"; @@ -880,9 +891,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return commit(legacy, `agent_report:${agentReport.state}`, 0); } - if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) { + if ( + detectedIdleTimestamp && + hasPositiveIdleEvidence(activitySignal) && + isIdleBeyondThreshold(session, detectedIdleTimestamp) + ) { setSessionState("stuck", idleWasBlocked ? "error_in_process" : "probe_failure"); - return commit(SESSION_STATUS.STUCK, "idle_beyond_threshold", 0); + return commit(SESSION_STATUS.STUCK, `idle_beyond_threshold ${activityEvidence}`, 0); + } + + if ( + isWeakActivityEvidence(activitySignal) && + (session.status === SESSION_STATUS.DETECTING || + session.status === SESSION_STATUS.STUCK || + session.status === SESSION_STATUS.NEEDS_INPUT) + ) { + return commit(session.status, activityEvidence, 0); } if ( diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index a8de6c6c4..519caeb5a 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -60,6 +60,7 @@ import { parseCanonicalLifecycle, } from "./lifecycle-state.js"; import { buildPrompt } from "./prompt-builder.js"; +import { classifyActivitySignal, createActivitySignal } from "./activity-signal.js"; import { getSessionsDir, getWorktreesDir, @@ -975,6 +976,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM session.status = "killed"; } session.activity = "exited"; + session.activitySignal = createActivitySignal("valid", { + activity: "exited", + source: "runtime", + }); return; } } catch { @@ -991,10 +996,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // external scripts (which don't store runtimeHandle) to always show "unknown". // This now runs for ALL sessions, including terminal statuses, so a merged // session with a live agent shows accurate activity (ready/idle/waiting_input). + session.activitySignal = createActivitySignal("unavailable"); if (plugins.agent) { try { const detected = await plugins.agent.getActivityState(session, config.readyThresholdMs); if (detected !== null) { + session.activitySignal = classifyActivitySignal(detected, "native"); session.activity = detected.state; session.lifecycle.runtime.state = "alive"; session.lifecycle.runtime.reason = "process_running"; @@ -1002,9 +1009,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (detected.timestamp && detected.timestamp > session.lastActivityAt) { session.lastActivityAt = detected.timestamp; } + } else { + session.activitySignal = createActivitySignal("null", { source: "native" }); } } catch { - // Can't detect activity — keep existing value + session.activitySignal = createActivitySignal("probe_failure", { source: "native" }); } // Enrich with live agent session info (summary, cost). @@ -1233,6 +1242,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM projectId: spawnConfig.projectId, status: deriveLegacyStatus(lifecycle), activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: createdAt, + source: "runtime", + }), lifecycle, branch, issueId: spawnConfig.issueId ?? null, @@ -1566,6 +1580,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM projectId: orchestratorConfig.projectId, status: deriveLegacyStatus(lifecycle), activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: createdAt, + source: "runtime", + }), lifecycle, branch, issueId: null, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bab548771..32863461b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -150,6 +150,23 @@ export const ACTIVITY_STATE = { EXITED: "exited" as const, } satisfies Record; +export type ActivitySignalState = "valid" | "stale" | "null" | "unavailable" | "probe_failure"; + +export type ActivitySignalSource = "native" | "terminal" | "runtime" | "none"; + +export interface ActivitySignal { + /** Confidence bucket for the activity probe result. */ + state: ActivitySignalState; + /** The observed activity value, if one was surfaced. */ + activity: ActivityState | null; + /** Timestamp that makes timing-based inferences safe, when available. */ + timestamp?: Date; + /** Where the activity signal came from. */ + source: ActivitySignalSource; + /** Optional extra detail for stale / failed probes. */ + detail?: string; +} + /** Result of activity detection, carrying both the state and an optional timestamp. */ export interface ActivityDetection { state: ActivityState; @@ -275,6 +292,9 @@ export interface Session { /** Activity state from agent plugin (null = not yet determined) */ activity: ActivityState | null; + /** Explicit confidence/availability contract for the current activity signal. */ + activitySignal: ActivitySignal; + /** Canonical lifecycle truth persisted in metadata. */ lifecycle: CanonicalSessionLifecycle; diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index 92d4f3e24..820376010 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -1,5 +1,6 @@ -import type { RuntimeHandle, Session, SessionId, SessionStatus } from "../types.js"; +import type { ActivitySignal, RuntimeHandle, Session, SessionId, SessionStatus } from "../types.js"; import { deriveLegacyStatus, parseCanonicalLifecycle } from "../lifecycle-state.js"; +import { createActivitySignal } from "../activity-signal.js"; import { parsePrFromUrl } from "./pr.js"; import { safeJsonParse, validateStatus } from "./validation.js"; @@ -7,12 +8,29 @@ interface SessionFromMetadataOptions { projectId?: string; status?: SessionStatus; activity?: Session["activity"]; + activitySignal?: ActivitySignal; runtimeHandle?: RuntimeHandle | null; createdAt?: Date; lastActivityAt?: Date; restoredAt?: Date; } +function deriveDefaultActivitySignal(options: SessionFromMetadataOptions): ActivitySignal { + if (options.activitySignal) { + return options.activitySignal; + } + + if (options.activity === undefined || options.activity === null) { + return createActivitySignal("unavailable"); + } + + return createActivitySignal("valid", { + activity: options.activity, + timestamp: options.lastActivityAt, + source: options.activity === "exited" ? "runtime" : "native", + }); +} + export function sessionFromMetadata( sessionId: SessionId, meta: Record, @@ -38,6 +56,7 @@ export function sessionFromMetadata( projectId: meta["project"] ?? options.projectId ?? "", status, activity: options.activity ?? null, + activitySignal: deriveDefaultActivitySignal(options), lifecycle, branch: meta["branch"] || null, issueId: meta["issue"] || null, diff --git a/packages/integration-tests/src/helpers/event-factory.ts b/packages/integration-tests/src/helpers/event-factory.ts index 71a2c8042..0a07a9d1d 100644 --- a/packages/integration-tests/src/helpers/event-factory.ts +++ b/packages/integration-tests/src/helpers/event-factory.ts @@ -1,4 +1,5 @@ import { + createActivitySignal, createInitialCanonicalLifecycle, type OrchestratorEvent, type Session, @@ -42,6 +43,11 @@ export function makeSession(overrides: Partial = {}): Session { projectId: "my-project", status: "working" as SessionStatus, activity: "active" as ActivityState, + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date("2025-06-15T12:00:00Z"), + source: "native", + }), lifecycle, branch: "feat/test", issueId: null, diff --git a/packages/integration-tests/src/helpers/session-factory.ts b/packages/integration-tests/src/helpers/session-factory.ts index 812d3faa8..b5e63b8ab 100644 --- a/packages/integration-tests/src/helpers/session-factory.ts +++ b/packages/integration-tests/src/helpers/session-factory.ts @@ -2,7 +2,7 @@ * Factory helpers to build Session and RuntimeHandle objects for tests. */ -import { createInitialCanonicalLifecycle, type RuntimeHandle, type Session } from "@aoagents/ao-core"; +import { createActivitySignal, createInitialCanonicalLifecycle, type RuntimeHandle, type Session } from "@aoagents/ao-core"; /** Build a tmux RuntimeHandle for a given session name. */ export function makeTmuxHandle(sessionName: string): RuntimeHandle { @@ -31,6 +31,11 @@ export function makeSession( projectId: "inttest", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), lifecycle, branch: null, issueId: null, diff --git a/packages/plugins/agent-aider/src/index.test.ts b/packages/plugins/agent-aider/src/index.test.ts index 058644f41..e2b9d6bb7 100644 --- a/packages/plugins/agent-aider/src/index.test.ts +++ b/packages/plugins/agent-aider/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core"; +import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core"; // Mock fs/promises for getSessionInfo tests (readFile for .aider.chat.history.md) vi.mock("node:fs/promises", async (importOriginal) => { @@ -53,6 +53,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "test-project", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), branch: "feat/test", issueId: null, pr: null, diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index a2be12ee6..7a557f2c2 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node: import { join } from "node:path"; import { tmpdir } from "node:os"; import { toClaudeProjectPath, create } from "../index.js"; -import type { Session, RuntimeHandle } from "@aoagents/ao-core"; +import { createActivitySignal, type Session, type RuntimeHandle } from "@aoagents/ao-core"; // Mock homedir() so getActivityState looks in our temp dir vi.mock("node:os", async (importOriginal) => { @@ -25,6 +25,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "test", status: "working", activity: "idle", + activitySignal: createActivitySignal("valid", { + activity: "idle", + timestamp: new Date(), + source: "native", + }), branch: "main", issueId: null, pr: null, diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 1ce859c6a..0d9c9cc3c 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Session, RuntimeHandle, AgentLaunchConfig, WorkspaceHooksConfig } from "@aoagents/ao-core"; +import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig, type WorkspaceHooksConfig } from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Hoisted mocks — available inside vi.mock factories @@ -61,6 +61,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "test-project", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), branch: "feat/test", issueId: null, pr: null, diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 7ab9faa8b..3d4e1a635 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Session, RuntimeHandle, AgentLaunchConfig, AgentSpecificConfig } from "@aoagents/ao-core"; +import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig, type AgentSpecificConfig } from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Hoisted mocks — available inside vi.mock factories @@ -83,6 +83,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "test-project", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), branch: "feat/test", issueId: null, pr: null, diff --git a/packages/plugins/agent-cursor/src/index.test.ts b/packages/plugins/agent-cursor/src/index.test.ts index f6382a265..1c92f2bcb 100644 --- a/packages/plugins/agent-cursor/src/index.test.ts +++ b/packages/plugins/agent-cursor/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core"; +import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core"; // Mock fs/promises for getSessionInfo tests vi.mock("node:fs/promises", async (importOriginal) => { @@ -73,6 +73,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "test-project", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), branch: "feat/test", issueId: null, pr: null, diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index a166429e1..04cf42bcf 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core"; +import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core"; const { mockAppendActivityEntry, mockReadLastActivityEntry, mockRecordTerminalActivity } = vi.hoisted(() => ({ @@ -42,6 +42,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "test-project", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), branch: "feat/test", issueId: null, pr: null, diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index 1edafbd09..5c32d4021 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -15,7 +15,7 @@ vi.mock("node:child_process", () => { }); import { create, manifest } from "../src/index.js"; -import type { PRInfo, SCMWebhookRequest, Session, ProjectConfig } from "@aoagents/ao-core"; +import { createActivitySignal, type PRInfo, type SCMWebhookRequest, type Session, type ProjectConfig } from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Fixtures @@ -46,6 +46,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "test", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), branch: "feat/my-feature", issueId: null, pr: null, diff --git a/packages/plugins/scm-gitlab/test/index.test.ts b/packages/plugins/scm-gitlab/test/index.test.ts index 05decdb55..1d7da50da 100644 --- a/packages/plugins/scm-gitlab/test/index.test.ts +++ b/packages/plugins/scm-gitlab/test/index.test.ts @@ -13,7 +13,7 @@ vi.mock("node:child_process", () => { }); import { create, manifest } from "../src/index.js"; -import type { PRInfo, Session, ProjectConfig, SCMWebhookRequest } from "@aoagents/ao-core"; +import { createActivitySignal, type PRInfo, type Session, type ProjectConfig, type SCMWebhookRequest } from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Fixtures @@ -44,6 +44,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "test", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), branch: "feat/my-feature", issueId: null, pr: null, diff --git a/packages/plugins/terminal-iterm2/src/index.test.ts b/packages/plugins/terminal-iterm2/src/index.test.ts index 371b97f02..9508639c0 100644 --- a/packages/plugins/terminal-iterm2/src/index.test.ts +++ b/packages/plugins/terminal-iterm2/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; -import { createInitialCanonicalLifecycle, type Session } from "@aoagents/ao-core"; +import { createInitialCanonicalLifecycle, createActivitySignal, type Session } from "@aoagents/ao-core"; vi.mock("node:child_process", () => ({ execFile: vi.fn(), @@ -28,6 +28,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "my-project", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), lifecycle, branch: "feat/test", issueId: null, diff --git a/packages/plugins/terminal-web/src/index.test.ts b/packages/plugins/terminal-web/src/index.test.ts index b34bc2d98..ca377fca2 100644 --- a/packages/plugins/terminal-web/src/index.test.ts +++ b/packages/plugins/terminal-web/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createInitialCanonicalLifecycle, type Session } from "@aoagents/ao-core"; +import { createInitialCanonicalLifecycle, createActivitySignal, type Session } from "@aoagents/ao-core"; import { manifest, create } from "./index.js"; function makeSession(overrides: Partial = {}): Session { @@ -14,6 +14,11 @@ function makeSession(overrides: Partial = {}): Session { projectId: "my-project", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), lifecycle, branch: "feat/test", issueId: null, diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 65a80f1a4..85095a267 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -4,6 +4,7 @@ import { SessionNotFoundError, SessionNotRestorableError, createInitialCanonicalLifecycle, + createActivitySignal, type Session, type SessionManager, type OrchestratorConfig, @@ -27,6 +28,11 @@ function makeSession(overrides: Partial & { id: string }): Session { projectId: "my-app", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), lifecycle, branch: null, issueId: null, diff --git a/packages/web/src/__tests__/helpers.ts b/packages/web/src/__tests__/helpers.ts index a487cd0b6..ad562b08f 100644 --- a/packages/web/src/__tests__/helpers.ts +++ b/packages/web/src/__tests__/helpers.ts @@ -1,25 +1,208 @@ -import type { DashboardSession, DashboardPR } from "@/lib/types"; +import type { + DashboardActivitySignal, + DashboardLifecycle, + DashboardPR, + DashboardSession, +} from "@/lib/types"; + +function humanize(token: string): string { + return token.replace(/_/g, " "); +} + +function deriveActivitySignal(session: DashboardSession): DashboardActivitySignal { + if (session.activitySignal) { + return session.activitySignal; + } + + if (session.activity === "exited") { + return { + state: "valid", + activity: "exited", + timestamp: session.lastActivityAt, + source: "runtime", + }; + } + + if (session.activity === null) { + return { + state: "unavailable", + activity: null, + timestamp: null, + source: "none", + }; + } + + return { + state: "valid", + activity: session.activity, + timestamp: session.lastActivityAt, + source: "native", + }; +} + +function deriveLifecycle(session: DashboardSession): DashboardLifecycle { + const isRuntimeEnded = + session.activity === "exited" || + session.status === "killed" || + session.status === "terminated" || + session.status === "cleanup" || + session.status === "done" || + session.status === "merged"; + + const sessionFacet = (() => { + switch (session.status) { + case "spawning": + return { state: "not_started", reason: "spawn_requested" }; + case "detecting": + return { state: "detecting", reason: "probe_failure" }; + case "needs_input": + return { state: "needs_input", reason: "awaiting_user_input" }; + case "stuck": + case "errored": + return { state: "stuck", reason: "probe_failure" }; + case "done": + return { state: "done", reason: "research_complete" }; + case "merged": + return { state: "done", reason: "merged_waiting_decision" }; + case "cleanup": + return { state: "terminated", reason: "agent_process_exited" }; + case "killed": + return { state: "terminated", reason: "manually_killed" }; + case "terminated": + return { state: "terminated", reason: "runtime_lost" }; + case "pr_open": + return { state: "working", reason: "pr_created" }; + case "ci_failed": + return { state: "working", reason: "fixing_ci" }; + case "changes_requested": + return { state: "working", reason: "resolving_review_comments" }; + case "review_pending": + case "approved": + case "mergeable": + case "idle": + return { state: "idle", reason: "awaiting_external_review" }; + case "working": + default: + return { state: "working", reason: "task_in_progress" }; + } + })(); + + const prFacet = (() => { + if (session.pr?.state === "merged" || session.status === "merged") { + return { state: "merged", reason: "merged" }; + } + if (session.pr?.state === "closed") { + return { state: "closed", reason: "closed_unmerged" }; + } + if (session.pr) { + if (session.pr.isDraft) { + return { state: "open", reason: "in_progress" }; + } + if (session.pr.ciStatus === "failing") { + return { state: "open", reason: "ci_failing" }; + } + if (session.pr.reviewDecision === "changes_requested") { + return { state: "open", reason: "changes_requested" }; + } + if (session.pr.unresolvedThreads > 0) { + return { state: "open", reason: "review_pending" }; + } + if (session.pr.reviewDecision === "approved") { + const ready = + session.pr.mergeability.mergeable && + session.pr.mergeability.ciPassing && + session.pr.mergeability.approved && + session.pr.mergeability.noConflicts; + return { state: "open", reason: ready ? "merge_ready" : "in_progress" }; + } + if (session.pr.reviewDecision === "pending" || session.pr.reviewDecision === "none") { + return { state: "open", reason: "review_pending" }; + } + return { state: "open", reason: "in_progress" }; + } + return { state: "none", reason: "not_created" }; + })(); + + const runtimeFacet = (() => { + if (session.status === "detecting") { + return { state: "probe_failed", reason: "probe_error" }; + } + if (session.status === "killed") { + return { state: "missing", reason: "manual_kill_requested" }; + } + if (session.status === "terminated") { + return { state: "missing", reason: "process_missing" }; + } + if (isRuntimeEnded) { + return { state: "exited", reason: "process_missing" }; + } + if (session.status === "spawning") { + return { state: "unknown", reason: "spawn_incomplete" }; + } + return { state: "alive", reason: "process_running" }; + })(); + + return { + sessionState: sessionFacet.state, + sessionReason: sessionFacet.reason, + prState: prFacet.state, + prReason: prFacet.reason, + runtimeState: runtimeFacet.state, + runtimeReason: runtimeFacet.reason, + session: { + state: sessionFacet.state, + reason: sessionFacet.reason, + label: humanize(sessionFacet.state), + reasonLabel: humanize(sessionFacet.reason), + startedAt: session.createdAt, + completedAt: sessionFacet.state === "done" ? session.lastActivityAt : null, + terminatedAt: sessionFacet.state === "terminated" ? session.lastActivityAt : null, + lastTransitionAt: session.lastActivityAt, + }, + pr: { + state: prFacet.state, + reason: prFacet.reason, + label: humanize(prFacet.state), + reasonLabel: humanize(prFacet.reason), + number: session.pr?.number ?? null, + url: session.pr?.url ?? null, + lastObservedAt: session.lastActivityAt, + }, + runtime: { + state: runtimeFacet.state, + reason: runtimeFacet.reason, + label: humanize(runtimeFacet.state), + reasonLabel: humanize(runtimeFacet.reason), + lastObservedAt: session.lastActivityAt, + }, + legacyStatus: session.status, + evidence: null, + detectingAttempts: session.status === "detecting" ? 1 : 0, + detectingEscalatedAt: null, + summary: `Session ${humanize(sessionFacet.state)}, PR ${humanize(prFacet.state)}, runtime ${humanize(runtimeFacet.state)}`, + guidance: null, + }; +} /** Create a minimal mock session with overrides */ export function makeSession(overrides: Partial = {}): DashboardSession { - return { + const baseSession: DashboardSession = { id: "test-1", projectId: "my-app", status: "working", activity: "active", - lifecycle: { - sessionState: "working", - sessionReason: "task_in_progress", - prState: "none", - prReason: "not_created", - runtimeState: "alive", - runtimeReason: "process_running", + activitySignal: { + state: "valid", + activity: "active", + timestamp: new Date().toISOString(), + source: "native", }, + lifecycle: undefined, branch: "feat/test", - issueId: "https://linear.app/test/issue/INT-100", - issueUrl: "https://linear.app/test/issue/INT-100", - issueLabel: "INT-100", - issueTitle: "Test issue", + issueId: null, + issueUrl: null, + issueLabel: null, + issueTitle: null, userPrompt: null, summary: "Test session", summaryIsFallback: false, @@ -27,7 +210,18 @@ export function makeSession(overrides: Partial = {}): Dashboar lastActivityAt: new Date().toISOString(), pr: null, metadata: {}, + }; + + const session = { + ...baseSession, ...overrides, + issueLabel: overrides.issueLabel ?? overrides.issueId ?? baseSession.issueLabel, + } satisfies DashboardSession; + + return { + ...session, + activitySignal: overrides.activitySignal ?? deriveActivitySignal(session), + lifecycle: overrides.lifecycle ?? deriveLifecycle(session), }; } diff --git a/packages/web/src/__tests__/prompt-spawn.test.ts b/packages/web/src/__tests__/prompt-spawn.test.ts index 53ba11965..f3f111f4b 100644 --- a/packages/web/src/__tests__/prompt-spawn.test.ts +++ b/packages/web/src/__tests__/prompt-spawn.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; import { createInitialCanonicalLifecycle, + createActivitySignal, type Session, type SessionManager, type OrchestratorConfig, @@ -37,6 +38,11 @@ function makeSession(overrides: Partial & { id: string }): Session { projectId: "my-app", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date("2025-01-01T00:00:00Z"), + source: "native", + }), lifecycle, branch: null, issueId: null, diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index dccc160a7..e3dfbfac9 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -18,6 +18,8 @@ import { getRuntimeTruthReasonLabel, getLifecycleGuidance, getLifecycleEvidence, + getActivitySignalLabel, + getActivitySignalReasonLabel, isDashboardRuntimeEnded, isDashboardSessionRestorable, } from "@/lib/types"; @@ -153,6 +155,11 @@ 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), @@ -559,7 +566,7 @@ export function SessionDetail({ isDashboardSessionRestorable(session) && !NON_RESTORABLE_STATUSES.has(session.status); const activity = (session.activity && activityMeta[session.activity]) ?? { - label: session.activity ?? "unknown", + label: getActivitySignalLabel(session), color: "var(--color-text-muted)", }; const headline = getSessionTitle(session); diff --git a/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx b/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx index 7e9472411..bac0ea797 100644 --- a/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx @@ -318,7 +318,7 @@ describe("Dashboard mobile layout", () => { issueTitle: "Restore completed agent", branch: null, lastActivityAt: "2026-04-11T09:07:00.000Z", - pr: makePR({ number: 209, state: "merged", title: "Wrapped up work" }), + pr: makePR({ number: 209, state: "closed", title: "Wrapped up work" }), }), ]} />, @@ -331,7 +331,7 @@ describe("Dashboard mobile layout", () => { "href", "https://github.com/acme/app/pull/100", ); - expect(screen.getByText("merged")).toBeInTheDocument(); + expect(screen.getByText("terminated")).toBeInTheDocument(); expect(screen.getByText("2h ago")).toBeInTheDocument(); await act(async () => { diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index 6ffe4a46b..56d8edd50 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -120,7 +120,7 @@ describe("SessionDetail desktop layout", () => { ); expect(screen.getByText("3 files")).toBeInTheDocument(); expect(screen.getByText("Draft")).toBeInTheDocument(); - expect(screen.getByText(/Changes requested/i)).toBeInTheDocument(); + expect(screen.getAllByText(/Changes requested/i).length).toBeGreaterThan(0); expect(screen.getByText(/Merge conflicts/i)).toBeInTheDocument(); expect(screen.getByText(/Unresolved Comments/i)).toBeInTheDocument(); expect(screen.getByText("Tighten the copy")).toBeInTheDocument(); diff --git a/packages/web/src/lib/__tests__/format.test.ts b/packages/web/src/lib/__tests__/format.test.ts index c32d746a8..d8fe44e9e 100644 --- a/packages/web/src/lib/__tests__/format.test.ts +++ b/packages/web/src/lib/__tests__/format.test.ts @@ -16,6 +16,12 @@ function makeSession(overrides?: Partial): DashboardSession { projectId: "test", status: "working", activity: "active", + activitySignal: { + state: "valid", + activity: "active", + timestamp: new Date().toISOString(), + source: "native", + }, branch: null, issueId: null, issueUrl: null, diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts index 214673d96..91bee5a5d 100644 --- a/packages/web/src/lib/__tests__/serialize.test.ts +++ b/packages/web/src/lib/__tests__/serialize.test.ts @@ -40,6 +40,12 @@ function createCoreSession(overrides?: Partial): Session { projectId: "test", status: "working", activity: "active", + activitySignal: { + state: "valid", + activity: "active", + timestamp: new Date("2025-01-01T01:00:00Z"), + source: "native", + }, lifecycle, branch: "feat/test", issueId: null, @@ -130,6 +136,13 @@ describe("sessionToDashboard", () => { expect(dashboard.projectId).toBe("test"); expect(dashboard.status).toBe("working"); expect(dashboard.activity).toBe("active"); + expect(dashboard.activitySignal).toEqual({ + state: "valid", + activity: "active", + timestamp: "2025-01-01T01:00:00.000Z", + source: "native", + detail: undefined, + }); expect(dashboard.branch).toBe("feat/test"); expect(dashboard.createdAt).toBe("2025-01-01T00:00:00.000Z"); expect(dashboard.lastActivityAt).toBe("2025-01-01T01:00:00.000Z"); @@ -510,6 +523,12 @@ describe("enrichSessionPR", () => { projectId: "test", status: "working", activity: "active", + activitySignal: { + state: "valid", + activity: "active", + timestamp: new Date().toISOString(), + source: "native", + }, branch: "feat/test", issueId: null, issueUrl: null, @@ -717,6 +736,12 @@ describe("enrichSessionIssueTitle", () => { projectId: "test", status: "working", activity: "active", + activitySignal: { + state: "valid", + activity: "active", + timestamp: new Date().toISOString(), + source: "native", + }, branch: "feat/test", issueId: null, issueUrl: null, diff --git a/packages/web/src/lib/__tests__/types.test.ts b/packages/web/src/lib/__tests__/types.test.ts index 93b54dd78..3083a3906 100644 --- a/packages/web/src/lib/__tests__/types.test.ts +++ b/packages/web/src/lib/__tests__/types.test.ts @@ -5,6 +5,8 @@ import { describe, it, expect } from "vitest"; import { getAttentionLevel, + getActivitySignalLabel, + getActivitySignalReasonLabel, isPRMergeReady, TERMINAL_STATUSES, TERMINAL_ACTIVITIES, @@ -27,6 +29,12 @@ function createSession(overrides?: Partial): DashboardSession projectId: "test", status: "working", activity: "active", + activitySignal: { + state: "valid", + activity: "active", + timestamp: new Date().toISOString(), + source: "native", + }, branch: "feat/test", issueId: null, issueUrl: null, @@ -773,3 +781,35 @@ describe("constants sync with core", () => { expect(NON_RESTORABLE_STATUSES).toBe(CORE_NON_RESTORABLE_STATUSES); }); }); + +describe("activity signal fallback", () => { + it("treats legacy idle activity without a timestamp as stale", () => { + const session = createSession({ + activity: "idle", + activitySignal: undefined, + }); + + expect(getActivitySignalLabel(session)).toBe("idle (stale)"); + expect(getActivitySignalReasonLabel(session)).toBe("missing timestamp"); + }); + + it("treats legacy blocked activity without a timestamp as stale", () => { + const session = createSession({ + activity: "blocked", + activitySignal: undefined, + }); + + expect(getActivitySignalLabel(session)).toBe("blocked (stale)"); + expect(getActivitySignalReasonLabel(session)).toBe("missing timestamp"); + }); + + it("keeps legacy active activity valid", () => { + const session = createSession({ + activity: "active", + activitySignal: undefined, + }); + + expect(getActivitySignalLabel(session)).toBe("active"); + expect(getActivitySignalReasonLabel(session)).toBeNull(); + }); +}); diff --git a/packages/web/src/lib/scm-webhooks.test.ts b/packages/web/src/lib/scm-webhooks.test.ts index cf10e039c..c01e42516 100644 --- a/packages/web/src/lib/scm-webhooks.test.ts +++ b/packages/web/src/lib/scm-webhooks.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + createActivitySignal, createInitialCanonicalLifecycle, type ProjectConfig, type SCMWebhookEvent, @@ -93,6 +94,11 @@ describe("findAffectedSessions", () => { projectId: "my-app", status: "working", activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), lifecycle: activeLifecycle, branch: "feat/one", issueId: null, @@ -118,6 +124,11 @@ describe("findAffectedSessions", () => { projectId: "my-app", status: "merged", activity: "exited", + activitySignal: createActivitySignal("valid", { + activity: "exited", + timestamp: new Date(), + source: "runtime", + }), lifecycle: mergedLifecycle, branch: "feat/one", issueId: null, diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index 66f52ffb1..5401a06c9 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -166,6 +166,13 @@ export function sessionToDashboard(session: Session): DashboardSession { projectId: session.projectId, status: session.status, activity: session.activity, + activitySignal: { + state: session.activitySignal.state, + activity: session.activitySignal.activity, + timestamp: session.activitySignal.timestamp?.toISOString() ?? null, + source: session.activitySignal.source, + detail: session.activitySignal.detail, + }, lifecycle: buildDashboardLifecycle(session), branch: session.branch, issueId: session.issueId, // Deprecated: kept for backwards compatibility diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 83e5d64cd..a19e73e3f 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -11,6 +11,8 @@ export type { SessionStatus, ActivityState, + ActivitySignalState, + ActivitySignalSource, CIStatus, ReviewDecision, MergeReadiness, @@ -35,6 +37,8 @@ import { type CIStatus, type SessionStatus, type ActivityState, + type ActivitySignalState, + type ActivitySignalSource, type ReviewDecision, type CanonicalSessionState, type CanonicalSessionReason, @@ -72,6 +76,7 @@ export interface DashboardSession { projectId: string; status: SessionStatus; activity: ActivityState | null; + activitySignal?: DashboardActivitySignal; lifecycle?: DashboardLifecycle; branch: string | null; issueId: string | null; // Deprecated: use issueUrl instead @@ -89,6 +94,14 @@ export interface DashboardSession { attentionLevel?: AttentionLevel; } +export interface DashboardActivitySignal { + state: ActivitySignalState; + activity: ActivityState | null; + timestamp: string | null; + source: ActivitySignalSource; + detail?: string; +} + export interface DashboardLifecycleFacet< TState extends string = string, TReason extends string = string, @@ -249,28 +262,28 @@ function humanizeLifecycleToken(token: string): string { } export function getSessionTruthLabel(session: DashboardSession): string { - return session.lifecycle?.session.label ?? humanizeLifecycleToken(session.status); + return session.lifecycle?.session?.label ?? humanizeLifecycleToken(session.status); } export function getSessionTruthReasonLabel(session: DashboardSession): string | null { - return session.lifecycle?.session.reasonLabel ?? null; + return session.lifecycle?.session?.reasonLabel ?? null; } export function getPRTruthLabel(session: DashboardSession): string { - if (session.lifecycle) return session.lifecycle.pr.label; + if (session.lifecycle?.pr?.label) 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; + return session.lifecycle?.pr?.reasonLabel ?? null; } export function getRuntimeTruthLabel(session: DashboardSession): string { - return session.lifecycle?.runtime.label ?? "unknown"; + return session.lifecycle?.runtime?.label ?? "unknown"; } export function getRuntimeTruthReasonLabel(session: DashboardSession): string | null { - return session.lifecycle?.runtime.reasonLabel ?? null; + return session.lifecycle?.runtime?.reasonLabel ?? null; } export function getLifecycleGuidance(session: DashboardSession): string | null { @@ -281,6 +294,64 @@ export function getLifecycleEvidence(session: DashboardSession): string | null { return session.lifecycle?.evidence ?? session.metadata["lifecycleEvidence"] ?? null; } +function resolveActivitySignal(session: DashboardSession): DashboardActivitySignal { + if (session.activitySignal) { + return session.activitySignal; + } + + if (session.activity === null) { + return { + state: "unavailable", + activity: null, + timestamp: null, + source: "none", + }; + } + + if (session.activity === "idle" || session.activity === "blocked") { + return { + state: "stale", + activity: session.activity, + timestamp: null, + source: "none", + detail: "missing_timestamp", + }; + } + + return { + state: "valid", + activity: session.activity, + timestamp: null, + source: "none", + }; +} + +export function getActivitySignalLabel(session: DashboardSession): string { + const signal = resolveActivitySignal(session); + switch (signal.state) { + case "valid": + return signal.activity ? humanizeLifecycleToken(signal.activity) : "valid"; + case "stale": + return signal.activity ? `${humanizeLifecycleToken(signal.activity)} (stale)` : "stale"; + case "null": + return "no activity signal"; + case "unavailable": + return "activity unavailable"; + case "probe_failure": + return "activity probe failed"; + } +} + +export function getActivitySignalReasonLabel(session: DashboardSession): string | null { + const signal = resolveActivitySignal(session); + const parts = [ + signal.source !== "none" ? `source ${signal.source}` : null, + signal.timestamp ? `observed ${signal.timestamp}` : null, + signal.detail ? humanizeLifecycleToken(signal.detail) : null, + ].filter((value): value is string => value !== null); + return parts.length > 0 ? parts.join(" • ") : null; +} + export function isDashboardSessionDone(session: DashboardSession): boolean { if (session.lifecycle) { return (