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:
parent
89c17f49f4
commit
7b82374ca8
|
|
@ -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`
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue