Skip to content

Commit 1c59b42

Browse files
committed
fix: persist compaction rotation target identity
1 parent 7acea06 commit 1c59b42

11 files changed

Lines changed: 214 additions & 13 deletions

src/agents/embedded-agent-runner/compact.hooks.harness.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,8 @@ export async function loadCompactHooksHarness(): Promise<{
730730
);
731731
return {
732732
...actual,
733+
rotateAgentRunSessionTargetAfterCompaction: rotateTranscriptAfterCompactionMock,
734+
rotateAgentRunSessionTargetFileAfterCompaction: rotateTranscriptAfterCompactionMock,
733735
rotateTranscriptAfterCompaction: rotateTranscriptAfterCompactionMock,
734736
};
735737
});

src/agents/embedded-agent-runner/compact.hooks.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2330,7 +2330,14 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
23302330
);
23312331

23322332
expect(result.ok).toBe(true);
2333-
expect(rotateTranscriptAfterCompactionMock).not.toHaveBeenCalled();
2333+
expect(rotateTranscriptAfterCompactionMock).toHaveBeenCalledWith({
2334+
runSessionTarget: expect.objectContaining({
2335+
sessionFile: TEST_SESSION_FILE,
2336+
sessionId: TEST_SESSION_ID,
2337+
storageKind: "file",
2338+
}),
2339+
sessionFile: TEST_SESSION_FILE,
2340+
});
23342341
expect(result.result?.sessionId).toBeUndefined();
23352342
expect(result.result?.sessionFile).toBeUndefined();
23362343
expectRecordFields(mockCallArg(maintain), {

src/agents/embedded-agent-runner/compact.queued.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
resolveAgentHarnessPolicy,
3030
} from "../harness/selection.js";
3131
import { isOpenAIProvider } from "../openai-routing.js";
32+
import { resolveAgentRunSessionTarget } from "../run-session-target.js";
3233
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
3334
import { DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON } from "./compact-reasons.js";
3435
import type { CompactEmbeddedAgentSessionParams } from "./compact.types.js";
@@ -42,7 +43,7 @@ import {
4243
resolveCompactionTimeoutMs,
4344
} from "./compaction-safety-timeout.js";
4445
import {
45-
rotateTranscriptFileAfterCompaction,
46+
rotateAgentRunSessionTargetFileAfterCompaction,
4647
shouldRotateCompactionTranscript,
4748
} from "./compaction-successor-transcript.js";
4849
import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js";
@@ -172,6 +173,7 @@ export async function compactEmbeddedAgentSession(
172173
agentDir,
173174
workspaceDir: resolvedWorkspaceDir,
174175
});
176+
const runSessionTarget = await resolveAgentRunSessionTarget(params);
175177
const runtimePolicySessionKey = params.sandboxSessionKey ?? params.sessionKey;
176178
const runtimePolicyAgentId =
177179
params.sandboxSessionKey && parseAgentSessionKey(params.sandboxSessionKey)
@@ -403,7 +405,8 @@ export async function compactEmbeddedAgentSession(
403405
if (result.ok && result.compacted) {
404406
if (shouldRotateCompactionTranscript(params.config) && !delegatedRotatedTranscript) {
405407
try {
406-
const rotation = await rotateTranscriptFileAfterCompaction({
408+
const rotation = await rotateAgentRunSessionTargetFileAfterCompaction({
409+
runSessionTarget,
407410
sessionFile: params.sessionFile,
408411
});
409412
if (rotation.rotated) {

src/agents/embedded-agent-runner/compact.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import { registerProviderStreamForModel } from "../provider-stream.js";
9898
import {
9999
applyAgentRunSessionTargetIdentity,
100100
resolveAgentRunSessionTarget,
101+
type ResolvedAgentRunSessionTarget,
101102
} from "../run-session-target.js";
102103
import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js";
103104
import { buildAgentRuntimePlan } from "../runtime-plan/build.js";
@@ -146,6 +147,7 @@ import {
146147
} from "./compaction-safety-timeout.js";
147148
import {
148149
type CompactionTranscriptRotation,
150+
rotateAgentRunSessionTargetAfterCompaction,
149151
rotateTranscriptAfterCompaction,
150152
shouldRotateCompactionTranscript,
151153
} from "./compaction-successor-transcript.js";
@@ -181,6 +183,7 @@ import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.j
181183
export type { CompactEmbeddedAgentSessionParams } from "./compact.types.js";
182184

183185
type CompactEmbeddedAgentSessionParamsWithSessionFile = CompactEmbeddedAgentSessionRuntimeParams & {
186+
runSessionTarget: ResolvedAgentRunSessionTarget;
184187
sessionFile: string;
185188
};
186189

@@ -433,6 +436,7 @@ export async function compactEmbeddedAgentSessionDirect(
433436
const params: CompactEmbeddedAgentSessionParamsWithSessionFile = {
434437
...paramsBase,
435438
agentId: paramsBase.agentId ?? runSessionTarget.agentId,
439+
runSessionTarget,
436440
sessionId: runSessionTarget.sessionId,
437441
sessionKey: paramsBase.sessionKey ?? runSessionTarget.sessionKey,
438442
sessionFile: runSessionTarget.sessionFile,
@@ -1427,7 +1431,8 @@ async function compactEmbeddedAgentSessionDirectOnce(
14271431
let transcriptRotation: CompactionTranscriptRotation = { rotated: false };
14281432
if (shouldRotateCompactionTranscript(params.config)) {
14291433
try {
1430-
transcriptRotation = await rotateTranscriptAfterCompaction({
1434+
transcriptRotation = await rotateAgentRunSessionTargetAfterCompaction({
1435+
runSessionTarget: params.runSessionTarget,
14311436
sessionManager: transcriptRotationSessionManager,
14321437
sessionFile: params.sessionFile,
14331438
});

src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import os from "node:os";
33
import path from "node:path";
44
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
55
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { loadSqliteSessionEntry } from "../../config/sessions/session-accessor.sqlite.js";
7+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
8+
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
9+
import { resolveAgentRunSessionTarget } from "../run-session-target.js";
610
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
711
import {
12+
rotateAgentRunSessionTargetAfterCompaction,
813
rotateTranscriptAfterCompaction,
914
rotateTranscriptFileAfterCompaction,
1015
shouldRotateCompactionTranscript,
@@ -19,6 +24,7 @@ async function createTmpDir(): Promise<string> {
1924
}
2025

2126
afterEach(async () => {
27+
closeOpenClawAgentDatabasesForTest();
2228
if (tmpDir) {
2329
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
2430
tmpDir = undefined;
@@ -211,6 +217,43 @@ describe("rotateTranscriptAfterCompaction", () => {
211217
expect(successor.getLabel(oldUserId)).toBeUndefined();
212218
});
213219

220+
it("persists rotated SQLite active target identity through the rotation operation", async () => {
221+
const dir = await createTmpDir();
222+
const sqlitePath = path.join(dir, "helper", "openclaw-agent.sqlite");
223+
const sessionKey = "agent:helper:commitments:compaction-rotation";
224+
const runSessionTarget = await resolveAgentRunSessionTarget({
225+
agentId: "helper",
226+
config: { session: { store: sqlitePath } } as OpenClawConfig,
227+
sessionId: "sqlite-source",
228+
sessionKey,
229+
});
230+
const { manager, sessionFile } = createCompactedSession(
231+
path.dirname(runSessionTarget.sessionFile),
232+
);
233+
234+
const result = await rotateAgentRunSessionTargetAfterCompaction({
235+
runSessionTarget,
236+
sessionManager: manager,
237+
sessionFile,
238+
now: () => new Date("2026-04-27T12:15:00.000Z"),
239+
});
240+
241+
expect(result.rotated).toBe(true);
242+
expect(result.targetIdentityPersisted).toBe(true);
243+
const rotatedSessionId = requireString(result.sessionId, "rotated session id");
244+
const rotatedSessionFile = requireString(result.sessionFile, "rotated session file");
245+
expect(
246+
loadSqliteSessionEntry({
247+
agentId: "helper",
248+
sessionKey,
249+
storePath: sqlitePath,
250+
}),
251+
).toMatchObject({
252+
sessionId: rotatedSessionId,
253+
sessionFile: rotatedSessionFile,
254+
});
255+
});
256+
214257
it("rotates with a fallback timestamp when the injected clock is invalid", async () => {
215258
const dir = await createTmpDir();
216259
const { manager, sessionFile } = createCompactedSession(dir);

src/agents/embedded-agent-runner/compaction-successor-transcript.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import path from "node:path";
33
import { resolveTimestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
44
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
55
import type { OpenClawConfig } from "../../config/types.openclaw.js";
6+
import {
7+
persistAgentRunSessionTargetIdentity,
8+
type ResolvedAgentRunSessionTarget,
9+
} from "../run-session-target.js";
610
import type { CompactionEntry, SessionEntry, SessionHeader } from "../sessions/index.js";
711
import { collectDuplicateUserMessageEntryIdsForCompaction } from "./compaction-duplicate-user-messages.js";
812
import {
@@ -21,6 +25,7 @@ export type CompactionTranscriptRotation = {
2125
reason?: string;
2226
sessionId?: string;
2327
sessionFile?: string;
28+
targetIdentityPersisted?: boolean;
2429
compactionEntryId?: string;
2530
leafId?: string;
2631
entriesWritten?: number;
@@ -83,6 +88,44 @@ export async function rotateTranscriptAfterCompaction(params: {
8388
};
8489
}
8590

91+
/** Rotates a compaction successor and persists the active run target in one operation. */
92+
export async function rotateAgentRunSessionTargetAfterCompaction(params: {
93+
allowTargetPersistenceRetry?: boolean;
94+
runSessionTarget: ResolvedAgentRunSessionTarget;
95+
sessionManager: ReadonlySessionManagerForRotation;
96+
sessionFile: string;
97+
now?: () => Date;
98+
}): Promise<CompactionTranscriptRotation> {
99+
const rotation = await rotateTranscriptAfterCompaction({
100+
sessionManager: params.sessionManager,
101+
sessionFile: params.sessionFile,
102+
...(params.now ? { now: params.now } : {}),
103+
});
104+
const targetIdentityPersisted = await persistRotatedAgentRunSessionTarget({
105+
allowTargetPersistenceRetry: params.allowTargetPersistenceRetry,
106+
runSessionTarget: params.runSessionTarget,
107+
rotation,
108+
});
109+
return rotation.rotated ? { ...rotation, targetIdentityPersisted } : rotation;
110+
}
111+
112+
/** Opens a transcript file, rotates its compaction successor, and persists the active target. */
113+
export async function rotateAgentRunSessionTargetFileAfterCompaction(params: {
114+
allowTargetPersistenceRetry?: boolean;
115+
runSessionTarget: ResolvedAgentRunSessionTarget;
116+
sessionFile: string;
117+
now?: () => Date;
118+
}): Promise<CompactionTranscriptRotation> {
119+
const state = await readTranscriptFileState(params.sessionFile);
120+
return rotateAgentRunSessionTargetAfterCompaction({
121+
allowTargetPersistenceRetry: params.allowTargetPersistenceRetry,
122+
runSessionTarget: params.runSessionTarget,
123+
sessionManager: state,
124+
sessionFile: params.sessionFile,
125+
...(params.now ? { now: params.now } : {}),
126+
});
127+
}
128+
86129
export async function rotateTranscriptFileAfterCompaction(params: {
87130
sessionFile: string;
88131
now?: () => Date;
@@ -95,6 +138,31 @@ export async function rotateTranscriptFileAfterCompaction(params: {
95138
});
96139
}
97140

141+
async function persistRotatedAgentRunSessionTarget(params: {
142+
allowTargetPersistenceRetry?: boolean;
143+
runSessionTarget: ResolvedAgentRunSessionTarget;
144+
rotation: CompactionTranscriptRotation;
145+
}): Promise<boolean> {
146+
if (!params.rotation.rotated || !params.rotation.sessionId || !params.rotation.sessionFile) {
147+
return false;
148+
}
149+
try {
150+
await persistAgentRunSessionTargetIdentity({
151+
target: params.runSessionTarget,
152+
sessionFile: params.rotation.sessionFile,
153+
sessionId: params.rotation.sessionId,
154+
});
155+
return true;
156+
} catch (err) {
157+
if (params.allowTargetPersistenceRetry !== true) {
158+
throw err;
159+
}
160+
// The successor transcript already exists. Return it so callers can adopt
161+
// the rotation and, when they own the run loop, retry target persistence.
162+
return false;
163+
}
164+
}
165+
98166
function findLatestCompactionIndex(entries: SessionEntry[]): number {
99167
for (let index = entries.length - 1; index >= 0; index -= 1) {
100168
if (entries[index]?.type === "compaction") {

src/agents/embedded-agent-runner/run.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1648,6 +1648,7 @@ export async function runEmbeddedAgent(
16481648
// attempt too. Otherwise plugin-owned transports can skip OpenClaw auth
16491649
// bootstrap but drift back to OpenClaw when the attempt is created.
16501650
agentHarnessId: agentHarness.id,
1651+
runSessionTarget,
16511652
...(params.sessionKey
16521653
? {
16531654
agentHarnessTaskRuntimeScope: createAgentHarnessTaskRuntimeScope({
@@ -1774,7 +1775,7 @@ export async function runEmbeddedAgent(
17741775
activeSessionFile = sessionFileUsed;
17751776
activeTargetChanged = true;
17761777
}
1777-
if (activeTargetChanged) {
1778+
if (activeTargetChanged && attempt.sessionTargetIdentityPersisted !== true) {
17781779
await persistActiveRunSessionTarget({
17791780
sessionFile: activeSessionFile,
17801781
sessionId: activeSessionId,

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ import {
314314
} from "./attempt.bootstrap-context.js";
315315
export { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js";
316316
import {
317-
rotateTranscriptAfterCompaction,
317+
rotateAgentRunSessionTargetAfterCompaction,
318318
shouldRotateCompactionTranscript,
319319
} from "../compaction-successor-transcript.js";
320320
import { releaseEmbeddedAttemptSessionLockForAbort } from "./attempt-abort.js";
@@ -760,6 +760,20 @@ function collectAttemptExplicitToolAllowlistSources(params: {
760760
]);
761761
}
762762

763+
function resolveAttemptRunSessionTarget(params: EmbeddedRunAttemptParams) {
764+
if (params.runSessionTarget) {
765+
return params.runSessionTarget;
766+
}
767+
return {
768+
agentId: params.agentId ?? "",
769+
sessionFile: params.sessionFile,
770+
sessionId: params.sessionId,
771+
sessionKey: params.sessionKey ?? "",
772+
storageKind: "file" as const,
773+
targetKind: "active-session-file" as const,
774+
};
775+
}
776+
763777
export async function runEmbeddedAttempt(
764778
params: EmbeddedRunAttemptParams,
765779
): Promise<EmbeddedRunAttemptResult> {
@@ -3285,6 +3299,7 @@ export async function runEmbeddedAttempt(
32853299
let messagesSnapshot: AgentMessage[] = [];
32863300
let sessionIdUsed = activeSession.sessionId;
32873301
let sessionFileUsed: string | undefined = params.sessionFile;
3302+
let sessionTargetIdentityPersisted = false;
32883303
const onAbort = () => {
32893304
externalAbort = true;
32903305
const reason = params.abortSignal ? getAbortReason(params.abortSignal) : undefined;
@@ -4588,13 +4603,16 @@ export async function runEmbeddedAttempt(
45884603
shouldRotateCompactionTranscript(params.config)
45894604
) {
45904605
try {
4591-
const rotation = await rotateTranscriptAfterCompaction({
4606+
const rotation = await rotateAgentRunSessionTargetAfterCompaction({
4607+
allowTargetPersistenceRetry: true,
4608+
runSessionTarget: resolveAttemptRunSessionTarget(params),
45924609
sessionManager: activeSessionManager,
45934610
sessionFile: params.sessionFile,
45944611
});
45954612
if (rotation.rotated) {
45964613
sessionIdUsed = rotation.sessionId ?? sessionIdUsed;
45974614
sessionFileUsed = rotation.sessionFile ?? sessionFileUsed;
4615+
sessionTargetIdentityPersisted = rotation.targetIdentityPersisted === true;
45984616
updateActiveEmbeddedRunSessionFile(params.sessionId, sessionFileUsed);
45994617
log.info(
46004618
`[compaction] rotated active transcript after automatic compaction ` +
@@ -4981,6 +4999,7 @@ export async function runEmbeddedAttempt(
49814999
preflightRecovery,
49825000
sessionIdUsed,
49835001
sessionFileUsed,
5002+
sessionTargetIdentityPersisted,
49845003
diagnosticTrace,
49855004
bootstrapPromptWarningSignaturesSeen: bootstrapPromptWarning.warningSignaturesSeen,
49865005
bootstrapPromptWarningSignature: bootstrapPromptWarning.signature,

src/agents/embedded-agent-runner/run/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
MessagingToolSend,
1717
MessagingToolSourceReplyPayload,
1818
} from "../../embedded-agent-messaging.types.js";
19+
import type { ResolvedAgentRunSessionTarget } from "../../run-session-target.js";
1920
import type { AgentRunTimeoutPhase } from "../../run-timeout-attribution.js";
2021
import type { AgentRuntimePlan } from "../../runtime-plan/types.js";
2122
import type { AgentMessage } from "../../runtime/index.js";
@@ -65,6 +66,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
6566
agentHarnessId?: string;
6667
/** OpenClaw-owned runtime policy prepared by the orchestrator for this attempt. */
6768
runtimePlan?: AgentRuntimePlan;
69+
/** Resolved storage target for operation-sized transcript/session mutations. */
70+
runSessionTarget?: ResolvedAgentRunSessionTarget;
6871
/** Host-issued scope for harnesses that mirror native child runs into task state. */
6972
agentHarnessTaskRuntimeScope?: AgentHarnessTaskRuntimeScope;
7073
/** Live observer called after wrapped tool outcomes are recorded. */
@@ -120,6 +123,8 @@ export type EmbeddedRunAttemptResult = {
120123
};
121124
sessionIdUsed: string;
122125
sessionFileUsed?: string;
126+
/** True when a compaction rotation already persisted the active run target. */
127+
sessionTargetIdentityPersisted?: boolean;
123128
diagnosticTrace?: DiagnosticTraceContext;
124129
agentHarnessId?: string;
125130
agentHarnessResultClassification?: "empty" | "reasoning-only" | "planning-only";

0 commit comments

Comments
 (0)