Sync backend handoff behavior with reviewed parent

This commit is contained in:
John Choi
2026-10-14 10:11:14 -07:00
parent ee949be374
commit f2930325f8
6 changed files with 65 additions and 90 deletions
@@ -1679,7 +1679,7 @@ describe("first-send connection updates", () => {
};
}
it("preserves an observed idle session when attach reports a resident running process", async () => {
it("preserves a newer Hub idle event over a running attach reply", async () => {
const { ctx, sessionId } = createContext({ attachedViaHub: true });
const binding = ctx.runtimeBindings.get("local");
if (!binding) throw new Error("missing binding");
@@ -1687,8 +1687,15 @@ describe("first-send connection updates", () => {
handleHubLiveEvent(ctx, {
event: "session.attached",
sessionId,
sequence: 1,
payload: { session: { status: "running" } },
});
handleHubLiveEvent(ctx, {
event: "session.updated",
sessionId,
sequence: 2,
payload: { session: { status: "idle" } },
});
return {
version: "v1",
ok: true,
@@ -52,6 +52,7 @@ import {
} from "./attachments";
import { createDesktopExtensionContext } from "./client-context";
import {
assertCloudHandoffCreationResolved,
beginActiveSessionSend,
handleHandoff,
handlePrepareHandoff,
@@ -368,7 +369,8 @@ async function withWorkspaceRestoreLock<T>(
const sessionWorkspace = workspacePathKey(session.config);
if (
sessionWorkspace &&
(hasActiveWorkspaceTurn(session) || hasActiveSessionSend(ctx, sessionId)) &&
(hasActiveWorkspaceTurn(session) ||
hasActiveSessionSend(ctx, sessionId)) &&
workspacePathsOverlap(sessionWorkspace, key, environment)
) {
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
@@ -1713,9 +1715,9 @@ async function handleSendOnce(
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const handoff = readCloudHandoffMetadata(
await readHandoffSessionMetadata(binding, sessionId),
);
const metadata = await readHandoffSessionMetadata(binding, sessionId);
assertCloudHandoffCreationResolved(metadata);
const handoff = readCloudHandoffMetadata(metadata);
if (handoff?.status === "pending") {
throw new Error(
`Cloud handoff is still pending. Retry /cloud or continue here: ${handoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, handoff.toCloudSessionId)}`,
@@ -2126,11 +2128,13 @@ async function handleFork(
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
}
const sourceSession = await manager.get(sourceSessionId);
const sourceHandoff = readCloudHandoffMetadata(
const sourceMetadata =
binding.kind === "local"
? (sourceSession?.metadata ?? readSessionMetadata(sourceSessionId))
: undefined,
);
: undefined;
if (forkBeforeRunCount !== undefined)
assertCloudHandoffCreationResolved(sourceMetadata);
const sourceHandoff = readCloudHandoffMetadata(sourceMetadata);
if (sourceHandoff?.status === "pending") {
throw new Error(
`Cloud handoff is still pending. Retry /cloud or continue here: ${sourceHandoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, sourceHandoff.toCloudSessionId)}`,
@@ -2429,11 +2433,12 @@ async function handleRestoreCheckpoint(
const manager = binding.sessionManager;
const persisted =
binding.kind === "local" ? await manager.get(sourceSessionId) : undefined;
const completedHandoff = readCloudHandoffMetadata(
const handoffMetadata =
binding.kind === "local"
? (persisted?.metadata ?? readSessionMetadata(sourceSessionId))
: undefined,
);
: undefined;
const completedHandoff = readCloudHandoffMetadata(handoffMetadata);
assertCloudHandoffCreationResolved(handoffMetadata);
if (completedHandoff?.status === "pending") {
throw new Error(
`Cloud handoff is still pending. Retry /cloud or continue here: ${completedHandoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, completedHandoff.toCloudSessionId)}`,
@@ -777,10 +777,13 @@ describe("cloud handoff gates", () => {
await expect(handingOff).rejects.toThrow();
});
it("trusts an authoritative idle live session over a legacy running record", async () => {
it.each([
"running",
"pending",
])("rejects handoff when Hub reports %s despite an idle Desktop cache", async (persistedStatus) => {
const { ctx, sessionId } = createHandoffGateContext({
busy: false,
persistedStatus: "running",
persistedStatus,
});
await expect(
@@ -788,7 +791,8 @@ describe("cloud handoff gates", () => {
action: "prepare_handoff",
sessionId,
}),
).rejects.not.toThrow("Stop the current run");
).rejects.toThrow("Stop the current run");
expect(ctx.cloudSessionManager).toBeFalsy();
});
it("rejects an empty source before provisioning", async () => {
@@ -1288,6 +1292,21 @@ describe("cloud handoff transaction", () => {
);
expect(first.getPersistedMetadata()).toHaveProperty("cloudHandoffIntent");
const restarted = createHandoffFixture(true, first.getPersistedMetadata());
const restore = vi.fn();
Object.assign(localSessionManager(restarted.ctx), { restore });
for (const action of ["restore_checkpoint", "send", "fork"] as const) {
await expect(
handleChatSessionCommand(restarted.ctx, {
action,
sessionId: request.sessionId,
prompt: "Change this workspace",
checkpointRunCount: 1,
forkBeforeRunCount: 1,
config: { cwd: "/workspace/project" },
}),
).rejects.toThrow("Cloud handoff creation is still unconfirmed");
}
expect(restore).not.toHaveBeenCalled();
await expect(
assertSessionDeleteAllowedDuringHandoff(
restarted.ctx,
@@ -134,8 +134,7 @@ async function assertHandoffIdle(
live?.status === "running" ||
live?.status === "stopping";
const persistedIsBusy =
!live &&
(persisted?.status === "running" || persisted?.status === "pending");
persisted?.status === "running" || persisted?.status === "pending";
if (liveIsBusy || persistedIsBusy) {
throw new Error("Stop the current run before handing off to cloud.");
}
@@ -1094,6 +1093,20 @@ export function beginSessionMetadataUpdate(
);
}
export function assertCloudHandoffCreationResolved(metadata: unknown): void {
if (
!readCloudHandoffMetadata(metadata) &&
metadata &&
typeof metadata === "object" &&
"cloudHandoffIntent" in metadata &&
metadata.cloudHandoffIntent
) {
throw new Error(
"Cloud handoff creation is still unconfirmed. Retry /cloud to recover it before changing this session.",
);
}
}
/**
* Deleting a source session while its cloud handoff is in flight can remove
* the only durable recovery record for an already-created sandbox. Keep this
@@ -1130,11 +1143,7 @@ export async function assertSessionDeleteAllowedDuringHandoff(
}
}
const handoff = readCloudHandoffMetadata(metadata);
if (!handoff && metadata?.cloudHandoffIntent) {
throw new Error(
"Cloud handoff creation is still unconfirmed. Retry /cloud to recover it before deleting this session.",
);
}
assertCloudHandoffCreationResolved(metadata);
if (handoff?.status === "pending") {
throw new Error(
`Cloud handoff is still pending. Retry /cloud or continue here: ${handoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, handoff.toCloudSessionId)}`,
@@ -923,57 +923,6 @@ describe("Code sidecar runtime capabilities", () => {
expect(readEvents(ctx)).toEqual([]);
});
it.each([
"session.attached",
"session.updated",
])("keeps a resident process idle until run.started after %s", async (event) => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set("idle-session", {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
attachedViaHub: true,
});
handleHubLiveEvent(ctx, {
event,
sessionId: "idle-session",
payload: { session: { status: "running" } },
});
expect(ctx.liveSessions.get("idle-session")).toMatchObject({
status: "idle",
busy: false,
});
expect(readEvents(ctx)).toEqual([]);
handleHubLiveEvent(ctx, {
event: "run.started",
sessionId: "idle-session",
});
expect(ctx.liveSessions.get("idle-session")).toMatchObject({
status: "running",
busy: true,
});
expect(readEvents(ctx)).toEqual([
{
type: "event",
event: {
name: "chat_session_status",
payload: {
environmentId: "local",
sessionId: "idle-session",
status: "running",
},
},
},
]);
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
+4 -18
View File
@@ -1149,15 +1149,7 @@ export function handleHubLiveEvent(
);
return;
}
case "run.started": {
const statusChanged = session.status !== "running";
session.status = "running";
session.busy = true;
if (statusChanged) {
sendEvent(ctx, "chat_session_status", { sessionId, status: "running" });
}
return;
}
case "run.started":
case "session.attached":
case "session.updated": {
const payloadSession =
@@ -1169,7 +1161,9 @@ export function handleHubLiveEvent(
const runtimeStatus =
typeof payloadSession?.status === "string"
? payloadSession.status
: session.status;
: event.event === "run.started"
? "running"
: session.status;
// Hub "pending" is still active; map it to running so follow-ups stay queued.
const status = runtimeStatus === "pending" ? "running" : runtimeStatus;
if (
@@ -1180,14 +1174,6 @@ export function handleHubLiveEvent(
) {
return;
}
// A resident interactive process can be idle; run.started marks an active turn.
if (
runtimeStatus === "running" &&
session.status !== "running" &&
session.config.executionTarget !== "cloud"
) {
return;
}
// Pods emit periodic session.updated snapshots; re-broadcasting an
// unchanged status marks the session unread in the sidebar every time.
const statusChanged = session.status !== status;