mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
Sync backend handoff behavior with reviewed parent
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user