From cb6294ec0247dc4f1ea4ea5f963a27ba19b2a85b Mon Sep 17 00:00:00 2001 From: Solved Bot Date: Wed, 27 May 2026 23:58:51 +0200 Subject: [PATCH 1/4] feat(heartbeat): ADR-0044 session lifecycle T1/T2/T3/T4 for claude_local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR-0044 «Heartbeat session lifecycle» (SOL-1629). - SessionCompactionPolicy gains maxCachedInputTokens (T1), rotateOnZeroOpenIssues (T3) and rotateOnNewIssueWake (T4). - claude_local adapter ships with T1=500_000, T2=6h, T3+T4 enabled by default. Per-agent overrides (CEO/CoS/Analyst T1=1M / T2=12h) continue to flow through runtimeConfig.heartbeat.sessionCompaction. - evaluateSessionCompaction now considers cache_read_input_tokens (the actual cost signal under Anthropic prompt caching) and the state-triggered rotations described in ADR-0044. - Extracted a pure decideSessionCompactionTrigger helper so the decision rule is unit-testable without a database. - usageJson now records freshSessionTriggeredBy for retrospective tuning per ADR-0044 §Acceptance criteria. - countOpenIssuesForAgent helper added inline in heartbeat service. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Paperclip --- .../adapter-utils/src/session-compaction.ts | 49 ++++- .../heartbeat-session-rotation-policy.test.ts | 204 ++++++++++++++++++ .../heartbeat-workspace-session.test.ts | 47 +++- server/src/services/heartbeat.ts | 127 +++++++++-- 4 files changed, 404 insertions(+), 23 deletions(-) create mode 100644 server/src/__tests__/heartbeat-session-rotation-policy.test.ts diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 3764155c40..ea264f1936 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -3,6 +3,10 @@ export interface SessionCompactionPolicy { maxSessionRuns: number; maxRawInputTokens: number; maxSessionAgeHours: number; + // ADR-0044 additions + maxCachedInputTokens: number; // T1: threshold on cache_read_input_tokens (0 = disabled) + rotateOnZeroOpenIssues: boolean; // T3: rotate when openIssuesCount == 0 + rotateOnNewIssueWake: boolean; // T4: rotate when wakeReason == "issue_assigned" } export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none"; @@ -25,6 +29,9 @@ const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { maxSessionRuns: 200, maxRawInputTokens: 2_000_000, maxSessionAgeHours: 72, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, }; // Adapters with native context management still participate in session resume, @@ -34,6 +41,21 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = { maxSessionRuns: 0, maxRawInputTokens: 0, maxSessionAgeHours: 0, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, +}; + +// ADR-0044 «Heartbeat session lifecycle» — fresh-session policy applied to claude_local by default. +// Per-agent overrides (e.g. CEO/CoS/Analyst T1=1M / T2=12h) are configured via runtimeConfig.heartbeat.sessionCompaction. +const CLAUDE_LOCAL_ADR_0044_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 0, // not used (variant D rejected by ADR-0044) + maxRawInputTokens: 0, // not used (cached_input is the meaningful signal) + maxSessionAgeHours: 6, // T2 default for execution agents + maxCachedInputTokens: 500_000, // T1 default for execution agents + rotateOnZeroOpenIssues: true, // T3 + rotateOnNewIssueWake: true, // T4 }; export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ @@ -52,7 +74,7 @@ export const ADAPTER_SESSION_MANAGEMENT: Record) { - return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0; + return ( + policy.maxSessionRuns > 0 || + policy.maxRawInputTokens > 0 || + policy.maxSessionAgeHours > 0 || + policy.maxCachedInputTokens > 0 || + policy.rotateOnZeroOpenIssues === true || + policy.rotateOnNewIssueWake === true + ); } diff --git a/server/src/__tests__/heartbeat-session-rotation-policy.test.ts b/server/src/__tests__/heartbeat-session-rotation-policy.test.ts new file mode 100644 index 0000000000..0e409ec492 --- /dev/null +++ b/server/src/__tests__/heartbeat-session-rotation-policy.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { + decideSessionCompactionTrigger, + type SessionCompactionTriggerInput, +} from "../services/heartbeat.ts"; +import type { SessionCompactionPolicy } from "@paperclipai/adapter-utils"; + +const DISABLED_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, +}; + +const ADR_0044_DEFAULT_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 6, + maxCachedInputTokens: 500_000, + rotateOnZeroOpenIssues: true, + rotateOnNewIssueWake: true, +}; + +function buildInput(overrides: Partial = {}): SessionCompactionTriggerInput { + return { + policy: DISABLED_POLICY, + runsCount: 1, + latestRawUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + sessionAgeHours: 0, + openIssuesCount: 1, + wakeReason: null, + ...overrides, + }; +} + +describe("decideSessionCompactionTrigger", () => { + it("returns null when no thresholds are crossed", () => { + expect(decideSessionCompactionTrigger(buildInput({ policy: ADR_0044_DEFAULT_POLICY }))).toBeNull(); + }); + + it("T1 triggers when cached_input >= maxCachedInputTokens", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + latestRawUsage: { inputTokens: 0, cachedInputTokens: 600_000, outputTokens: 0 }, + }), + ); + expect(result?.triggeredBy).toBe("t1"); + expect(result?.reason).toMatch(/cache_read reached 600,000 tokens/); + }); + + it("T2 triggers when sessionAgeHours >= maxSessionAgeHours", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionAgeHours: 7, + }), + ); + expect(result?.triggeredBy).toBe("t2"); + expect(result?.reason).toBe("session age reached 7 hours"); + }); + + it("T3 triggers when openIssuesCount is 0 and rotateOnZeroOpenIssues is true", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + openIssuesCount: 0, + }), + ); + expect(result?.triggeredBy).toBe("t3"); + expect(result?.reason).toBe("no open issues for agent"); + }); + + it("T3 does NOT trigger when openIssuesCount is null (caller skipped the count)", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + openIssuesCount: null, + }), + ); + expect(result).toBeNull(); + }); + + it("T4 triggers when wakeReason is issue_assigned and rotateOnNewIssueWake is true", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + wakeReason: "issue_assigned", + }), + ); + expect(result?.triggeredBy).toBe("t4"); + expect(result?.reason).toBe("wake triggered by new issue assignment"); + }); + + it("T4 does NOT trigger for other wake reasons", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + wakeReason: "heartbeat_timer", + }), + ); + expect(result).toBeNull(); + }); + + it("legacy_runs triggers when runsCount > maxSessionRuns", () => { + const policy: SessionCompactionPolicy = { ...DISABLED_POLICY, maxSessionRuns: 5 }; + const result = decideSessionCompactionTrigger(buildInput({ policy, runsCount: 6 })); + expect(result?.triggeredBy).toBe("legacy_runs"); + expect(result?.reason).toBe("session exceeded 5 runs"); + }); + + it("legacy_raw_input triggers when raw inputTokens >= maxRawInputTokens", () => { + const policy: SessionCompactionPolicy = { ...DISABLED_POLICY, maxRawInputTokens: 1_000_000 }; + const result = decideSessionCompactionTrigger( + buildInput({ + policy, + latestRawUsage: { inputTokens: 1_500_000, cachedInputTokens: 0, outputTokens: 0 }, + }), + ); + expect(result?.triggeredBy).toBe("legacy_raw_input"); + expect(result?.reason).toMatch(/raw input reached 1,500,000 tokens/); + }); + + it("priority: legacy_runs wins over T1 when both conditions hold", () => { + const policy: SessionCompactionPolicy = { + ...ADR_0044_DEFAULT_POLICY, + maxSessionRuns: 5, + }; + const result = decideSessionCompactionTrigger( + buildInput({ + policy, + runsCount: 6, + latestRawUsage: { inputTokens: 0, cachedInputTokens: 800_000, outputTokens: 0 }, + }), + ); + expect(result?.triggeredBy).toBe("legacy_runs"); + }); + + it("priority: T1 wins over T2 when both conditions hold", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + latestRawUsage: { inputTokens: 0, cachedInputTokens: 800_000, outputTokens: 0 }, + sessionAgeHours: 10, + }), + ); + expect(result?.triggeredBy).toBe("t1"); + }); + + it("priority: T2 wins over T3 when both conditions hold", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionAgeHours: 10, + openIssuesCount: 0, + }), + ); + expect(result?.triggeredBy).toBe("t2"); + }); + + it("priority: T3 wins over T4 when both conditions hold", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + openIssuesCount: 0, + wakeReason: "issue_assigned", + }), + ); + expect(result?.triggeredBy).toBe("t3"); + }); + + it("ignores T3 when rotateOnZeroOpenIssues is false even if count is 0", () => { + const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, rotateOnZeroOpenIssues: false }; + const result = decideSessionCompactionTrigger(buildInput({ policy, openIssuesCount: 0 })); + expect(result).toBeNull(); + }); + + it("ignores T4 when rotateOnNewIssueWake is false even if wakeReason is issue_assigned", () => { + const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, rotateOnNewIssueWake: false }; + const result = decideSessionCompactionTrigger(buildInput({ policy, wakeReason: "issue_assigned" })); + expect(result).toBeNull(); + }); + + it("ignores T1 when maxCachedInputTokens is 0 (disabled)", () => { + const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxCachedInputTokens: 0 }; + const result = decideSessionCompactionTrigger( + buildInput({ + policy, + latestRawUsage: { inputTokens: 0, cachedInputTokens: 10_000_000, outputTokens: 0 }, + }), + ); + expect(result).toBeNull(); + }); + + it("ignores T2 when maxSessionAgeHours is 0 (disabled)", () => { + const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxSessionAgeHours: 0 }; + const result = decideSessionCompactionTrigger(buildInput({ policy, sessionAgeHours: 100 })); + expect(result).toBeNull(); + }); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 73a9c1f1af..a7171f9e72 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -3032,18 +3032,27 @@ describe("prioritizeProjectWorkspaceCandidatesForRun", () => { }); describe("parseSessionCompactionPolicy", () => { - it("disables Paperclip-managed rotation by default for codex and claude local", () => { + it("disables Paperclip-managed rotation by default for codex local (native context management)", () => { expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({ enabled: true, maxSessionRuns: 0, maxRawInputTokens: 0, maxSessionAgeHours: 0, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, }); + }); + + it("applies ADR-0044 fresh-session policy by default for claude_local", () => { expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({ enabled: true, maxSessionRuns: 0, maxRawInputTokens: 0, - maxSessionAgeHours: 0, + maxSessionAgeHours: 6, + maxCachedInputTokens: 500_000, + rotateOnZeroOpenIssues: true, + rotateOnNewIssueWake: true, }); }); @@ -3053,12 +3062,18 @@ describe("parseSessionCompactionPolicy", () => { maxSessionRuns: 200, maxRawInputTokens: 2_000_000, maxSessionAgeHours: 72, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, }); expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({ enabled: true, maxSessionRuns: 200, maxRawInputTokens: 2_000_000, maxSessionAgeHours: 72, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, }); }); @@ -3079,6 +3094,34 @@ describe("parseSessionCompactionPolicy", () => { maxSessionRuns: 25, maxRawInputTokens: 500_000, maxSessionAgeHours: 0, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, + }); + }); + + it("supports ADR-0044 per-agent overrides (T1/T2/T3/T4)", () => { + expect( + parseSessionCompactionPolicy( + buildAgent("claude_local", { + heartbeat: { + sessionCompaction: { + maxCachedInputTokens: 1_000_000, + maxSessionAgeHours: 12, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, + }, + }, + }), + ), + ).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 12, + maxCachedInputTokens: 1_000_000, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, }); }); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6456843a17..4a245938fa 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3514,19 +3514,88 @@ interface WakeupOptions { allowRunCoalescing?: boolean; } -type UsageTotals = { +export type UsageTotals = { inputTokens: number; cachedInputTokens: number; outputTokens: number; }; +export type SessionCompactionTrigger = + | "t1" + | "t2" + | "t3" + | "t4" + | "legacy_runs" + | "legacy_raw_input"; + type SessionCompactionDecision = { rotate: boolean; reason: string | null; + triggeredBy: SessionCompactionTrigger | null; handoffMarkdown: string | null; previousRunId: string | null; }; +export interface SessionCompactionTriggerInput { + policy: SessionCompactionPolicy; + runsCount: number; + latestRawUsage: UsageTotals | null; + sessionAgeHours: number; + openIssuesCount: number | null; + wakeReason: string | null; +} + +// Pure decision rule for session rotation. Priority order: +// legacy_runs > legacy_raw_input > T1 (cached input) > T2 (age) > T3 (zero open issues) > T4 (new issue wake). +// First match wins so triggeredBy is deterministic for retrospective tuning (ADR-0044 §Acceptance criteria). +export function decideSessionCompactionTrigger( + input: SessionCompactionTriggerInput, +): { reason: string; triggeredBy: SessionCompactionTrigger } | null { + const { policy, runsCount, latestRawUsage, sessionAgeHours, openIssuesCount, wakeReason } = input; + + if (policy.maxSessionRuns > 0 && runsCount > policy.maxSessionRuns) { + return { reason: `session exceeded ${policy.maxSessionRuns} runs`, triggeredBy: "legacy_runs" }; + } + if ( + policy.maxRawInputTokens > 0 && + latestRawUsage && + latestRawUsage.inputTokens >= policy.maxRawInputTokens + ) { + return { + reason: + `session raw input reached ${formatCount(latestRawUsage.inputTokens)} tokens ` + + `(threshold ${formatCount(policy.maxRawInputTokens)})`, + triggeredBy: "legacy_raw_input", + }; + } + if ( + policy.maxCachedInputTokens > 0 && + latestRawUsage && + latestRawUsage.cachedInputTokens >= policy.maxCachedInputTokens + ) { + return { + reason: + `session cache_read reached ${formatCount(latestRawUsage.cachedInputTokens)} tokens ` + + `(threshold ${formatCount(policy.maxCachedInputTokens)})`, + triggeredBy: "t1", + }; + } + if (policy.maxSessionAgeHours > 0 && sessionAgeHours >= policy.maxSessionAgeHours) { + return { reason: `session age reached ${Math.floor(sessionAgeHours)} hours`, triggeredBy: "t2" }; + } + if ( + policy.rotateOnZeroOpenIssues && + typeof openIssuesCount === "number" && + openIssuesCount === 0 + ) { + return { reason: "no open issues for agent", triggeredBy: "t3" }; + } + if (policy.rotateOnNewIssueWake && wakeReason === "issue_assigned") { + return { reason: "wake triggered by new issue assignment", triggeredBy: "t4" }; + } + return null; +} + interface ParsedIssueAssigneeAdapterOverrides { adapterConfig: Record | null; useProjectWorkspace: boolean | null; @@ -11542,12 +11611,15 @@ export function heartbeatService( sessionId: string | null; issueId: string | null; continuationSummaryBody?: string | null; + wakeReason?: string | null; + openIssuesCount?: number | null; }): Promise { const { agent, sessionId, issueId } = input; if (!sessionId) { return { rotate: false, reason: null, + triggeredBy: null, handoffMarkdown: null, previousRunId: null, }; @@ -11558,6 +11630,7 @@ export function heartbeatService( return { rotate: false, reason: null, + triggeredBy: null, handoffMarkdown: null, previousRunId: null, }; @@ -11589,6 +11662,7 @@ export function heartbeatService( return { rotate: false, reason: null, + triggeredBy: null, handoffMarkdown: null, previousRunId: null, }; @@ -11610,28 +11684,22 @@ export function heartbeatService( ) : 0; - let reason: string | null = null; - if (policy.maxSessionRuns > 0 && runs.length > policy.maxSessionRuns) { - reason = `session exceeded ${policy.maxSessionRuns} runs`; - } else if ( - policy.maxRawInputTokens > 0 && - latestRawUsage && - latestRawUsage.inputTokens >= policy.maxRawInputTokens - ) { - reason = - `session raw input reached ${formatCount(latestRawUsage.inputTokens)} tokens ` + - `(threshold ${formatCount(policy.maxRawInputTokens)})`; - } else if ( - policy.maxSessionAgeHours > 0 && - sessionAgeHours >= policy.maxSessionAgeHours - ) { - reason = `session age reached ${Math.floor(sessionAgeHours)} hours`; - } + const trigger = decideSessionCompactionTrigger({ + policy, + runsCount: runs.length, + latestRawUsage, + sessionAgeHours, + openIssuesCount: typeof input.openIssuesCount === "number" ? input.openIssuesCount : null, + wakeReason: input.wakeReason ?? null, + }); + const reason: string | null = trigger?.reason ?? null; + const triggeredBy: SessionCompactionTrigger | null = trigger?.triggeredBy ?? null; if (!reason || !latestRun) { return { rotate: false, reason: null, + triggeredBy: null, handoffMarkdown: null, previousRunId: latestRun?.id ?? null, }; @@ -11669,6 +11737,7 @@ export function heartbeatService( return { rotate: true, reason, + triggeredBy, handoffMarkdown, previousRunId: latestRun.id, }; @@ -16360,6 +16429,19 @@ export function heartbeatService( return Number(count ?? 0); } + async function countOpenIssuesForAgent(agentId: string) { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(issues) + .where( + and( + eq(issues.assigneeAgentId, agentId), + notInArray(issues.status, ["done", "cancelled"]), + ), + ); + return Number(count ?? 0); + } + async function withChatControlRecoveryGate( run: typeof heartbeatRuns.$inferSelect, stage: "claim" | "dispatch", @@ -21578,11 +21660,19 @@ export function heartbeatService( stripPaperclipSessionMetadataFromSessionParams(runtimeSessionParams), ); + const wakeReasonForCompaction = readNonEmptyString(context?.wakeReason) ?? null; + const policyForCompaction = parseSessionCompactionPolicy(agent); + const openIssuesCountForCompaction = policyForCompaction.rotateOnZeroOpenIssues + ? await countOpenIssuesForAgent(agent.id) + : null; const sessionCompaction = await evaluateSessionCompaction({ agent, sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter, issueId, continuationSummaryBody: continuationSummary?.body ?? null, + policy: policyForCompaction, + wakeReason: wakeReasonForCompaction, + openIssuesCount: openIssuesCountForCompaction, }); if (sessionCompaction.rotate) { context.paperclipSessionHandoffMarkdown = @@ -23749,6 +23839,7 @@ export function heartbeatService( runtimeForAdapter.sessionDisplayId == null, sessionRotated: sessionCompaction.rotate, sessionRotationReason: sessionCompaction.reason, + freshSessionTriggeredBy: sessionCompaction.rotate ? sessionCompaction.triggeredBy : null, configFreshness: configFreshnessResultMetadata, provider: readNonEmptyString(adapterResult.provider) ?? "unknown", From 08e2c3b2ebe7dfd64d95b7cb191e31efd83b7599 Mon Sep 17 00:00:00 2001 From: Engineer Date: Sat, 4 Jul 2026 21:49:08 +0200 Subject: [PATCH 2/4] fix(heartbeat): extract ISSUE_ASSIGNED_WAKE_REASON constant + pass policy to evaluateSessionCompaction - Export ISSUE_ASSIGNED_WAKE_REASON near other wake reason constants; use it in decideSessionCompactionTrigger instead of a bare string literal (greptile P2). - Add optional policy param to evaluateSessionCompaction; use pre-parsed policy from caller to avoid double parseSessionCompactionPolicy call (greptile P2). Co-Authored-By: Claude Sonnet 4.6 --- server/src/services/heartbeat.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4a245938fa..c5767f4146 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -821,6 +821,7 @@ const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([ ]); export { MAX_TURN_CONTINUATION_RETRY_REASON }; export const MAX_TURN_CONTINUATION_WAKE_REASON = "max_turns_continuation_retry"; +export const ISSUE_ASSIGNED_WAKE_REASON = "issue_assigned"; const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2; const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10; const MAX_TURN_CONTINUATION_DEFAULT_DELAY_MS = 1_000; @@ -3590,7 +3591,7 @@ export function decideSessionCompactionTrigger( ) { return { reason: "no open issues for agent", triggeredBy: "t3" }; } - if (policy.rotateOnNewIssueWake && wakeReason === "issue_assigned") { + if (policy.rotateOnNewIssueWake && wakeReason === ISSUE_ASSIGNED_WAKE_REASON) { return { reason: "wake triggered by new issue assignment", triggeredBy: "t4" }; } return null; @@ -11613,6 +11614,7 @@ export function heartbeatService( continuationSummaryBody?: string | null; wakeReason?: string | null; openIssuesCount?: number | null; + policy?: SessionCompactionPolicy; }): Promise { const { agent, sessionId, issueId } = input; if (!sessionId) { @@ -11625,7 +11627,7 @@ export function heartbeatService( }; } - const policy = parseSessionCompactionPolicy(agent); + const policy = input.policy ?? parseSessionCompactionPolicy(agent); if (!policy.enabled || !hasSessionCompactionThresholds(policy)) { return { rotate: false, From d1a49877ec3a911842a06f00f7a3991bfaadd894 Mon Sep 17 00:00:00 2001 From: Paperclip Engineer Date: Sun, 5 Jul 2026 13:26:03 +0200 Subject: [PATCH 3/4] fix(hermes): add missing SessionCompactionPolicy fields (T1/T3/T4) Adds maxCachedInputTokens, rotateOnZeroOpenIssues, rotateOnNewIssueWake to hermes gateway and local adapter defaults; these fields were added in the ADR-0044 policy struct but hermes objects were not updated. Co-Authored-By: Claude Sonnet 4.6 --- packages/adapters/hermes/src/gateway/index.ts | 3 +++ packages/adapters/hermes/src/index.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/adapters/hermes/src/gateway/index.ts b/packages/adapters/hermes/src/gateway/index.ts index 6d10a1d4ea..10a68ecd33 100644 --- a/packages/adapters/hermes/src/gateway/index.ts +++ b/packages/adapters/hermes/src/gateway/index.ts @@ -14,6 +14,9 @@ const sessionManagement: AdapterSessionManagement = { maxSessionRuns: 0, maxRawInputTokens: 0, maxSessionAgeHours: 0, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, }, }; diff --git a/packages/adapters/hermes/src/index.ts b/packages/adapters/hermes/src/index.ts index 3494bf5970..e4b896eb34 100644 --- a/packages/adapters/hermes/src/index.ts +++ b/packages/adapters/hermes/src/index.ts @@ -54,6 +54,9 @@ const sessionManagement: AdapterSessionManagement = { maxSessionRuns: 0, maxRawInputTokens: 0, maxSessionAgeHours: 0, + maxCachedInputTokens: 0, + rotateOnZeroOpenIssues: false, + rotateOnNewIssueWake: false, }, }; From 2a663834bb6613b5331355f64d92e4f79ba774ac Mon Sep 17 00:00:00 2001 From: Solved Engineer Agent Date: Sat, 12 Sep 2026 12:15:29 +0200 Subject: [PATCH 4/4] feat(heartbeat): close ADR-0044 gaps - session-cumulative T1, T5 turn trigger, pre-reset save notice SOL-5441 follow-up to SOL-5430/PR #6808: 1. T1 now sums cache_read across all heartbeatRuns rows for the session (sessionCachedInputTokens) instead of reading only the latest run's usage, so many-small-runs sessions can no longer stay under threshold in aggregate while still ballooning context (claude_local default raised to 5,000,000 to match the session-cumulative semantics). 2. New T5 trigger fires clearSession once session-cumulative turn count exceeds maxSessionTurns (default 150 for claude_local), complementing the existing per-run maxTurnsPerRun continuation cap which never rotated the session on its own. 3. New pre-rotation advisory (decideSessionNearRotationWarning) injects a notice into the current run's prompt once T1/T5 counters cross 80% of threshold, telling the claude-local agent to persist WORKING-CONTEXT/ save_to_knowledge before a future dispatch rotates the session out from under it. Updated SessionCompactionPolicy (maxSessionTurns), all adapter defaults (claude-local, hermes gateway/local, cursor, opencode_local, codex_local), and heartbeat/session-compaction test suites accordingly. Co-Authored-By: Paperclip --- .../adapter-utils/src/session-compaction.ts | 15 +- .../claude-local/src/server/execute.ts | 6 + packages/adapters/hermes/src/gateway/index.ts | 1 + packages/adapters/hermes/src/index.ts | 1 + .../heartbeat-session-rotation-policy.test.ts | 141 +++++++++++++++++- .../heartbeat-workspace-session.test.ts | 11 +- server/src/services/heartbeat.ts | 127 +++++++++++++++- 7 files changed, 285 insertions(+), 17 deletions(-) diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index ea264f1936..204baafc81 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -4,9 +4,10 @@ export interface SessionCompactionPolicy { maxRawInputTokens: number; maxSessionAgeHours: number; // ADR-0044 additions - maxCachedInputTokens: number; // T1: threshold on cache_read_input_tokens (0 = disabled) + maxCachedInputTokens: number; // T1: threshold on session-cumulative cache_read_input_tokens (0 = disabled) rotateOnZeroOpenIssues: boolean; // T3: rotate when openIssuesCount == 0 rotateOnNewIssueWake: boolean; // T4: rotate when wakeReason == "issue_assigned" + maxSessionTurns: number; // T5: threshold on session-cumulative turn count (0 = disabled) } export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none"; @@ -32,6 +33,7 @@ const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { maxCachedInputTokens: 0, rotateOnZeroOpenIssues: false, rotateOnNewIssueWake: false, + maxSessionTurns: 0, }; // Adapters with native context management still participate in session resume, @@ -44,6 +46,7 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = { maxCachedInputTokens: 0, rotateOnZeroOpenIssues: false, rotateOnNewIssueWake: false, + maxSessionTurns: 0, }; // ADR-0044 «Heartbeat session lifecycle» — fresh-session policy applied to claude_local by default. @@ -53,9 +56,10 @@ const CLAUDE_LOCAL_ADR_0044_POLICY: SessionCompactionPolicy = { maxSessionRuns: 0, // not used (variant D rejected by ADR-0044) maxRawInputTokens: 0, // not used (cached_input is the meaningful signal) maxSessionAgeHours: 6, // T2 default for execution agents - maxCachedInputTokens: 500_000, // T1 default for execution agents + maxCachedInputTokens: 5_000_000, // T1 default: session-cumulative cache_read across all runs, not per-run rotateOnZeroOpenIssues: true, // T3 rotateOnNewIssueWake: true, // T4 + maxSessionTurns: 150, // T5 default: session-cumulative turns, matches maxTurnsPerRun continuation cap }; export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ @@ -171,6 +175,7 @@ export function readSessionCompactionOverride(runtimeConfig: unknown): Partial) { return ( policy.maxSessionRuns > 0 || @@ -231,6 +239,7 @@ export function hasSessionCompactionThresholds(policy: Pick< policy.maxSessionAgeHours > 0 || policy.maxCachedInputTokens > 0 || policy.rotateOnZeroOpenIssues === true || - policy.rotateOnNewIssueWake === true + policy.rotateOnNewIssueWake === true || + policy.maxSessionTurns > 0 ); } diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 02230de87a..f0be62e520 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -854,10 +854,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise = {}): SessionCompactionTriggerInput { @@ -30,6 +33,8 @@ function buildInput(overrides: Partial = {}): Ses policy: DISABLED_POLICY, runsCount: 1, latestRawUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + sessionCachedInputTokens: 0, + sessionTurnsCount: 0, sessionAgeHours: 0, openIssuesCount: 1, wakeReason: null, @@ -42,17 +47,79 @@ describe("decideSessionCompactionTrigger", () => { expect(decideSessionCompactionTrigger(buildInput({ policy: ADR_0044_DEFAULT_POLICY }))).toBeNull(); }); - it("T1 triggers when cached_input >= maxCachedInputTokens", () => { + it("T1 triggers when session-cumulative cache_read >= maxCachedInputTokens", () => { const result = decideSessionCompactionTrigger( buildInput({ policy: ADR_0044_DEFAULT_POLICY, - latestRawUsage: { inputTokens: 0, cachedInputTokens: 600_000, outputTokens: 0 }, + sessionCachedInputTokens: 600_000, }), ); expect(result?.triggeredBy).toBe("t1"); expect(result?.reason).toMatch(/cache_read reached 600,000 tokens/); }); + it("T1 does NOT trigger from a single run's latestRawUsage alone (must be session-cumulative)", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + latestRawUsage: { inputTokens: 0, cachedInputTokens: 600_000, outputTokens: 0 }, + sessionCachedInputTokens: 200_000, + }), + ); + expect(result).toBeNull(); + }); + + it("T5 triggers when session-cumulative turns >= maxSessionTurns", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionTurnsCount: 150, + }), + ); + expect(result?.triggeredBy).toBe("t5"); + expect(result?.reason).toMatch(/turns reached 150/); + }); + + it("T5 does NOT trigger below maxSessionTurns", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionTurnsCount: 149, + }), + ); + expect(result).toBeNull(); + }); + + it("ignores T5 when maxSessionTurns is 0 (disabled)", () => { + const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxSessionTurns: 0 }; + const result = decideSessionCompactionTrigger( + buildInput({ policy, sessionTurnsCount: 1_000 }), + ); + expect(result).toBeNull(); + }); + + it("priority: T1 wins over T5 when both conditions hold", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionCachedInputTokens: 600_000, + sessionTurnsCount: 200, + }), + ); + expect(result?.triggeredBy).toBe("t1"); + }); + + it("priority: T5 wins over T2 when both conditions hold", () => { + const result = decideSessionCompactionTrigger( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionTurnsCount: 200, + sessionAgeHours: 10, + }), + ); + expect(result?.triggeredBy).toBe("t5"); + }); + it("T2 triggers when sessionAgeHours >= maxSessionAgeHours", () => { const result = decideSessionCompactionTrigger( buildInput({ @@ -134,7 +201,7 @@ describe("decideSessionCompactionTrigger", () => { buildInput({ policy, runsCount: 6, - latestRawUsage: { inputTokens: 0, cachedInputTokens: 800_000, outputTokens: 0 }, + sessionCachedInputTokens: 800_000, }), ); expect(result?.triggeredBy).toBe("legacy_runs"); @@ -144,7 +211,7 @@ describe("decideSessionCompactionTrigger", () => { const result = decideSessionCompactionTrigger( buildInput({ policy: ADR_0044_DEFAULT_POLICY, - latestRawUsage: { inputTokens: 0, cachedInputTokens: 800_000, outputTokens: 0 }, + sessionCachedInputTokens: 800_000, sessionAgeHours: 10, }), ); @@ -190,7 +257,7 @@ describe("decideSessionCompactionTrigger", () => { const result = decideSessionCompactionTrigger( buildInput({ policy, - latestRawUsage: { inputTokens: 0, cachedInputTokens: 10_000_000, outputTokens: 0 }, + sessionCachedInputTokens: 10_000_000, }), ); expect(result).toBeNull(); @@ -202,3 +269,67 @@ describe("decideSessionCompactionTrigger", () => { expect(result).toBeNull(); }); }); + +describe("decideSessionNearRotationWarning", () => { + it("returns null when nothing is close to threshold", () => { + expect( + decideSessionNearRotationWarning(buildInput({ policy: ADR_0044_DEFAULT_POLICY })), + ).toBeNull(); + }); + + it("warns when cache_read is at 80% of maxCachedInputTokens", () => { + const result = decideSessionNearRotationWarning( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionCachedInputTokens: 400_000, + }), + ); + expect(result?.reason).toMatch(/cache_read is at 400,000 tokens/); + }); + + it("does NOT warn just below the 80% cache_read ratio", () => { + const result = decideSessionNearRotationWarning( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionCachedInputTokens: 399_999, + }), + ); + expect(result).toBeNull(); + }); + + it("warns when turns are at 80% of maxSessionTurns", () => { + const result = decideSessionNearRotationWarning( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionTurnsCount: 120, + }), + ); + expect(result?.reason).toMatch(/used 120 turns/); + }); + + it("does NOT warn just below the 80% turns ratio", () => { + const result = decideSessionNearRotationWarning( + buildInput({ + policy: ADR_0044_DEFAULT_POLICY, + sessionTurnsCount: 119, + }), + ); + expect(result).toBeNull(); + }); + + it("ignores cache_read ratio when maxCachedInputTokens is 0 (disabled)", () => { + const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxCachedInputTokens: 0 }; + const result = decideSessionNearRotationWarning( + buildInput({ policy, sessionCachedInputTokens: 10_000_000 }), + ); + expect(result).toBeNull(); + }); + + it("ignores turns ratio when maxSessionTurns is 0 (disabled)", () => { + const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxSessionTurns: 0 }; + const result = decideSessionNearRotationWarning( + buildInput({ policy, sessionTurnsCount: 1_000 }), + ); + expect(result).toBeNull(); + }); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index a7171f9e72..ede7416bb7 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -3041,6 +3041,7 @@ describe("parseSessionCompactionPolicy", () => { maxCachedInputTokens: 0, rotateOnZeroOpenIssues: false, rotateOnNewIssueWake: false, + maxSessionTurns: 0, }); }); @@ -3050,9 +3051,10 @@ describe("parseSessionCompactionPolicy", () => { maxSessionRuns: 0, maxRawInputTokens: 0, maxSessionAgeHours: 6, - maxCachedInputTokens: 500_000, + maxCachedInputTokens: 5_000_000, rotateOnZeroOpenIssues: true, rotateOnNewIssueWake: true, + maxSessionTurns: 150, }); }); @@ -3065,6 +3067,7 @@ describe("parseSessionCompactionPolicy", () => { maxCachedInputTokens: 0, rotateOnZeroOpenIssues: false, rotateOnNewIssueWake: false, + maxSessionTurns: 0, }); expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({ enabled: true, @@ -3074,6 +3077,7 @@ describe("parseSessionCompactionPolicy", () => { maxCachedInputTokens: 0, rotateOnZeroOpenIssues: false, rotateOnNewIssueWake: false, + maxSessionTurns: 0, }); }); @@ -3097,10 +3101,11 @@ describe("parseSessionCompactionPolicy", () => { maxCachedInputTokens: 0, rotateOnZeroOpenIssues: false, rotateOnNewIssueWake: false, + maxSessionTurns: 0, }); }); - it("supports ADR-0044 per-agent overrides (T1/T2/T3/T4)", () => { + it("supports ADR-0044 per-agent overrides (T1/T2/T3/T4/T5)", () => { expect( parseSessionCompactionPolicy( buildAgent("claude_local", { @@ -3110,6 +3115,7 @@ describe("parseSessionCompactionPolicy", () => { maxSessionAgeHours: 12, rotateOnZeroOpenIssues: false, rotateOnNewIssueWake: false, + maxSessionTurns: 100, }, }, }), @@ -3122,6 +3128,7 @@ describe("parseSessionCompactionPolicy", () => { maxCachedInputTokens: 1_000_000, rotateOnZeroOpenIssues: false, rotateOnNewIssueWake: false, + maxSessionTurns: 100, }); }); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c5767f4146..4b64ee7891 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3526,6 +3526,7 @@ export type SessionCompactionTrigger = | "t2" | "t3" | "t4" + | "t5" | "legacy_runs" | "legacy_raw_input"; @@ -3535,24 +3536,40 @@ type SessionCompactionDecision = { triggeredBy: SessionCompactionTrigger | null; handoffMarkdown: string | null; previousRunId: string | null; + nearRotationReason: string | null; }; export interface SessionCompactionTriggerInput { policy: SessionCompactionPolicy; runsCount: number; latestRawUsage: UsageTotals | null; + // Session-cumulative cache_read across all heartbeatRuns rows for this session (T1). + // Distinct from latestRawUsage, which stays single-run for legacy_raw_input. + sessionCachedInputTokens: number | null; + // Session-cumulative turn count (sum of each run's num_turns result) (T5). + sessionTurnsCount: number | null; sessionAgeHours: number; openIssuesCount: number | null; wakeReason: string | null; } // Pure decision rule for session rotation. Priority order: -// legacy_runs > legacy_raw_input > T1 (cached input) > T2 (age) > T3 (zero open issues) > T4 (new issue wake). +// legacy_runs > legacy_raw_input > T1 (session-cumulative cached input) > T5 (session-cumulative turns) > +// T2 (age) > T3 (zero open issues) > T4 (new issue wake). // First match wins so triggeredBy is deterministic for retrospective tuning (ADR-0044 §Acceptance criteria). export function decideSessionCompactionTrigger( input: SessionCompactionTriggerInput, ): { reason: string; triggeredBy: SessionCompactionTrigger } | null { - const { policy, runsCount, latestRawUsage, sessionAgeHours, openIssuesCount, wakeReason } = input; + const { + policy, + runsCount, + latestRawUsage, + sessionCachedInputTokens, + sessionTurnsCount, + sessionAgeHours, + openIssuesCount, + wakeReason, + } = input; if (policy.maxSessionRuns > 0 && runsCount > policy.maxSessionRuns) { return { reason: `session exceeded ${policy.maxSessionRuns} runs`, triggeredBy: "legacy_runs" }; @@ -3571,16 +3588,28 @@ export function decideSessionCompactionTrigger( } if ( policy.maxCachedInputTokens > 0 && - latestRawUsage && - latestRawUsage.cachedInputTokens >= policy.maxCachedInputTokens + typeof sessionCachedInputTokens === "number" && + sessionCachedInputTokens >= policy.maxCachedInputTokens ) { return { reason: - `session cache_read reached ${formatCount(latestRawUsage.cachedInputTokens)} tokens ` + + `session cache_read reached ${formatCount(sessionCachedInputTokens)} tokens ` + `(threshold ${formatCount(policy.maxCachedInputTokens)})`, triggeredBy: "t1", }; } + if ( + policy.maxSessionTurns > 0 && + typeof sessionTurnsCount === "number" && + sessionTurnsCount >= policy.maxSessionTurns + ) { + return { + reason: + `session turns reached ${formatCount(sessionTurnsCount)} ` + + `(threshold ${formatCount(policy.maxSessionTurns)})`, + triggeredBy: "t5", + }; + } if (policy.maxSessionAgeHours > 0 && sessionAgeHours >= policy.maxSessionAgeHours) { return { reason: `session age reached ${Math.floor(sessionAgeHours)} hours`, triggeredBy: "t2" }; } @@ -3597,6 +3626,43 @@ export function decideSessionCompactionTrigger( return null; } +// Pre-rotation advisory: not a trigger, just a heads-up injected into the *current* +// run when session-cumulative counters are already close to a T1/T5 threshold, so the +// agent gets a chance to persist WORKING-CONTEXT/save_to_knowledge before a future +// dispatch's evaluateSessionCompaction call actually rotates the session out from +// under it (that call only runs between dispatches, never mid-run). +const NEAR_ROTATION_WARNING_RATIO = 0.8; + +export function decideSessionNearRotationWarning( + input: SessionCompactionTriggerInput, +): { reason: string } | null { + const { policy, sessionCachedInputTokens, sessionTurnsCount } = input; + + if ( + policy.maxCachedInputTokens > 0 && + typeof sessionCachedInputTokens === "number" && + sessionCachedInputTokens >= policy.maxCachedInputTokens * NEAR_ROTATION_WARNING_RATIO + ) { + return { + reason: + `session cache_read is at ${formatCount(sessionCachedInputTokens)} tokens, ` + + `approaching the ${formatCount(policy.maxCachedInputTokens)} rotation threshold`, + }; + } + if ( + policy.maxSessionTurns > 0 && + typeof sessionTurnsCount === "number" && + sessionTurnsCount >= policy.maxSessionTurns * NEAR_ROTATION_WARNING_RATIO + ) { + return { + reason: + `session has used ${formatCount(sessionTurnsCount)} turns, ` + + `approaching the ${formatCount(policy.maxSessionTurns)} turn rotation threshold`, + }; + } + return null; +} + interface ParsedIssueAssigneeAdapterOverrides { adapterConfig: Record | null; useProjectWorkspace: boolean | null; @@ -11624,6 +11690,7 @@ export function heartbeatService( triggeredBy: null, handoffMarkdown: null, previousRunId: null, + nearRotationReason: null, }; } @@ -11635,6 +11702,7 @@ export function heartbeatService( triggeredBy: null, handoffMarkdown: null, previousRunId: null, + nearRotationReason: null, }; } @@ -11667,6 +11735,7 @@ export function heartbeatService( triggeredBy: null, handoffMarkdown: null, previousRunId: null, + nearRotationReason: null, }; } @@ -11686,24 +11755,62 @@ export function heartbeatService( ) : 0; - const trigger = decideSessionCompactionTrigger({ + let sessionCachedInputTokens: number | null = null; + let sessionTurnsCount: number | null = null; + if (policy.maxCachedInputTokens > 0 || policy.maxSessionTurns > 0) { + const [aggregate] = await db + .select({ + sessionCachedInputTokens: sql`sum( + coalesce( + (${heartbeatRuns.usageJson} ->> 'rawCachedInputTokens')::numeric, + (${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric, + 0 + ) + )`.as("sessionCachedInputTokens"), + sessionTurnsCount: sql`sum( + coalesce((${heartbeatRuns.resultJson} ->> 'num_turns')::numeric, 0) + )`.as("sessionTurnsCount"), + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agent.id), + eq(heartbeatRuns.sessionIdAfter, sessionId), + ), + ); + sessionCachedInputTokens = + policy.maxCachedInputTokens > 0 && aggregate?.sessionCachedInputTokens != null + ? Number(aggregate.sessionCachedInputTokens) + : null; + sessionTurnsCount = + policy.maxSessionTurns > 0 && aggregate?.sessionTurnsCount != null + ? Number(aggregate.sessionTurnsCount) + : null; + } + + const triggerInput: SessionCompactionTriggerInput = { policy, runsCount: runs.length, latestRawUsage, + sessionCachedInputTokens, + sessionTurnsCount, sessionAgeHours, openIssuesCount: typeof input.openIssuesCount === "number" ? input.openIssuesCount : null, wakeReason: input.wakeReason ?? null, - }); + }; + const trigger = decideSessionCompactionTrigger(triggerInput); const reason: string | null = trigger?.reason ?? null; const triggeredBy: SessionCompactionTrigger | null = trigger?.triggeredBy ?? null; if (!reason || !latestRun) { + const nearRotationReason = decideSessionNearRotationWarning(triggerInput)?.reason ?? null; return { rotate: false, reason: null, triggeredBy: null, handoffMarkdown: null, previousRunId: latestRun?.id ?? null, + nearRotationReason, }; } @@ -11742,6 +11849,7 @@ export function heartbeatService( triggeredBy, handoffMarkdown, previousRunId: latestRun.id, + nearRotationReason: null, }; } @@ -21695,6 +21803,11 @@ export function heartbeatService( delete context.paperclipSessionRotationReason; delete context.paperclipPreviousSessionId; } + if (!sessionCompaction.rotate && sessionCompaction.nearRotationReason) { + context.paperclipSessionNearRotationNotice = sessionCompaction.nearRotationReason; + } else { + delete context.paperclipSessionNearRotationNotice; + } const runtimeForAdapter = { sessionId: runtimeSessionIdForAdapter,