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