fix: isolate ACP spawned runs

This commit is contained in:
Peter Steinberger
2026-04-25 22:06:45 +01:00
parent 63fac653ed
commit 2febe72108
6 changed files with 52 additions and 1 deletions

View File

@@ -79,6 +79,9 @@ Docs: https://docs.openclaw.ai
- ACP/sessions_spawn: reject normal OpenClaw config agent ids when callers
explicitly request `runtime="acp"`, while allowing agents configured with
`runtime.type="acp"` to resolve to their ACP harness id. Fixes #63914.
- 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/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.

View File

@@ -336,6 +336,7 @@ Interface details:
- `resumeSessionId` (optional): resume an existing ACP session instead of creating a new one. The agent replays its conversation history via `session/load`. Requires `runtime: "acp"`.
- `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events.
- When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
- `runTimeoutSeconds` (optional): aborts the ACP child turn after N seconds. `0` keeps the turn on the gateway's no-timeout path. The same value is applied to the Gateway run and ACP runtime so stalled/quota-exhausted harnesses do not occupy the parent agent lane indefinitely.
- `model` (optional): explicit model override for the ACP child session. Codex ACP spawns normalize OpenClaw Codex refs such as `openai-codex/gpt-5.4` to Codex ACP startup config before `session/new`; slash forms such as `openai-codex/gpt-5.4/high` also set Codex ACP reasoning effort. Other harnesses must advertise ACP `models` and support `session/set_model`; otherwise OpenClaw/acpx fails clearly instead of silently falling back to the target agent default.
- `thinking` (optional): explicit thinking/reasoning effort for the ACP child session. For Codex ACP, `minimal` maps to low effort, `low`/`medium`/`high`/`xhigh` map directly, and `off` omits the reasoning-effort startup override.
@@ -359,6 +360,7 @@ One-shot ACP sessions spawned by another agent run are background children, simi
- The parent asks for work with `sessions_spawn({ runtime: "acp", mode: "run" })`.
- The child runs in its own ACP harness session.
- Child turns run on the same background lane used by native sub-agent spawns, so a slow ACP harness does not block unrelated main-session work.
- Completion reports back through the internal task-completion announce path.
- The parent rewrites the child result in normal assistant voice when a user-facing reply is useful.

View File

@@ -168,6 +168,8 @@ type AgentCallParams = {
channel?: string;
to?: string;
threadId?: string;
lane?: string;
timeout?: number;
};
type CrossAgentWorkspaceFixture = {
workspaceRoot: string;
@@ -330,6 +332,12 @@ function expectAgentGatewayCall(overrides: AgentCallParams): void {
expect(agentCall?.params?.channel).toBe(overrides.channel);
expect(agentCall?.params?.to).toBe(overrides.to);
expect(agentCall?.params?.threadId).toBe(overrides.threadId);
if (Object.hasOwn(overrides, "lane")) {
expect(agentCall?.params?.lane).toBe(overrides.lane);
}
if (Object.hasOwn(overrides, "timeout")) {
expect(agentCall?.params?.timeout).toBe(overrides.timeout);
}
}
function resolveMatrixRoomTargetForTest(value: string | undefined): string | undefined {
@@ -701,6 +709,7 @@ describe("spawnAcpDirect", () => {
expect(agentCall?.params?.to).toBe("channel:child-thread");
expect(agentCall?.params?.threadId).toBe("child-thread");
expect(agentCall?.params?.deliver).toBe(true);
expect(agentCall?.params?.lane).toBe("subagent");
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: expect.stringMatching(/^agent:codex:acp:/),
@@ -742,6 +751,33 @@ describe("spawnAcpDirect", () => {
);
});
it("applies ACP spawn run timeout to runtime options and dispatch", async () => {
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
runTimeoutSeconds: 45,
},
{
agentSessionKey: "agent:main:main",
},
);
expectAcceptedSpawn(result);
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: expect.stringMatching(/^agent:codex:acp:/),
agent: "codex",
runtimeOptions: {
timeoutSeconds: 45,
},
}),
);
const agentCall = findAgentGatewayCall();
expect(agentCall?.params?.lane).toBe("subagent");
expect(agentCall?.params?.timeout).toBe(45);
});
it("rejects OpenClaw config agent ids when runtime=acp targets a native agent", async () => {
replaceSpawnConfig({
...createDefaultSpawnConfig(),

View File

@@ -70,6 +70,7 @@ import {
startAcpSpawnParentStreamRelay,
} from "./acp-spawn-parent-stream.js";
import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
import { AGENT_LANE_SUBAGENT } from "./lanes.js";
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js";
import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js";
@@ -99,6 +100,7 @@ export type SpawnAcpParams = {
resumeSessionId?: string;
model?: string;
thinking?: string;
runTimeoutSeconds?: number;
cwd?: string;
mode?: SpawnAcpMode;
thread?: boolean;
@@ -854,6 +856,7 @@ async function initializeAcpSpawnRuntime(params: {
resumeSessionId?: string;
model?: string;
thinking?: string;
runTimeoutSeconds?: number;
cwd?: string;
}): Promise<AcpSpawnInitializedRuntime> {
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.targetAgentId });
@@ -879,10 +882,11 @@ async function initializeAcpSpawnRuntime(params: {
mode: params.runtimeMode,
resumeSessionId: params.resumeSessionId,
runtimeOptions:
params.model || params.thinking
params.model || params.thinking || params.runTimeoutSeconds
? {
...(params.model ? { model: params.model } : {}),
...(params.thinking ? { thinking: params.thinking } : {}),
...(params.runTimeoutSeconds ? { timeoutSeconds: params.runTimeoutSeconds } : {}),
}
: undefined,
cwd: params.cwd,
@@ -1229,6 +1233,7 @@ export async function spawnAcpDirect(
resumeSessionId: params.resumeSessionId,
model: params.model,
thinking: params.thinking,
runTimeoutSeconds: params.runTimeoutSeconds,
cwd: runtimeCwd,
});
initializedRuntime = initializedSession.runtimeCloseHandle;
@@ -1312,6 +1317,8 @@ export async function spawnAcpDirect(
threadId: deliveryPlan.threadId,
idempotencyKey: childIdem,
deliver: deliveryPlan.useInlineDelivery,
lane: AGENT_LANE_SUBAGENT,
...(params.runTimeoutSeconds != null ? { timeout: params.runTimeoutSeconds } : {}),
label: params.label || undefined,
},
timeoutMs: 10_000,

View File

@@ -221,6 +221,7 @@ describe("sessions_spawn tool", () => {
task: "investigate the failing CI run",
agentId: "codex",
cwd: "/workspace",
runTimeoutSeconds: 45,
thread: true,
mode: "session",
streamTo: "parent",
@@ -236,6 +237,7 @@ describe("sessions_spawn tool", () => {
task: "investigate the failing CI run",
agentId: "codex",
cwd: "/workspace",
runTimeoutSeconds: 45,
thread: true,
mode: "session",
streamTo: "parent",

View File

@@ -260,6 +260,7 @@ export function createSessionsSpawnTool(
resumeSessionId,
model: modelOverride,
thinking: thinkingOverrideRaw,
runTimeoutSeconds,
cwd,
mode: mode === "run" || mode === "session" ? mode : undefined,
thread,