From 8f1a214a2342f435f24c2eb7f91e2ac15ec16dee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 22:15:46 +0100 Subject: [PATCH] fix: resolve oneshot ACP identities before close --- CHANGELOG.md | 3 + src/acp/control-plane/manager.core.ts | 11 +--- src/acp/control-plane/manager.test.ts | 85 +++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c94472e700..2a36aca45d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,9 @@ Docs: https://docs.openclaw.ai - ACP/sessions_spawn: apply `runTimeoutSeconds` to ACP child turns and dispatch those turns on the background subagent lane, so quota-stalled ACP harnesses do not occupy the main agent lane indefinitely. Fixes #68823. +- ACP/oneshot: reconcile runtime session identity before closing completed + oneshot ACP runs, so finished `sessions.json` entries do not stay stuck with + `acp.identity.state="pending"`. - ACP/models: document that non-Codex ACP model overrides require adapter support for ACP `models` plus `session/set_model`, so unsupported harnesses fail clearly instead of silently falling back to their defaults. diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index f8a2957eae7..cded1e31978 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -929,15 +929,8 @@ export class AcpSessionManager { if (activeTurn && this.activeTurnBySession.get(actorKey) === activeTurn) { this.activeTurnBySession.delete(actorKey); } - if ( - !retryFreshHandle && - !skipPostTurnCleanup && - runtime && - handle && - meta && - meta.mode !== "oneshot" - ) { - ({ handle } = await this.reconcileRuntimeSessionIdentifiers({ + if (!retryFreshHandle && !skipPostTurnCleanup && runtime && handle && meta) { + ({ handle, meta } = await this.reconcileRuntimeSessionIdentifiers({ cfg: input.cfg, sessionKey, runtime, diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 4d345c6dc32..3e5a5148653 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -2399,6 +2399,91 @@ describe("AcpSessionManager", () => { expect(currentMeta.identity?.agentSessionId).toBe("agent-fresh"); }); + it("reconciles oneshot ACP identity from runtime status before closing after a turn", async () => { + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockResolvedValue({ + sessionKey: "agent:codex:acp:session-1", + backend: "acpx", + runtimeSessionName: "runtime-1", + backendSessionId: "acpx-oneshot", + }); + runtimeState.getStatus.mockResolvedValue({ + summary: "status=done", + backendSessionId: "acpx-oneshot", + agentSessionId: "agent-oneshot", + details: { status: "done" }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + + let currentMeta: SessionAcpMeta | undefined; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = + (paramsUnknown as { sessionKey?: string }).sessionKey ?? "agent:codex:acp:session-1"; + return { + sessionKey, + storeSessionKey: sessionKey, + 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.initializeSession({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + agent: "codex", + mode: "oneshot", + }); + + expect(currentMeta?.identity).toMatchObject({ + state: "pending", + acpxSessionId: "acpx-oneshot", + source: "ensure", + }); + + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }); + + expect(runtimeState.getStatus).toHaveBeenCalledTimes(2); + expect(runtimeState.close).toHaveBeenCalledWith({ + handle: expect.objectContaining({ + backendSessionId: "acpx-oneshot", + agentSessionId: "agent-oneshot", + }), + reason: "oneshot-complete", + }); + expect(currentMeta?.identity).toMatchObject({ + state: "resolved", + acpxSessionId: "acpx-oneshot", + agentSessionId: "agent-oneshot", + source: "status", + }); + }); + it("reconciles pending ACP identities during startup scan", async () => { const runtimeState = createRuntime(); runtimeState.getStatus.mockResolvedValue({