From dd83a11503655aa0e927337861f592708fe1e881 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 17 Apr 2026 19:28:56 +0530 Subject: [PATCH 1/3] fix: model missing activity evidence explicitly (#122) Represent missing activity probes as first-class signal states so lifecycle inference only treats valid idle evidence as proof. This prevents false stuck transitions, keeps API/UI lifecycle truth aligned, and makes root monorepo verification deterministic by serializing recursive build and typecheck. --- package.json | 4 +- packages/cli/src/commands/status.ts | 2 + packages/core/CHANGELOG.md | 6 + .../src/__tests__/lifecycle-manager.test.ts | 58 ++++- .../__tests__/session-manager/query.test.ts | 31 +++ packages/core/src/__tests__/test-utils.ts | 6 + packages/core/src/activity-signal.ts | 121 ++++++++++ packages/core/src/index.ts | 10 + packages/core/src/lifecycle-manager.ts | 90 ++++--- packages/core/src/session-manager.ts | 21 +- packages/core/src/types.ts | 20 ++ .../core/src/utils/session-from-metadata.ts | 5 +- .../src/helpers/event-factory.ts | 6 + .../src/helpers/session-factory.ts | 7 +- .../plugins/agent-aider/src/index.test.ts | 7 +- .../src/__tests__/activity-detection.test.ts | 7 +- .../agent-claude-code/src/index.test.ts | 7 +- .../plugins/agent-codex/src/index.test.ts | 7 +- .../plugins/agent-cursor/src/index.test.ts | 7 +- .../plugins/agent-opencode/src/index.test.ts | 7 +- .../plugins/scm-github/test/index.test.ts | 7 +- .../plugins/scm-gitlab/test/index.test.ts | 7 +- .../plugins/terminal-iterm2/src/index.test.ts | 7 +- .../plugins/terminal-web/src/index.test.ts | 7 +- packages/web/CHANGELOG.md | 6 + packages/web/src/__tests__/api-routes.test.ts | 6 + packages/web/src/__tests__/helpers.ts | 220 ++++++++++++++++-- .../web/src/__tests__/prompt-spawn.test.ts | 6 + packages/web/src/components/SessionDetail.tsx | 9 +- .../__tests__/Dashboard.mobile.test.tsx | 4 +- .../__tests__/SessionDetail.desktop.test.tsx | 2 +- packages/web/src/lib/__tests__/format.test.ts | 6 + .../web/src/lib/__tests__/serialize.test.ts | 25 ++ packages/web/src/lib/__tests__/types.test.ts | 6 + packages/web/src/lib/scm-webhooks.test.ts | 11 + packages/web/src/lib/serialize.ts | 7 + packages/web/src/lib/types.ts | 62 ++++- 37 files changed, 749 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/activity-signal.ts diff --git a/package.json b/package.json index 1664c7a64..a0b3d4af4 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,13 @@ "packageManager": "pnpm@9.15.4", "type": "module", "scripts": { - "build": "pnpm -r build", + "build": "pnpm -r --workspace-concurrency=1 build", "dev": "pnpm --filter @aoagents/ao-web dev", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", - "typecheck": "pnpm -r typecheck", + "typecheck": "pnpm -r --workspace-concurrency=1 typecheck", "test": "pnpm -r --filter '!@aoagents/ao-web' test", "test:integration": "pnpm --filter @aoagents/ao-integration-tests test:integration", "clean": "pnpm -r clean", 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/CHANGELOG.md b/packages/core/CHANGELOG.md index cae1b3cab..7c8edc2c8 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @composio/ao-core +## Unreleased + +### Patch Changes + +- Fix lifecycle activity inference to model missing evidence explicitly, prevent null or failed probes from being treated as idle proof, and require valid idle timing before sessions can transition to `stuck`. + ## 0.2.0 ### Minor Changes diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 5869af993..0866b5131 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" }); 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..c45855ac4 --- /dev/null +++ b/packages/core/src/activity-signal.ts @@ -0,0 +1,121 @@ +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 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..781254344 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,6 +122,16 @@ export { classifyTerminalActivity, recordTerminalActivity, } from "./activity-log.js"; +export { + ACTIVITY_STRONG_WINDOW_MS, + ACTIVITY_WEAK_WINDOW_MS, + classifyActivitySignal, + createActivitySignal, + formatActivitySignalEvidence, + hasPositiveIdleEvidence, + 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..7a322f5a7 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -40,6 +40,13 @@ import { import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; import { updateMetadata } from "./metadata.js"; import { getSessionsDir } from "./paths.js"; +import { + classifyActivitySignal, + createActivitySignal, + formatActivitySignalEvidence, + hasPositiveIdleEvidence, + supportsRecentLiveness, +} from "./activity-signal.js"; import { isAgentReportFresh, mapAgentReportToLifecycle, @@ -433,8 +440,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 +461,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 +499,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan ): DeterminedStatus => { session.lifecycle = lifecycle; session.status = status; + session.activitySignal = activitySignal; return { status, evidence, @@ -568,9 +566,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 +589,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 +638,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 +675,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 +689,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 +700,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 +711,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 +790,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 +852,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 +890,13 @@ 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 ( 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..9dc60dff8 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,6 +8,7 @@ interface SessionFromMetadataOptions { projectId?: string; status?: SessionStatus; activity?: Session["activity"]; + activitySignal?: ActivitySignal; runtimeHandle?: RuntimeHandle | null; createdAt?: Date; lastActivityAt?: Date; @@ -38,6 +40,7 @@ export function sessionFromMetadata( projectId: meta["project"] ?? options.projectId ?? "", status, activity: options.activity ?? null, + activitySignal: options.activitySignal ?? createActivitySignal("unavailable"), 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/CHANGELOG.md b/packages/web/CHANGELOG.md index 5df34b062..a0fddd449 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,11 @@ # @composio/ao-web +## Unreleased + +### Patch Changes + +- Update dashboard lifecycle truth rendering to expose explicit activity-signal uncertainty, keep activity/runtime labels aligned with core lifecycle semantics, and cover stale, null, and probe-failure cases in tests. + ## 0.2.2 ### Patch Changes 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..c49c37d99 100644 --- a/packages/web/src/lib/__tests__/types.test.ts +++ b/packages/web/src/lib/__tests__/types.test.ts @@ -27,6 +27,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, 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..727a525c7 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,43 @@ export function getLifecycleEvidence(session: DashboardSession): string | null { return session.lifecycle?.evidence ?? session.metadata["lifecycleEvidence"] ?? null; } +function resolveActivitySignal(session: DashboardSession): DashboardActivitySignal { + return ( + session.activitySignal ?? { + state: session.activity === null ? "unavailable" : "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 ( From ca8c4cc7ab93efb3eda0b9f81b296a6f51b7e58e Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 17 Apr 2026 19:38:32 +0530 Subject: [PATCH 2/3] fix: address lifecycle review feedback (#122) Keep reconstructed activity and activitySignal coherent, preserve stronger lifecycle states when repeated polls only surface weak evidence, and switch release notes from manual changelog edits to a Changesets entry. This also stabilizes the respond/working path investigated in issue #126. --- .../lifecycle-activity-signal-contract.md | 5 +++ packages/core/CHANGELOG.md | 6 --- .../src/__tests__/lifecycle-manager.test.ts | 42 +++++++++++++++++++ packages/core/src/activity-signal.ts | 4 ++ packages/core/src/index.ts | 1 + packages/core/src/lifecycle-manager.ts | 10 +++++ .../core/src/utils/session-from-metadata.ts | 18 +++++++- packages/web/CHANGELOG.md | 6 --- packages/web/src/lib/types.ts | 2 +- 9 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 .changeset/lifecycle-activity-signal-contract.md 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/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7c8edc2c8..cae1b3cab 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,11 +1,5 @@ # @composio/ao-core -## Unreleased - -### Patch Changes - -- Fix lifecycle activity inference to model missing evidence explicitly, prevent null or failed probes from being treated as idle proof, and require valid idle timing before sessions can transition to `stuck`. - ## 0.2.0 ### Minor Changes diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 0866b5131..e8c103617 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -675,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/activity-signal.ts b/packages/core/src/activity-signal.ts index c45855ac4..95ed80aff 100644 --- a/packages/core/src/activity-signal.ts +++ b/packages/core/src/activity-signal.ts @@ -112,6 +112,10 @@ export function supportsRecentLiveness(signal: ActivitySignal, now: Date = new D ); } +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}` : ""; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 781254344..3069a95c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -129,6 +129,7 @@ export { createActivitySignal, formatActivitySignalEvidence, hasPositiveIdleEvidence, + isWeakActivityEvidence, summarizeActivityFreshness, supportsRecentLiveness, } from "./activity-signal.js"; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 7a322f5a7..223f7c564 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -45,6 +45,7 @@ import { createActivitySignal, formatActivitySignalEvidence, hasPositiveIdleEvidence, + isWeakActivityEvidence, supportsRecentLiveness, } from "./activity-signal.js"; import { @@ -899,6 +900,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan 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 ( session.status === SESSION_STATUS.SPAWNING || session.status === SESSION_STATUS.DETECTING || diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index 9dc60dff8..820376010 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -15,6 +15,22 @@ interface SessionFromMetadataOptions { 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, @@ -40,7 +56,7 @@ export function sessionFromMetadata( projectId: meta["project"] ?? options.projectId ?? "", status, activity: options.activity ?? null, - activitySignal: options.activitySignal ?? createActivitySignal("unavailable"), + activitySignal: deriveDefaultActivitySignal(options), lifecycle, branch: meta["branch"] || null, issueId: meta["issue"] || null, diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index a0fddd449..5df34b062 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,11 +1,5 @@ # @composio/ao-web -## Unreleased - -### Patch Changes - -- Update dashboard lifecycle truth rendering to expose explicit activity-signal uncertainty, keep activity/runtime labels aligned with core lifecycle semantics, and cover stale, null, and probe-failure cases in tests. - ## 0.2.2 ### Patch Changes diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 727a525c7..bc289c224 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -76,7 +76,7 @@ export interface DashboardSession { projectId: string; status: SessionStatus; activity: ActivityState | null; - activitySignal: DashboardActivitySignal; + activitySignal?: DashboardActivitySignal; lifecycle?: DashboardLifecycle; branch: string | null; issueId: string | null; // Deprecated: use issueUrl instead From f1769ec910b1032d650d2a57017ef67d87f025bc Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 17 Apr 2026 19:52:15 +0530 Subject: [PATCH 3/3] fix: address final lifecycle review findings (#122) --- package.json | 4 +-- packages/web/src/lib/__tests__/types.test.ts | 34 ++++++++++++++++++++ packages/web/src/lib/types.ts | 31 +++++++++++++++--- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index a0b3d4af4..1664c7a64 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,13 @@ "packageManager": "pnpm@9.15.4", "type": "module", "scripts": { - "build": "pnpm -r --workspace-concurrency=1 build", + "build": "pnpm -r build", "dev": "pnpm --filter @aoagents/ao-web dev", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", - "typecheck": "pnpm -r --workspace-concurrency=1 typecheck", + "typecheck": "pnpm -r typecheck", "test": "pnpm -r --filter '!@aoagents/ao-web' test", "test:integration": "pnpm --filter @aoagents/ao-integration-tests test:integration", "clean": "pnpm -r clean", diff --git a/packages/web/src/lib/__tests__/types.test.ts b/packages/web/src/lib/__tests__/types.test.ts index c49c37d99..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, @@ -779,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/types.ts b/packages/web/src/lib/types.ts index bc289c224..a19e73e3f 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -295,14 +295,35 @@ export function getLifecycleEvidence(session: DashboardSession): string | null { } function resolveActivitySignal(session: DashboardSession): DashboardActivitySignal { - return ( - session.activitySignal ?? { - state: session.activity === null ? "unavailable" : "valid", + 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 {