Merge 2a663834bb into c9e3bb7ca4
This commit is contained in:
commit
e4f7ae7b1c
|
|
@ -3,6 +3,11 @@ export interface SessionCompactionPolicy {
|
|||
maxSessionRuns: number;
|
||||
maxRawInputTokens: number;
|
||||
maxSessionAgeHours: number;
|
||||
// ADR-0044 additions
|
||||
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";
|
||||
|
|
@ -25,6 +30,10 @@ const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = {
|
|||
maxSessionRuns: 200,
|
||||
maxRawInputTokens: 2_000_000,
|
||||
maxSessionAgeHours: 72,
|
||||
maxCachedInputTokens: 0,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 0,
|
||||
};
|
||||
|
||||
// Adapters with native context management still participate in session resume,
|
||||
|
|
@ -34,6 +43,23 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
|
|||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 0,
|
||||
maxCachedInputTokens: 0,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 0,
|
||||
};
|
||||
|
||||
// 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: 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([
|
||||
|
|
@ -52,7 +78,7 @@ export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement
|
|||
claude_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
defaultSessionCompaction: CLAUDE_LOCAL_ADR_0044_POLICY,
|
||||
},
|
||||
codex_local: {
|
||||
supportsSessionResume: true,
|
||||
|
|
@ -146,11 +172,19 @@ export function readSessionCompactionOverride(runtimeConfig: unknown): Partial<S
|
|||
const maxSessionRuns = readNumber(compaction.maxSessionRuns);
|
||||
const maxRawInputTokens = readNumber(compaction.maxRawInputTokens);
|
||||
const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours);
|
||||
const maxCachedInputTokens = readNumber(compaction.maxCachedInputTokens);
|
||||
const rotateOnZeroOpenIssues = readBoolean(compaction.rotateOnZeroOpenIssues);
|
||||
const rotateOnNewIssueWake = readBoolean(compaction.rotateOnNewIssueWake);
|
||||
const maxSessionTurns = readNumber(compaction.maxSessionTurns);
|
||||
|
||||
if (enabled !== undefined) explicit.enabled = enabled;
|
||||
if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns;
|
||||
if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens;
|
||||
if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours;
|
||||
if (maxCachedInputTokens !== undefined) explicit.maxCachedInputTokens = maxCachedInputTokens;
|
||||
if (rotateOnZeroOpenIssues !== undefined) explicit.rotateOnZeroOpenIssues = rotateOnZeroOpenIssues;
|
||||
if (rotateOnNewIssueWake !== undefined) explicit.rotateOnNewIssueWake = rotateOnNewIssueWake;
|
||||
if (maxSessionTurns !== undefined) explicit.maxSessionTurns = maxSessionTurns;
|
||||
|
||||
return explicit;
|
||||
}
|
||||
|
|
@ -174,6 +208,10 @@ export function resolveSessionCompactionPolicy(
|
|||
maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns,
|
||||
maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens,
|
||||
maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours,
|
||||
maxCachedInputTokens: explicitOverride.maxCachedInputTokens ?? basePolicy.maxCachedInputTokens,
|
||||
rotateOnZeroOpenIssues: explicitOverride.rotateOnZeroOpenIssues ?? basePolicy.rotateOnZeroOpenIssues,
|
||||
rotateOnNewIssueWake: explicitOverride.rotateOnNewIssueWake ?? basePolicy.rotateOnNewIssueWake,
|
||||
maxSessionTurns: explicitOverride.maxSessionTurns ?? basePolicy.maxSessionTurns,
|
||||
},
|
||||
adapterSessionManagement,
|
||||
explicitOverride,
|
||||
|
|
@ -187,7 +225,21 @@ export function resolveSessionCompactionPolicy(
|
|||
|
||||
export function hasSessionCompactionThresholds(policy: Pick<
|
||||
SessionCompactionPolicy,
|
||||
"maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours"
|
||||
| "maxSessionRuns"
|
||||
| "maxRawInputTokens"
|
||||
| "maxSessionAgeHours"
|
||||
| "maxCachedInputTokens"
|
||||
| "rotateOnZeroOpenIssues"
|
||||
| "rotateOnNewIssueWake"
|
||||
| "maxSessionTurns"
|
||||
>) {
|
||||
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 ||
|
||||
policy.maxSessionTurns > 0
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -858,10 +858,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
? ""
|
||||
: renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const nearRotationNotice = asString(context.paperclipSessionNearRotationNotice, "").trim();
|
||||
const nearRotationAdvisory = nearRotationNotice
|
||||
? `## Session rotation approaching\n\n${nearRotationNotice}. This session may be rotated to a fresh one soon. Before finishing this run, persist any long-term context worth keeping (open threads, decisions, next steps) now via WORKING-CONTEXT notes or save_to_knowledge — a rotated session will not retain this conversation's in-memory state.`
|
||||
: "";
|
||||
const prompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
nearRotationAdvisory,
|
||||
taskContextNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
|
|
@ -870,6 +875,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
nearRotationAdvisoryChars: nearRotationAdvisory.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ const sessionManagement: AdapterSessionManagement = {
|
|||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 0,
|
||||
maxCachedInputTokens: 0,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 0,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ const sessionManagement: AdapterSessionManagement = {
|
|||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 0,
|
||||
maxCachedInputTokens: 0,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 0,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decideSessionCompactionTrigger,
|
||||
decideSessionNearRotationWarning,
|
||||
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,
|
||||
maxSessionTurns: 0,
|
||||
};
|
||||
|
||||
const ADR_0044_DEFAULT_POLICY: SessionCompactionPolicy = {
|
||||
enabled: true,
|
||||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 6,
|
||||
maxCachedInputTokens: 500_000,
|
||||
rotateOnZeroOpenIssues: true,
|
||||
rotateOnNewIssueWake: true,
|
||||
maxSessionTurns: 150,
|
||||
};
|
||||
|
||||
function buildInput(overrides: Partial<SessionCompactionTriggerInput> = {}): SessionCompactionTriggerInput {
|
||||
return {
|
||||
policy: DISABLED_POLICY,
|
||||
runsCount: 1,
|
||||
latestRawUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
sessionCachedInputTokens: 0,
|
||||
sessionTurnsCount: 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 session-cumulative cache_read >= maxCachedInputTokens", () => {
|
||||
const result = decideSessionCompactionTrigger(
|
||||
buildInput({
|
||||
policy: ADR_0044_DEFAULT_POLICY,
|
||||
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({
|
||||
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,
|
||||
sessionCachedInputTokens: 800_000,
|
||||
}),
|
||||
);
|
||||
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,
|
||||
sessionCachedInputTokens: 800_000,
|
||||
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,
|
||||
sessionCachedInputTokens: 10_000_000,
|
||||
}),
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -3032,18 +3032,29 @@ 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,
|
||||
maxSessionTurns: 0,
|
||||
});
|
||||
});
|
||||
|
||||
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: 5_000_000,
|
||||
rotateOnZeroOpenIssues: true,
|
||||
rotateOnNewIssueWake: true,
|
||||
maxSessionTurns: 150,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -3053,12 +3064,20 @@ describe("parseSessionCompactionPolicy", () => {
|
|||
maxSessionRuns: 200,
|
||||
maxRawInputTokens: 2_000_000,
|
||||
maxSessionAgeHours: 72,
|
||||
maxCachedInputTokens: 0,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 0,
|
||||
});
|
||||
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 200,
|
||||
maxRawInputTokens: 2_000_000,
|
||||
maxSessionAgeHours: 72,
|
||||
maxCachedInputTokens: 0,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -3079,6 +3098,37 @@ describe("parseSessionCompactionPolicy", () => {
|
|||
maxSessionRuns: 25,
|
||||
maxRawInputTokens: 500_000,
|
||||
maxSessionAgeHours: 0,
|
||||
maxCachedInputTokens: 0,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports ADR-0044 per-agent overrides (T1/T2/T3/T4/T5)", () => {
|
||||
expect(
|
||||
parseSessionCompactionPolicy(
|
||||
buildAgent("claude_local", {
|
||||
heartbeat: {
|
||||
sessionCompaction: {
|
||||
maxCachedInputTokens: 1_000_000,
|
||||
maxSessionAgeHours: 12,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 100,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 12,
|
||||
maxCachedInputTokens: 1_000_000,
|
||||
rotateOnZeroOpenIssues: false,
|
||||
rotateOnNewIssueWake: false,
|
||||
maxSessionTurns: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -830,6 +830,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;
|
||||
|
|
@ -3530,19 +3531,154 @@ interface WakeupOptions {
|
|||
allowRunCoalescing?: boolean;
|
||||
}
|
||||
|
||||
type UsageTotals = {
|
||||
export type UsageTotals = {
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
};
|
||||
|
||||
export type SessionCompactionTrigger =
|
||||
| "t1"
|
||||
| "t2"
|
||||
| "t3"
|
||||
| "t4"
|
||||
| "t5"
|
||||
| "legacy_runs"
|
||||
| "legacy_raw_input";
|
||||
|
||||
type SessionCompactionDecision = {
|
||||
rotate: boolean;
|
||||
reason: string | null;
|
||||
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 (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,
|
||||
sessionCachedInputTokens,
|
||||
sessionTurnsCount,
|
||||
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 &&
|
||||
typeof sessionCachedInputTokens === "number" &&
|
||||
sessionCachedInputTokens >= policy.maxCachedInputTokens
|
||||
) {
|
||||
return {
|
||||
reason:
|
||||
`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" };
|
||||
}
|
||||
if (
|
||||
policy.rotateOnZeroOpenIssues &&
|
||||
typeof openIssuesCount === "number" &&
|
||||
openIssuesCount === 0
|
||||
) {
|
||||
return { reason: "no open issues for agent", triggeredBy: "t3" };
|
||||
}
|
||||
if (policy.rotateOnNewIssueWake && wakeReason === ISSUE_ASSIGNED_WAKE_REASON) {
|
||||
return { reason: "wake triggered by new issue assignment", triggeredBy: "t4" };
|
||||
}
|
||||
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<string, unknown> | null;
|
||||
useProjectWorkspace: boolean | null;
|
||||
|
|
@ -11777,24 +11913,31 @@ export function heartbeatService(
|
|||
sessionId: string | null;
|
||||
issueId: string | null;
|
||||
continuationSummaryBody?: string | null;
|
||||
wakeReason?: string | null;
|
||||
openIssuesCount?: number | null;
|
||||
policy?: SessionCompactionPolicy;
|
||||
}): Promise<SessionCompactionDecision> {
|
||||
const { agent, sessionId, issueId } = input;
|
||||
if (!sessionId) {
|
||||
return {
|
||||
rotate: false,
|
||||
reason: null,
|
||||
triggeredBy: null,
|
||||
handoffMarkdown: null,
|
||||
previousRunId: null,
|
||||
nearRotationReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const policy = parseSessionCompactionPolicy(agent);
|
||||
const policy = input.policy ?? parseSessionCompactionPolicy(agent);
|
||||
if (!policy.enabled || !hasSessionCompactionThresholds(policy)) {
|
||||
return {
|
||||
rotate: false,
|
||||
reason: null,
|
||||
triggeredBy: null,
|
||||
handoffMarkdown: null,
|
||||
previousRunId: null,
|
||||
nearRotationReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -11824,8 +11967,10 @@ export function heartbeatService(
|
|||
return {
|
||||
rotate: false,
|
||||
reason: null,
|
||||
triggeredBy: null,
|
||||
handoffMarkdown: null,
|
||||
previousRunId: null,
|
||||
nearRotationReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -11845,30 +11990,62 @@ 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`;
|
||||
let sessionCachedInputTokens: number | null = null;
|
||||
let sessionTurnsCount: number | null = null;
|
||||
if (policy.maxCachedInputTokens > 0 || policy.maxSessionTurns > 0) {
|
||||
const [aggregate] = await db
|
||||
.select({
|
||||
sessionCachedInputTokens: sql<string>`sum(
|
||||
coalesce(
|
||||
(${heartbeatRuns.usageJson} ->> 'rawCachedInputTokens')::numeric,
|
||||
(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric,
|
||||
0
|
||||
)
|
||||
)`.as("sessionCachedInputTokens"),
|
||||
sessionTurnsCount: sql<string>`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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -11904,8 +12081,10 @@ export function heartbeatService(
|
|||
return {
|
||||
rotate: true,
|
||||
reason,
|
||||
triggeredBy,
|
||||
handoffMarkdown,
|
||||
previousRunId: latestRun.id,
|
||||
nearRotationReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -16620,6 +16799,19 @@ export function heartbeatService(
|
|||
return Number(count ?? 0);
|
||||
}
|
||||
|
||||
async function countOpenIssuesForAgent(agentId: string) {
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`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",
|
||||
|
|
@ -21979,11 +22171,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 =
|
||||
|
|
@ -22004,6 +22204,11 @@ export function heartbeatService(
|
|||
delete context.paperclipSessionRotationReason;
|
||||
delete context.paperclipPreviousSessionId;
|
||||
}
|
||||
if (!sessionCompaction.rotate && sessionCompaction.nearRotationReason) {
|
||||
context.paperclipSessionNearRotationNotice = sessionCompaction.nearRotationReason;
|
||||
} else {
|
||||
delete context.paperclipSessionNearRotationNotice;
|
||||
}
|
||||
|
||||
if (managedAiRuntime) {
|
||||
sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.identity;
|
||||
|
|
@ -24182,6 +24387,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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue