diff --git a/server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts b/server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts new file mode 100644 index 00000000..45d8f855 --- /dev/null +++ b/server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + describeSessionResetReason, + shouldResetTaskSessionForWake, +} from "../services/heartbeat.ts"; + +// PF-4: timer-driven wakes ("heartbeat_timer") are exploratory and do not +// carry continuation state. Reusing the prior task session for repeated +// timer wakes accumulates low-value context and pushes the session toward +// the 64k compaction threshold (observed in CEO run 292a5fd1). The +// shouldResetTaskSessionForWake / describeSessionResetReason pair must +// agree that timer wakes start a fresh session, while preserving the +// existing reset rules for assignment / review / approval / changes wakes +// and the existing reuse policy for issue_commented and other reasons. + +describe("PF-4 shouldResetTaskSessionForWake", () => { + it("resets the session when wakeReason is heartbeat_timer", () => { + expect( + shouldResetTaskSessionForWake({ + source: "scheduler", + reason: "interval_elapsed", + wakeReason: "heartbeat_timer", + }), + ).toBe(true); + }); + + it("still resets for the existing reset reasons", () => { + for (const wakeReason of [ + "issue_assigned", + "execution_review_requested", + "execution_approval_requested", + "execution_changes_requested", + ] as const) { + expect(shouldResetTaskSessionForWake({ wakeReason })).toBe(true); + } + }); + + it("still respects forceFreshSession === true", () => { + expect(shouldResetTaskSessionForWake({ forceFreshSession: true })).toBe(true); + }); + + it("does not reset for issue_commented (preserve continuation context)", () => { + expect(shouldResetTaskSessionForWake({ wakeReason: "issue_commented" })).toBe(false); + }); + + it("does not reset for transient_failure_retry (resume in-flight work)", () => { + expect(shouldResetTaskSessionForWake({ wakeReason: "transient_failure_retry" })).toBe(false); + }); + + it("does not reset for unknown wake reasons", () => { + expect(shouldResetTaskSessionForWake({ wakeReason: "unknown_reason" })).toBe(false); + }); + + it("does not reset when context is null/undefined", () => { + expect(shouldResetTaskSessionForWake(null)).toBe(false); + expect(shouldResetTaskSessionForWake(undefined)).toBe(false); + }); +}); + +describe("PF-4 describeSessionResetReason", () => { + it("describes heartbeat_timer wakes explicitly so run logs explain the reset", () => { + const reason = describeSessionResetReason({ + wakeReason: "heartbeat_timer", + }); + expect(reason).toBe("wake reason is heartbeat_timer (timer-driven wake starts fresh)"); + }); + + it("returns the existing reasons for the existing reset triggers", () => { + expect(describeSessionResetReason({ wakeReason: "issue_assigned" })).toBe( + "wake reason is issue_assigned", + ); + expect(describeSessionResetReason({ wakeReason: "execution_review_requested" })).toBe( + "wake reason is execution_review_requested", + ); + expect(describeSessionResetReason({ wakeReason: "execution_approval_requested" })).toBe( + "wake reason is execution_approval_requested", + ); + expect(describeSessionResetReason({ wakeReason: "execution_changes_requested" })).toBe( + "wake reason is execution_changes_requested", + ); + }); + + it("returns the forceFreshSession message when explicitly requested", () => { + expect(describeSessionResetReason({ forceFreshSession: true })).toBe( + "forceFreshSession was requested", + ); + }); + + it("returns null for non-resetting wake reasons", () => { + expect(describeSessionResetReason({ wakeReason: "issue_commented" })).toBeNull(); + expect(describeSessionResetReason({ wakeReason: "transient_failure_retry" })).toBeNull(); + expect(describeSessionResetReason({ wakeReason: "unknown_reason" })).toBeNull(); + expect(describeSessionResetReason(null)).toBeNull(); + expect(describeSessionResetReason(undefined)).toBeNull(); + }); + + it("agrees with shouldResetTaskSessionForWake on every input — non-null reason iff should reset", () => { + const cases: Array | null | undefined> = [ + { wakeReason: "heartbeat_timer" }, + { wakeReason: "issue_assigned" }, + { wakeReason: "execution_review_requested" }, + { wakeReason: "execution_approval_requested" }, + { wakeReason: "execution_changes_requested" }, + { forceFreshSession: true }, + { wakeReason: "issue_commented" }, + { wakeReason: "transient_failure_retry" }, + { wakeReason: "unknown_reason" }, + null, + undefined, + ]; + for (const ctx of cases) { + const shouldReset = shouldResetTaskSessionForWake(ctx); + const reason = describeSessionResetReason(ctx); + expect(Boolean(reason)).toBe(shouldReset); + } + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index e54bfdb4..4b0a0c00 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1995,8 +1995,12 @@ function deriveTaskKey( /** * Extended task key derivation that falls back to a stable synthetic key - * for timer/heartbeat wakes. This ensures timer wakes can resume their - * previous session via `agentTaskSessions` instead of starting fresh. + * for timer/heartbeat wakes. The synthetic key keeps the + * `agentTaskSessions` row addressable across heartbeats so the row can be + * cleared and re-keyed deterministically; it does NOT mean the prior + * session is resumed. Since PF-4 (#4838), `heartbeat_timer` wakes always + * go through `shouldResetTaskSessionForWake` and start a fresh session — + * see `describeSessionResetReason` for the paired log message. * * The synthetic key is only used when: * - No explicit task/issue key exists in the context @@ -2025,7 +2029,14 @@ export function shouldResetTaskSessionForWake( wakeReason === "issue_assigned" || wakeReason === "execution_review_requested" || wakeReason === "execution_approval_requested" || - wakeReason === "execution_changes_requested" + wakeReason === "execution_changes_requested" || + // PF-4: timer-driven wakes are exploratory ("any new work?"). They do not + // carry meaningful continuation state, so reusing the prior task session + // for repeated timer wakes accumulates low-value context and pushes the + // session toward the 64k compaction threshold (observed in CEO run + // 292a5fd1, where timer wakes repeatedly bloated a long-lived manager + // session). Reset on every timer wake so each interval starts fresh. + wakeReason === "heartbeat_timer" ) { return true; } @@ -2090,7 +2101,7 @@ export function formatRuntimeWorkspaceWarningLog(warning: string) { }; } -function describeSessionResetReason( +export function describeSessionResetReason( contextSnapshot: Record | null | undefined, ) { if (contextSnapshot?.forceFreshSession === true) return "forceFreshSession was requested"; @@ -2100,6 +2111,9 @@ function describeSessionResetReason( if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested"; if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested"; if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested"; + // PF-4: paired with shouldResetTaskSessionForWake — keep the reason wording + // explicit so run logs make session reuse/reset behavior legible. + if (wakeReason === "heartbeat_timer") return "wake reason is heartbeat_timer (timer-driven wake starts fresh)"; return null; }