diff --git a/.changeset/lifecycle-transitions-report-watcher.md b/.changeset/lifecycle-transitions-report-watcher.md new file mode 100644 index 000000000..6253f9750 --- /dev/null +++ b/.changeset/lifecycle-transitions-report-watcher.md @@ -0,0 +1,14 @@ +--- +"@aoagents/ao-core": minor +--- + +Add centralized lifecycle transitions and report watcher for agent monitoring. + +- **Lifecycle transitions (#137)**: Centralize all lifecycle state mutations through `applyLifecycleDecision()` for consistent timestamp handling, atomic metadata persistence, and observability. +- **Detecting bounds (#138)**: Add time-based (5 min) and attempt-based (3 attempts) bounds to detecting state with evidence hashing to prevent counter reset on unchanged probe results. +- **Report watcher (#140)**: Background trigger system that audits agent reports for anomalies (no_acknowledge, stale_report, agent_needs_input) and integrates with the reaction engine. + +New exports: +- `applyLifecycleDecision`, `applyDecisionToLifecycle`, `buildTransitionMetadataPatch`, `createStateTransitionDecision` +- `DETECTING_MAX_ATTEMPTS`, `DETECTING_MAX_DURATION_MS`, `hashEvidence`, `isDetectingTimedOut` +- `auditAgentReports`, `checkAcknowledgeTimeout`, `checkStaleReport`, `checkBlockedAgent`, `shouldAuditSession`, `getReactionKeyForTrigger`, `DEFAULT_REPORT_WATCHER_CONFIG`, `REPORT_WATCHER_METADATA_KEYS` diff --git a/packages/core/src/__tests__/lifecycle-transition.test.ts b/packages/core/src/__tests__/lifecycle-transition.test.ts index e81e9eaf4..2240bc395 100644 --- a/packages/core/src/__tests__/lifecycle-transition.test.ts +++ b/packages/core/src/__tests__/lifecycle-transition.test.ts @@ -97,6 +97,38 @@ describe("applyDecisionToLifecycle", () => { expect(lifecycle.session.terminatedAt).toBe(nowIso); }); + it("does not overwrite completedAt if already set", () => { + lifecycle.session.completedAt = "2026-04-16T12:00:00.000Z"; + + const decision: LifecycleDecision = { + status: "done", + evidence: "test", + detectingAttempts: 0, + sessionState: "done", + sessionReason: "research_complete", + }; + + applyDecisionToLifecycle(lifecycle, decision, nowIso); + + expect(lifecycle.session.completedAt).toBe("2026-04-16T12:00:00.000Z"); + }); + + it("does not overwrite terminatedAt if already set", () => { + lifecycle.session.terminatedAt = "2026-04-16T12:00:00.000Z"; + + const decision: LifecycleDecision = { + status: "terminated", + evidence: "test", + detectingAttempts: 0, + sessionState: "terminated", + sessionReason: "manually_killed", + }; + + applyDecisionToLifecycle(lifecycle, decision, nowIso); + + expect(lifecycle.session.terminatedAt).toBe("2026-04-16T12:00:00.000Z"); + }); + it("applies PR state and reason", () => { const decision: LifecycleDecision = { status: "pr_open", diff --git a/packages/core/src/__tests__/report-watcher.test.ts b/packages/core/src/__tests__/report-watcher.test.ts index 4d74922de..25fd6a6fb 100644 --- a/packages/core/src/__tests__/report-watcher.test.ts +++ b/packages/core/src/__tests__/report-watcher.test.ts @@ -52,9 +52,9 @@ describe("shouldAuditSession", () => { } }); - it("returns false for spawning sessions", () => { + it("returns true for spawning sessions (allows acknowledge timeout check)", () => { const session = createMockSession({ status: "spawning" }); - expect(shouldAuditSession(session)).toBe(false); + expect(shouldAuditSession(session)).toBe(true); }); it("returns false for orchestrator sessions", () => { @@ -214,8 +214,8 @@ describe("checkBlockedAgent", () => { }); describe("auditAgentReports", () => { - it("returns null for sessions that should not be audited", () => { - const session = createMockSession({ status: "spawning" }); + it("returns null for terminal sessions", () => { + const session = createMockSession({ status: "done" }); expect(auditAgentReports(session)).toBeNull(); }); @@ -254,7 +254,6 @@ describe("getReactionKeyForTrigger", () => { it("maps triggers to reaction keys", () => { expect(getReactionKeyForTrigger("no_acknowledge")).toBe("report-no-acknowledge"); expect(getReactionKeyForTrigger("stale_report")).toBe("report-stale"); - expect(getReactionKeyForTrigger("agent_blocked")).toBe("report-blocked"); expect(getReactionKeyForTrigger("agent_needs_input")).toBe("report-needs-input"); }); }); diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 4bd0a5948..7a60e5cfc 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -1787,13 +1787,26 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan */ async function auditAndReactToReports(session: Session): Promise { const auditResult = auditAgentReports(session); - if (!auditResult || !auditResult.trigger) return; + const now = new Date().toISOString(); + + // If no trigger, clear any active trigger metadata + if (!auditResult || !auditResult.trigger) { + const hadActiveTrigger = session.metadata[REPORT_WATCHER_METADATA_KEYS.ACTIVE_TRIGGER]; + if (hadActiveTrigger) { + updateSessionMetadata(session, { + [REPORT_WATCHER_METADATA_KEYS.LAST_AUDITED_AT]: now, + [REPORT_WATCHER_METADATA_KEYS.ACTIVE_TRIGGER]: "", + [REPORT_WATCHER_METADATA_KEYS.TRIGGER_ACTIVATED_AT]: "", + [REPORT_WATCHER_METADATA_KEYS.TRIGGER_COUNT]: "", + }); + } + return; + } const reactionKey = getReactionKeyForTrigger(auditResult.trigger); const reactionConfig = getReactionConfigForSession(session, reactionKey); // Update audit metadata - const now = new Date().toISOString(); const currentTriggerCount = parseInt( session.metadata[REPORT_WATCHER_METADATA_KEYS.TRIGGER_COUNT] ?? "0", 10, diff --git a/packages/core/src/lifecycle-transition.ts b/packages/core/src/lifecycle-transition.ts index 1d35ff29a..344851ed6 100644 --- a/packages/core/src/lifecycle-transition.ts +++ b/packages/core/src/lifecycle-transition.ts @@ -95,14 +95,14 @@ export function applyDecisionToLifecycle( lifecycle.session.reason = decision.sessionReason; lifecycle.session.lastTransitionAt = nowIso; - // Handle special timestamp fields + // Handle special timestamp fields (only set if not already set) if (decision.sessionState === "working" && lifecycle.session.startedAt === null) { lifecycle.session.startedAt = nowIso; } - if (decision.sessionState === "done") { + if (decision.sessionState === "done" && lifecycle.session.completedAt === null) { lifecycle.session.completedAt = nowIso; } - if (decision.sessionState === "terminated") { + if (decision.sessionState === "terminated" && lifecycle.session.terminatedAt === null) { lifecycle.session.terminatedAt = nowIso; } } @@ -227,20 +227,26 @@ export function applyLifecycleDecision( const nextStatus = deriveLegacyStatus(nextLifecycle, previousStatus); const statusChanged = nextStatus !== previousStatus; - // Build and apply metadata patch - const metadataPatch = buildTransitionMetadataPatch( - nextLifecycle, - input.decision, - previousStatus, - ); + // Build metadata patch, starting with additional metadata (so lifecycle keys take precedence) + const metadataPatch: Record = {}; - // Merge additional metadata + // Apply additional metadata first if (input.additionalMetadata) { for (const [key, value] of Object.entries(input.additionalMetadata)) { metadataPatch[key] = value; } } + // Apply lifecycle patch second (overwrites any conflicting keys from additionalMetadata) + const lifecyclePatch = buildTransitionMetadataPatch( + nextLifecycle, + input.decision, + previousStatus, + ); + for (const [key, value] of Object.entries(lifecyclePatch)) { + metadataPatch[key] = value; + } + // Persist updateMetadata(input.dataDir, input.sessionId, metadataPatch); diff --git a/packages/core/src/report-watcher.ts b/packages/core/src/report-watcher.ts index ccc8351a6..7f86cb533 100644 --- a/packages/core/src/report-watcher.ts +++ b/packages/core/src/report-watcher.ts @@ -18,7 +18,6 @@ import { readAgentReport, isAgentReportFresh, type AgentReport } from "./agent-r export type ReportWatcherTrigger = | "no_acknowledge" | "stale_report" - | "agent_blocked" | "agent_needs_input"; /** @@ -80,6 +79,7 @@ const TERMINAL_STATUSES: Set = new Set([ /** * Check if a session should be audited for report issues. + * Note: spawning sessions are included so acknowledge timeout can fire. */ export function shouldAuditSession(session: Session): boolean { // Skip terminal sessions @@ -90,10 +90,7 @@ export function shouldAuditSession(session: Session): boolean { if (session.lifecycle?.session.kind === "orchestrator") { return false; } - // Skip sessions still spawning (give them time to start) - if (session.status === "spawning") { - return false; - } + // Note: spawning sessions are NOT skipped — they need acknowledge timeout checks return true; } @@ -237,8 +234,6 @@ export function getReactionKeyForTrigger(trigger: ReportWatcherTrigger): string return "report-no-acknowledge"; case "stale_report": return "report-stale"; - case "agent_blocked": - return "report-blocked"; case "agent_needs_input": return "report-needs-input"; }