fix: address PR review comments for lifecycle transitions

- Allow spawning sessions in shouldAuditSession for acknowledge timeout
- Remove dead agent_blocked trigger type (never returned)
- Clear audit metadata when no trigger fires
- Protect completedAt/terminatedAt from being overwritten
- Ensure lifecycle patch takes precedence over additionalMetadata
- Add changeset entry for new exports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
harshitsinghbhandari 2026-04-18 00:39:35 +05:30
parent 89c17f49f4
commit 7b82374ca8
6 changed files with 83 additions and 24 deletions

View File

@ -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`

View File

@ -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",

View File

@ -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");
});
});

View File

@ -1787,13 +1787,26 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
*/
async function auditAndReactToReports(session: Session): Promise<void> {
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,

View File

@ -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<string, string> = {};
// 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);

View File

@ -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<SessionStatus> = 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";
}