mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
Handle compaction sidecar edge cases
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user