mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 22:10:51 +00:00
ACP: reproduce binding restart session reset (#49435)
* ACP: reproduce restart binding regression * ACP: resume configured bindings after restart * ACP: scope restart resume to persistent sessions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
identityEquals,
|
||||
isSessionIdentityPending,
|
||||
mergeSessionIdentity,
|
||||
resolveRuntimeResumeSessionId,
|
||||
resolveRuntimeHandleIdentifiersFromIdentity,
|
||||
resolveSessionIdentityFromMeta,
|
||||
} from "../runtime/session-identity.js";
|
||||
@@ -972,20 +973,45 @@ export class AcpSessionManager {
|
||||
|
||||
const backend = this.deps.requireRuntimeBackend(configuredBackend || undefined);
|
||||
const runtime = backend.runtime;
|
||||
const ensured = await withAcpRuntimeErrorBoundary({
|
||||
run: async () =>
|
||||
await runtime.ensureSession({
|
||||
sessionKey: params.sessionKey,
|
||||
agent,
|
||||
mode,
|
||||
cwd,
|
||||
}),
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: "Could not initialize ACP session runtime.",
|
||||
});
|
||||
|
||||
const previousMeta = params.meta;
|
||||
const previousIdentity = resolveSessionIdentityFromMeta(previousMeta);
|
||||
const persistedResumeSessionId =
|
||||
mode === "persistent" ? resolveRuntimeResumeSessionId(previousIdentity) : undefined;
|
||||
const ensureSession = async (resumeSessionId?: string) =>
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
run: async () =>
|
||||
await runtime.ensureSession({
|
||||
sessionKey: params.sessionKey,
|
||||
agent,
|
||||
mode,
|
||||
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||
cwd,
|
||||
}),
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: "Could not initialize ACP session runtime.",
|
||||
});
|
||||
let ensured: AcpRuntimeHandle;
|
||||
if (persistedResumeSessionId) {
|
||||
try {
|
||||
ensured = await ensureSession(persistedResumeSessionId);
|
||||
} catch (error) {
|
||||
const acpError = toAcpRuntimeError({
|
||||
error,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: "Could not initialize ACP session runtime.",
|
||||
});
|
||||
if (acpError.code !== "ACP_SESSION_INIT_FAILED") {
|
||||
throw acpError;
|
||||
}
|
||||
logVerbose(
|
||||
`acp-manager: resume init failed for ${params.sessionKey}; retrying without persisted ACP session id: ${acpError.message}`,
|
||||
);
|
||||
ensured = await ensureSession();
|
||||
}
|
||||
} else {
|
||||
ensured = await ensureSession();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const effectiveCwd = normalizeText(ensured.cwd) ?? cwd;
|
||||
const nextRuntimeOptions = normalizeRuntimeOptions({
|
||||
|
||||
@@ -432,6 +432,186 @@ describe("AcpSessionManager", () => {
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("passes persisted ACP backend session identity back into ensureSession for configured bindings after restart", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
const sessionKey = "agent:codex:acp:binding:discord:default:deadbeef";
|
||||
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
|
||||
const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey;
|
||||
return {
|
||||
sessionKey: key,
|
||||
storeSessionKey: key,
|
||||
acp: {
|
||||
...readySessionMeta(),
|
||||
runtimeSessionName: key,
|
||||
identity: {
|
||||
state: "resolved",
|
||||
source: "status",
|
||||
acpxSessionId: "acpx-sid-1",
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
await manager.runTurn({
|
||||
cfg: baseCfg,
|
||||
sessionKey,
|
||||
text: "after restart",
|
||||
mode: "prompt",
|
||||
requestId: "r-binding-restart",
|
||||
});
|
||||
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
resumeSessionId: "acpx-sid-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not resume persisted ACP identity for oneshot sessions after restart", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
const sessionKey = "agent:codex:acp:binding:discord:default:oneshot";
|
||||
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
|
||||
const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey;
|
||||
return {
|
||||
sessionKey: key,
|
||||
storeSessionKey: key,
|
||||
acp: {
|
||||
...readySessionMeta(),
|
||||
runtimeSessionName: key,
|
||||
mode: "oneshot",
|
||||
identity: {
|
||||
state: "resolved",
|
||||
source: "status",
|
||||
acpxSessionId: "acpx-sid-oneshot",
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
await manager.runTurn({
|
||||
cfg: baseCfg,
|
||||
sessionKey,
|
||||
text: "after restart",
|
||||
mode: "prompt",
|
||||
requestId: "r-binding-oneshot",
|
||||
});
|
||||
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(1);
|
||||
const ensureInput = runtimeState.ensureSession.mock.calls[0]?.[0] as
|
||||
| { resumeSessionId?: string; mode?: string }
|
||||
| undefined;
|
||||
expect(ensureInput).toMatchObject({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
});
|
||||
expect(ensureInput?.resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to a fresh ensure when reopening a persisted ACP backend session id fails", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
runtimeState.ensureSession.mockImplementation(async (inputUnknown: unknown) => {
|
||||
const input = inputUnknown as {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
resumeSessionId?: string;
|
||||
};
|
||||
if (input.resumeSessionId) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
"failed to resume persisted ACP session",
|
||||
);
|
||||
}
|
||||
return {
|
||||
sessionKey: input.sessionKey,
|
||||
backend: "acpx",
|
||||
runtimeSessionName: `${input.sessionKey}:${input.mode}:runtime`,
|
||||
backendSessionId: "acpx-sid-fresh",
|
||||
};
|
||||
});
|
||||
runtimeState.getStatus.mockResolvedValue({
|
||||
summary: "status=alive",
|
||||
backendSessionId: "acpx-sid-fresh",
|
||||
details: { status: "alive" },
|
||||
});
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
const sessionKey = "agent:codex:acp:binding:discord:default:retry-fresh";
|
||||
let currentMeta: SessionAcpMeta = {
|
||||
...readySessionMeta(),
|
||||
runtimeSessionName: sessionKey,
|
||||
identity: {
|
||||
state: "resolved",
|
||||
source: "status",
|
||||
acpxSessionId: "acpx-sid-stale",
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
|
||||
const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey;
|
||||
return {
|
||||
sessionKey: key,
|
||||
storeSessionKey: key,
|
||||
acp: currentMeta,
|
||||
};
|
||||
});
|
||||
hoisted.upsertAcpSessionMetaMock.mockImplementation(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
mutate: (
|
||||
current: SessionAcpMeta | undefined,
|
||||
entry: { acp?: SessionAcpMeta } | undefined,
|
||||
) => SessionAcpMeta | null | undefined;
|
||||
};
|
||||
const next = params.mutate(currentMeta, { acp: currentMeta });
|
||||
if (next) {
|
||||
currentMeta = next;
|
||||
}
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
updatedAt: Date.now(),
|
||||
acp: currentMeta,
|
||||
};
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
await manager.runTurn({
|
||||
cfg: baseCfg,
|
||||
sessionKey,
|
||||
text: "after restart",
|
||||
mode: "prompt",
|
||||
requestId: "r-binding-retry-fresh",
|
||||
});
|
||||
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeState.ensureSession.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
resumeSessionId: "acpx-sid-stale",
|
||||
});
|
||||
const retryInput = runtimeState.ensureSession.mock.calls[1]?.[0] as
|
||||
| { resumeSessionId?: string }
|
||||
| undefined;
|
||||
expect(retryInput?.resumeSessionId).toBeUndefined();
|
||||
expect(currentMeta.identity?.acpxSessionId).toBe("acpx-sid-fresh");
|
||||
});
|
||||
|
||||
it("enforces acp.maxConcurrentSessions when opening new runtime handles", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
|
||||
@@ -71,6 +71,15 @@ export function identityHasStableSessionId(identity: SessionAcpIdentity | undefi
|
||||
return Boolean(identity?.acpxSessionId || identity?.agentSessionId);
|
||||
}
|
||||
|
||||
export function resolveRuntimeResumeSessionId(
|
||||
identity: SessionAcpIdentity | undefined,
|
||||
): string | undefined {
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeText(identity.acpxSessionId) ?? normalizeText(identity.agentSessionId);
|
||||
}
|
||||
|
||||
export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean {
|
||||
if (!identity) {
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user