fix: skip cache-ttl append after compaction to prevent double compaction (#28548)

Merged via squash.

Prepared head SHA: a4114a52bcff6ed4057cc54d3c629bd723f3d420
Co-authored-by: MoerAI <26067127+MoerAI@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
ToToKr 2026-03-13 07:17:18 +09:00 committed by GitHub
parent 7332e6d609
commit 9cd54ea882
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 132 additions and 1 deletions

View File

@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
- Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted.
- Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.
## 2026.3.11

View File

@ -257,6 +257,14 @@ const testModel = {
input: ["text"],
} as unknown as Model<Api>;
const cacheTtlEligibleModel = {
api: "anthropic",
provider: "anthropic",
compat: {},
contextWindow: 8192,
input: ["text"],
} as unknown as Model<Api>;
describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => {
const tempPaths: string[] = [];
@ -382,6 +390,123 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => {
});
});
describe("runEmbeddedAttempt cache-ttl tracking after compaction", () => {
const tempPaths: string[] = [];
beforeEach(() => {
hoisted.createAgentSessionMock.mockReset();
hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager);
hoisted.resolveSandboxContextMock.mockReset();
hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({
release: async () => {},
});
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.branch.mockReset();
hoisted.sessionManager.resetLeaf.mockReset();
hoisted.sessionManager.buildSessionContext.mockReset().mockReturnValue({ messages: [] });
hoisted.sessionManager.appendCustomEntry.mockReset();
});
afterEach(async () => {
while (tempPaths.length > 0) {
const target = tempPaths.pop();
if (target) {
await fs.rm(target, { recursive: true, force: true });
}
}
});
async function runAttemptWithCacheTtl(compactionCount: number) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cache-ttl-workspace-"));
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cache-ttl-agent-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(() => ({
...createSubscriptionMock(),
getCompactionCount: () => compactionCount,
}));
hoisted.createAgentSessionMock.mockImplementation(async () => {
const session: MutableSession = {
sessionId: "embedded-session",
messages: [],
isCompacting: false,
isStreaming: false,
agent: {
replaceMessages: (messages: unknown[]) => {
session.messages = [...messages];
},
},
prompt: async () => {
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
abort: async () => {},
dispose: () => {},
steer: async () => {},
};
return { session };
});
return await runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:test-cache-ttl",
sessionFile,
workspaceDir,
agentDir,
config: {
agents: {
defaults: {
contextPruning: {
mode: "cache-ttl",
},
},
},
},
prompt: "hello",
timeoutMs: 10_000,
runId: `run-cache-ttl-${compactionCount}`,
provider: "anthropic",
modelId: "claude-sonnet-4-20250514",
model: cacheTtlEligibleModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
});
}
it("skips cache-ttl append when compaction completed during the attempt", async () => {
const result = await runAttemptWithCacheTtl(1);
expect(result.promptError).toBeNull();
expect(hoisted.sessionManager.appendCustomEntry).not.toHaveBeenCalledWith(
"openclaw.cache-ttl",
expect.anything(),
);
});
it("appends cache-ttl when no compaction completed during the attempt", async () => {
const result = await runAttemptWithCacheTtl(0);
expect(result.promptError).toBeNull();
expect(hoisted.sessionManager.appendCustomEntry).toHaveBeenCalledWith(
"openclaw.cache-ttl",
expect.objectContaining({
provider: "anthropic",
modelId: "claude-sonnet-4-20250514",
timestamp: expect.any(Number),
}),
);
});
});
describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
const tempPaths: string[] = [];
const sessionKey = "agent:main:discord:channel:test-ctx-engine";

View File

@ -2542,14 +2542,19 @@ export async function runEmbeddedAttempt(
}
}
// Check if ANY compaction occurred during the entire attempt (prompt + retry).
// Using a cumulative count (> 0) instead of a delta check avoids missing
// compactions that complete during activeSession.prompt() before the delta
// baseline is sampled.
const compactionOccurredThisAttempt = getCompactionCount() > 0;
// Append cache-TTL timestamp AFTER prompt + compaction retry completes.
// Previously this was before the prompt, which caused a custom entry to be
// inserted between compaction and the next prompt — breaking the
// prepareCompaction() guard that checks the last entry type, leading to
// double-compaction. See: https://github.com/openclaw/openclaw/issues/9282
// Skip when timed out during compaction — session state may be inconsistent.
// Also skip when compaction ran this attempt — appending a custom entry
// after compaction would break the guard again. See: #28491
if (!timedOutDuringCompaction && !compactionOccurredThisAttempt) {
const shouldTrackCacheTtl =
params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" &&