Handle compaction sidecar edge cases

This commit is contained in:
Robin Newhouse
2026-06-26 09:56:24 -07:00
parent 41ea024cf0
commit 9ffcabcac1
9 changed files with 338 additions and 86 deletions
@@ -1,5 +1,6 @@
import type { AgentToolContext, HubEventEnvelope } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSessionCompactionState } from "../../session/models/session-compaction";
import { SessionSource } from "../../types/common";
const commandMock = vi.hoisted(() => vi.fn());
@@ -1410,6 +1411,69 @@ describe("HubRuntimeHost", () => {
});
});
it("records rejected compaction state updates as handled errors", async () => {
const telemetry = { capture: vi.fn() };
const state = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "sess-1",
});
commandMock.mockResolvedValue({
ok: false,
error: {
code: "session_wrong_client",
message: "Session sess-1 is owned by other-client",
},
});
const { HubRuntimeHost } = await import("./hub-runtime-host");
const host = new HubRuntimeHost({
url: "ws://127.0.0.1:25463/hub",
telemetry: telemetry as never,
});
await expect(
host.updateSessionCompactionState(" sess-1 ", state),
).resolves.toEqual({ updated: false });
expect(telemetry.capture).toHaveBeenCalledWith({
event: "sdk.error",
properties: expect.objectContaining({
component: "core",
operation: "hub.runtime_host.update_session_compaction_state",
severity: "warn",
handled: true,
command: "session.compaction.update",
sessionId: "sess-1",
errorCode: "session_wrong_client",
error_message: "Session sess-1 is owned by other-client",
}),
});
});
it("treats stale compaction state updates as non-error no-ops", async () => {
const telemetry = { capture: vi.fn() };
const state = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "sess-1",
});
commandMock.mockResolvedValue({
ok: true,
payload: { updated: false },
});
const { HubRuntimeHost } = await import("./hub-runtime-host");
const host = new HubRuntimeHost({
url: "ws://127.0.0.1:25463/hub",
telemetry: telemetry as never,
});
await expect(
host.updateSessionCompactionState("sess-1", state),
).resolves.toEqual({ updated: false });
expect(telemetry.capture).not.toHaveBeenCalled();
});
it("throws when the hub rejects settings list", async () => {
commandMock.mockResolvedValue({
ok: false,
@@ -1301,6 +1301,22 @@ export class HubRuntimeHost implements RuntimeHost {
{ sessionId: target, state },
target,
);
if (!reply.ok) {
captureSdkError(this.telemetry, {
component: "core",
operation: "hub.runtime_host.update_session_compaction_state",
error: new Error(
hubReplyErrorMessage(reply, "session.compaction.update"),
),
severity: "warn",
handled: true,
context: {
command: "session.compaction.update",
sessionId: target,
errorCode: reply.error?.code,
},
});
}
return {
updated: reply.ok && reply.payload?.updated === true,
};
@@ -923,6 +923,47 @@ describe("HubServerTransport boundaries", () => {
expect(updateSessionCompactionState).not.toHaveBeenCalled();
});
it("allows a creator to claim ownerless compaction sidecar ownership", async () => {
const state = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "session-1",
});
const readSessionCompactionState = vi.fn().mockResolvedValue(state);
const transport = createTransport({
sessionHost: { readSessionCompactionState },
});
const ctx = getContext(transport);
ensureSessionParticipant(ctx, "session-1", "viewer-client", "participant");
expect(
ctx.sessionState.get("session-1")?.createdByClientId,
).toBeUndefined();
ensureSessionState(ctx, "session-1", "owner-client", "creator");
expect(ctx.sessionState.get("session-1")?.createdByClientId).toBe(
"owner-client",
);
expect(
ctx.sessionState.get("session-1")?.participants.has("viewer-client"),
).toBe(true);
const getReply = await transport.handleCommand({
version: "v1",
requestId: "req-compact-get",
command: "session.compaction.get",
clientId: "owner-client",
sessionId: "session-1",
});
expect(getReply).toMatchObject({
ok: true,
payload: { state },
});
expect(readSessionCompactionState).toHaveBeenCalledWith("session-1");
});
it("clears compaction sidecar ownership when the owner detaches", async () => {
const readSessionCompactionState = vi.fn();
const transport = createTransport({
@@ -1125,7 +1166,7 @@ describe("HubServerTransport boundaries", () => {
});
expect(reply).toMatchObject({
ok: false,
ok: true,
payload: { updated: false },
});
expect(events).not.toEqual(
@@ -183,6 +183,9 @@ export function ensureSessionState(
if (options.interactive !== undefined) {
existing.interactive = options.interactive;
}
if (role === "creator" && !existing.createdByClientId) {
existing.createdByClientId = clientId;
}
if (!existing.participants.has(clientId)) {
existing.participants.set(clientId, {
clientId,
@@ -32,13 +32,6 @@ import {
const CAPABILITY_OWNER_METADATA_KEY = "hubCapabilityOwnerClientId";
function setCapabilityOwner(
metadata: Record<string, unknown>,
clientId: string,
): void {
metadata[CAPABILITY_OWNER_METADATA_KEY] = clientId;
}
function getCapabilityOwnerClientId(
ctx: HubTransportContext,
sessionId: string,
@@ -168,7 +161,6 @@ export async function handleSessionCreate(
cwd: typeof payload.cwd === "string" ? payload.cwd : undefined,
contributionCount: clientContributions.length,
});
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
const requestedSessionId =
typeof sessionConfig?.sessionId === "string"
? sessionConfig.sessionId.trim()
@@ -426,7 +418,6 @@ export async function handleSessionRestore(
const clientContributions = parseHubClientContributions(
runtimeOptions.clientContributions,
);
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
const requestedSessionId =
typeof sessionConfig?.sessionId === "string"
? sessionConfig.sessionId.trim()
@@ -896,7 +887,7 @@ export async function handleSessionCompactionUpdate(
return {
version: envelope.version,
requestId: envelope.requestId,
ok: updated.updated,
ok: true,
payload: {
updated: updated.updated,
session: updatedSession ?? session,
@@ -14,6 +14,7 @@ import type {
import { setClineDir, setHomeDir } from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TelemetryService } from "../../services/telemetry/TelemetryService";
import { createSessionCompactionState } from "../../session/models/session-compaction";
import type { SessionManifest } from "../../session/models/session-manifest";
import { SessionSource } from "../../types/common";
import type { CoreSessionConfig } from "../../types/config";
@@ -4336,6 +4337,83 @@ describe("LocalRuntimeHost", () => {
);
});
it("does not project compaction state when compaction is disabled", async () => {
const sessionId = "sess-compaction-disabled";
const manifest = createManifest(sessionId);
const initialMessages: MessageWithMetadata[] = [
{ role: "user", content: "canonical source" },
];
const initialCompactionState = createSessionCompactionState({
sourceMessages: initialMessages,
compactedMessages: [{ role: "user", content: "projected summary" }],
conversationId: sessionId,
updatedAt: "2026-01-01T00:00:00.000Z",
});
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest-compaction-disabled.json",
messagesPath: "/tmp/messages-compaction-disabled.json",
manifest,
}),
persistSessionMessages: vi.fn(),
persistSessionCompactionState: vi.fn(),
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const run = vi.fn().mockResolvedValue(createResult());
const createAgent = vi.fn().mockReturnValue({
run,
continue: vi.fn(),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue(sessionId),
restore: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn().mockReturnValue(initialMessages),
messages: initialMessages,
});
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder: {
build: vi.fn().mockReturnValue({
tools: [],
shutdown: vi.fn(),
}),
},
createAgent: createAgent as never,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({
sessionId,
compaction: {
enabled: false,
strategy: "basic",
},
}),
initialMessages,
initialCompactionState,
interactive: true,
}),
);
const prepareTurn = createAgent.mock.calls[0]?.[0]?.prepareTurn;
expect(prepareTurn).toBeUndefined();
expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled();
await expect(
manager.updateSessionCompactionState(sessionId, initialCompactionState),
).resolves.toEqual({ updated: true });
expect(createAgent.mock.calls[0]?.[0]?.prepareTurn).toBeUndefined();
});
it("formats prompt in core and merges explicit + mention user files", async () => {
const tempCwd = mkdtempSync(join(tmpdir(), "core-session-format-"));
try {
@@ -485,77 +485,79 @@ export class LocalRuntimeHost implements RuntimeHost {
const explicitInitialCompactionState = startInput.initialCompactionState;
let activeSessionRef: ActiveSession | undefined;
const compact = createContextCompactionPrepareTurn(configWithProvider);
const initialCompactionState =
explicitInitialCompactionState ??
(compact ? resumedCompactionState : undefined);
const prepareTurn = createCompactionStateAwarePrepareTurn({
compact,
getState: () => activeSessionRef?.compactionState,
saveState: async (state) => {
const activeSession = activeSessionRef;
if (!activeSession) return;
const stateForSession = {
...state,
conversation_id: activeSession.sessionId,
};
try {
const result = await this.persistActiveSessionCompactionState(
activeSession,
stateForSession,
);
if (!result.updated) {
configWithProvider.logger?.debug?.(
"Skipped stale session compaction state",
{
sessionId: activeSession.sessionId,
sourceMessageCount: stateForSession.source_message_count,
},
);
}
} catch (error) {
configWithProvider.logger?.error?.(
"Failed to persist session compaction state",
{ sessionId: activeSession.sessionId, error },
);
captureSdkError(configWithProvider.telemetry, {
component: "core",
operation: "session.persist_compaction_state",
severity: "warn",
handled: true,
error,
context: {
sessionId: activeSession.sessionId,
providerId: configWithProvider.providerId,
modelId: configWithProvider.modelId,
},
});
}
},
clearState: async () => {
const activeSession = activeSessionRef;
if (!activeSession?.compactionState) return;
try {
await this.clearActiveSessionCompactionState(activeSession);
} catch (error) {
configWithProvider.logger?.error?.(
"Failed to delete stale session compaction state",
{ sessionId: activeSession.sessionId, error },
);
captureSdkError(configWithProvider.telemetry, {
component: "core",
operation: "session.delete_compaction_state",
severity: "warn",
handled: true,
error,
context: {
sessionId: activeSession.sessionId,
providerId: configWithProvider.providerId,
modelId: configWithProvider.modelId,
},
});
}
},
});
const initialCompactionState = compact
? (explicitInitialCompactionState ?? resumedCompactionState)
: undefined;
const prepareTurn = compact
? createCompactionStateAwarePrepareTurn({
compact,
getState: () => activeSessionRef?.compactionState,
saveState: async (state) => {
const activeSession = activeSessionRef;
if (!activeSession) return;
const stateForSession = {
...state,
conversation_id: activeSession.sessionId,
};
try {
const result = await this.persistActiveSessionCompactionState(
activeSession,
stateForSession,
);
if (!result.updated) {
configWithProvider.logger?.debug?.(
"Skipped stale session compaction state",
{
sessionId: activeSession.sessionId,
sourceMessageCount: stateForSession.source_message_count,
},
);
}
} catch (error) {
configWithProvider.logger?.error?.(
"Failed to persist session compaction state",
{ sessionId: activeSession.sessionId, error },
);
captureSdkError(configWithProvider.telemetry, {
component: "core",
operation: "session.persist_compaction_state",
severity: "warn",
handled: true,
error,
context: {
sessionId: activeSession.sessionId,
providerId: configWithProvider.providerId,
modelId: configWithProvider.modelId,
},
});
}
},
clearState: async () => {
const activeSession = activeSessionRef;
if (!activeSession?.compactionState) return;
try {
await this.clearActiveSessionCompactionState(activeSession);
} catch (error) {
configWithProvider.logger?.error?.(
"Failed to delete stale session compaction state",
{ sessionId: activeSession.sessionId, error },
);
captureSdkError(configWithProvider.telemetry, {
component: "core",
operation: "session.delete_compaction_state",
severity: "warn",
handled: true,
error,
context: {
sessionId: activeSession.sessionId,
providerId: configWithProvider.providerId,
modelId: configWithProvider.modelId,
},
});
}
},
})
: undefined;
const agentConfig = {
sessionId,
@@ -700,4 +700,55 @@ describe("UnifiedSessionPersistenceService", () => {
expect(existsSync(join(sessionsDir, sessionId))).toBe(false);
},
);
sqliteIt(
"deletes a session when compaction sidecar cleanup fails",
async () => {
const dbDir = mkdtempSync(join(tmpdir(), "delete-sidecar-fail-db-"));
const sessionsDir = mkdtempSync(
join(tmpdir(), "delete-sidecar-fail-sessions-"),
);
tempDirs.push(dbDir, sessionsDir);
const store = new SqliteSessionStore({ sessionsDir: dbDir });
stores.push(store);
const service = new CoreSessionService(store, {
sessionArtifactsDir: sessionsDir,
});
const sessionId = "sidecar-delete-fail-session";
await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: false,
provider: "anthropic",
model: "claude-sonnet-4-6",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: false,
enableTeams: false,
prompt: "delete me",
startedAt: "2026-04-10T19:00:00.000Z",
});
const manifestStore = (
service as unknown as {
manifestStore: {
deleteSessionCompactionState: (sessionId: string) => Promise<void>;
};
}
).manifestStore;
const deleteSidecar = vi
.spyOn(manifestStore, "deleteSessionCompactionState")
.mockRejectedValue(new Error("sidecar busy"));
const result = await service.deleteSession(sessionId);
expect(result).toEqual({ deleted: true });
await expect(service.listSessions(10)).resolves.not.toEqual(
expect.arrayContaining([expect.objectContaining({ sessionId })]),
);
expect(deleteSidecar).toHaveBeenCalledWith(sessionId);
},
);
});
@@ -568,9 +568,7 @@ export class UnifiedSessionPersistenceService {
children.map(async (child) => {
await deleteCheckpointRefs(child.cwd, child.sessionId);
unlinkIfExists(child.messagesPath);
await this.manifestStore.deleteSessionCompactionState(
child.sessionId,
);
await this.deleteSessionCompactionStateIfExists(child.sessionId);
unlinkIfExists(
this.manifestStore.artifacts.sessionManifestPath(
child.sessionId,
@@ -585,7 +583,7 @@ export class UnifiedSessionPersistenceService {
await deleteCheckpointRefs(row.cwd, id);
unlinkIfExists(row.messagesPath);
await this.manifestStore.deleteSessionCompactionState(id);
await this.deleteSessionCompactionStateIfExists(id);
unlinkIfExists(this.manifestStore.artifacts.sessionManifestPath(id, false));
if (row.isSubagent) {
this.manifestStore.artifacts.removeSessionDirIfEmpty(id);
@@ -604,4 +602,12 @@ export class UnifiedSessionPersistenceService {
}
return { deleted: true };
}
private async deleteSessionCompactionStateIfExists(
sessionId: string,
): Promise<void> {
try {
await this.manifestStore.deleteSessionCompactionState(sessionId);
} catch {}
}
}