Skip to content

Commit 3f68402

Browse files
authored
fix: unify reply lifecycle across stop, rotation, and restart (#61267) (thanks @dutifulbob)
1 parent bb494ea commit 3f68402

23 files changed

Lines changed: 2253 additions & 365 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
### Fixes
4848

4949
- Security: preserve restrictive plugin-only tool allowlists, require owner access for `/allowlist add` and `/allowlist remove`, fail closed when `before_tool_call` hooks crash, block browser SSRF redirect bypasses earlier, and keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins. (#58476, #59836, #59822, #58771, #59120) Thanks @eleqtrizit and @pgondhi987.
50+
- Auto-reply: unify reply lifecycle ownership across preflight compaction, session rotation, CLI-backed runs, and gateway restart handling so `/stop` and same-session overlap checks target the right active turn and restart-interrupted turns return the restart notice instead of being silently dropped. (#61267) Thanks @dutifulbob.
5051
- Agents/Claude CLI/security: clear inherited Claude Code config-root and plugin-root env overrides like `CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_PLUGIN_*`, so OpenClaw-launched Claude CLI runs cannot be silently pointed at an alternate Claude config/plugin tree with different hooks, plugins, or auth context. Thanks @vincentkoc.
5152
- Agents/Claude CLI/security: clear inherited Claude Code provider-routing and managed-auth env overrides, and mark OpenClaw-launched Claude CLI runs as host-managed, so Claude CLI backdoor sessions cannot be silently redirected to proxy, Bedrock, Vertex, Foundry, or parent-managed token contexts. Thanks @vincentkoc.
5253
- Agents/Claude CLI/security: force host-managed Claude CLI backdoor runs to `--setting-sources user`, even under custom backend arg overrides, so repo-local `.claude` project/local settings, hooks, and plugin discovery do not silently execute inside non-interactive OpenClaw sessions. Thanks @vincentkoc.

src/agents/cli-runner.spawn.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,70 @@ describe("runCliAgent spawn path", () => {
150150
expect(input.scopeKey).toContain("thread-123");
151151
});
152152

153+
it("cancels the managed CLI run when the abort signal fires", async () => {
154+
const runCliAgent = await setupCliRunnerTestModule();
155+
const abortController = new AbortController();
156+
let resolveWait!: (value: {
157+
reason:
158+
| "manual-cancel"
159+
| "overall-timeout"
160+
| "no-output-timeout"
161+
| "spawn-error"
162+
| "signal"
163+
| "exit";
164+
exitCode: number | null;
165+
exitSignal: NodeJS.Signals | number | null;
166+
durationMs: number;
167+
stdout: string;
168+
stderr: string;
169+
timedOut: boolean;
170+
noOutputTimedOut: boolean;
171+
}) => void;
172+
const cancel = vi.fn((reason?: string) => {
173+
resolveWait({
174+
reason: reason === "manual-cancel" ? "manual-cancel" : "signal",
175+
exitCode: null,
176+
exitSignal: null,
177+
durationMs: 50,
178+
stdout: "",
179+
stderr: "",
180+
timedOut: false,
181+
noOutputTimedOut: false,
182+
});
183+
});
184+
supervisorSpawnMock.mockResolvedValueOnce({
185+
runId: "run-supervisor",
186+
pid: 1234,
187+
startedAtMs: Date.now(),
188+
stdin: undefined,
189+
wait: vi.fn(
190+
async () =>
191+
await new Promise((resolve) => {
192+
resolveWait = resolve;
193+
}),
194+
),
195+
cancel,
196+
});
197+
198+
const runPromise = runCliAgent({
199+
sessionId: "s1",
200+
sessionFile: "/tmp/session.jsonl",
201+
workspaceDir: "/tmp",
202+
prompt: "hi",
203+
provider: "codex-cli",
204+
model: "gpt-5.4",
205+
timeoutMs: 1_000,
206+
runId: "run-abort",
207+
abortSignal: abortController.signal,
208+
});
209+
210+
await Promise.resolve();
211+
abortController.abort();
212+
213+
await expect(runPromise).rejects.toMatchObject({ name: "AbortError" });
214+
expect(cancel).toHaveBeenCalledWith("manual-cancel");
215+
});
216+
153217
it("streams CLI text deltas from JSONL stdout", async () => {
154218
const runCliAgent = await setupCliRunnerTestModule();
155219
const agentEvents: Array<{ stream: string; text?: string; delta?: string }> = [];

src/agents/cli-runner/execute.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export function setCliRunnerExecuteTestDeps(overrides: Partial<typeof executeDep
3636
Object.assign(executeDeps, overrides);
3737
}
3838

39+
function createCliAbortError(): Error {
40+
const error = new Error("CLI run aborted");
41+
error.name = "AbortError";
42+
return error;
43+
}
44+
3945
function buildCliLogArgs(params: {
4046
args: string[];
4147
systemPromptArg?: string;
@@ -84,6 +90,9 @@ export async function executePreparedCliRun(
8490
cliSessionIdToUse?: string,
8591
): Promise<CliOutput> {
8692
const params = context.params;
93+
if (params.abortSignal?.aborted) {
94+
throw createCliAbortError();
95+
}
8796
const backend = context.preparedBackend.backend;
8897
const { sessionId: resolvedSessionId, isNew } = resolveSessionIdToSend({
8998
backend,
@@ -226,8 +235,38 @@ export async function executePreparedCliRun(
226235
input: stdinPayload,
227236
onStdout: streamingParser ? (chunk: string) => streamingParser.push(chunk) : undefined,
228237
});
229-
const result = await managedRun.wait();
238+
const replyBackendHandle = params.replyOperation
239+
? {
240+
kind: "cli" as const,
241+
cancel: () => {
242+
managedRun.cancel("manual-cancel");
243+
},
244+
isStreaming: () => false,
245+
}
246+
: undefined;
247+
if (replyBackendHandle) {
248+
params.replyOperation?.attachBackend(replyBackendHandle);
249+
}
250+
const abortManagedRun = () => {
251+
managedRun.cancel("manual-cancel");
252+
};
253+
params.abortSignal?.addEventListener("abort", abortManagedRun, { once: true });
254+
if (params.abortSignal?.aborted) {
255+
abortManagedRun();
256+
}
257+
let result: Awaited<ReturnType<typeof managedRun.wait>>;
258+
try {
259+
result = await managedRun.wait();
260+
} finally {
261+
if (replyBackendHandle) {
262+
params.replyOperation?.detachBackend(replyBackendHandle);
263+
}
264+
params.abortSignal?.removeEventListener("abort", abortManagedRun);
265+
}
230266
streamingParser?.finish();
267+
if (params.abortSignal?.aborted && result.reason === "manual-cancel") {
268+
throw createCliAbortError();
269+
}
231270

232271
const stdout = result.stdout.trim();
233272
const stderr = result.stderr.trim();

src/agents/cli-runner/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ImageContent } from "@mariozechner/pi-ai";
2+
import type { ReplyOperation } from "../../auto-reply/reply/reply-run-registry.js";
23
import type { ThinkLevel } from "../../auto-reply/thinking.js";
34
import type { OpenClawConfig } from "../../config/config.js";
45
import type { CliSessionBinding } from "../../config/sessions.js";
@@ -32,6 +33,8 @@ export type RunCliAgentParams = {
3233
imageOrder?: PromptImageOrderEntry[];
3334
messageProvider?: string;
3435
agentAccountId?: string;
36+
abortSignal?: AbortSignal;
37+
replyOperation?: ReplyOperation;
3538
};
3639

3740
export type CliPreparedBackend = {

src/agents/pi-embedded-runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {
1919
isEmbeddedPiRunActive,
2020
isEmbeddedPiRunStreaming,
2121
queueEmbeddedPiMessage,
22+
resolveActiveEmbeddedRunSessionId,
2223
waitForEmbeddedPiRunEnd,
2324
} from "./pi-embedded-runner/runs.js";
2425
export { buildEmbeddedSandboxInfo } from "./pi-embedded-runner/sandbox-info.js";

src/agents/pi-embedded-runner/run.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,28 @@ export async function runEmbeddedPiAgent(
120120
: "markdown");
121121
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
122122

123-
return enqueueSession(() =>
124-
enqueueGlobal(async () => {
123+
const throwIfAborted = () => {
124+
if (!params.abortSignal?.aborted) {
125+
return;
126+
}
127+
const reason = params.abortSignal.reason;
128+
if (reason instanceof Error) {
129+
throw reason;
130+
}
131+
const abortErr =
132+
reason !== undefined
133+
? new Error("Operation aborted", { cause: reason })
134+
: new Error("Operation aborted");
135+
abortErr.name = "AbortError";
136+
throw abortErr;
137+
};
138+
139+
throwIfAborted();
140+
141+
return enqueueSession(() => {
142+
throwIfAborted();
143+
return enqueueGlobal(async () => {
144+
throwIfAborted();
125145
const started = Date.now();
126146
const workspaceResolution = resolveRunWorkspaceDir({
127147
workspaceDir: params.workspaceDir,
@@ -569,6 +589,7 @@ export async function runEmbeddedPiAgent(
569589
timeoutMs: params.timeoutMs,
570590
runId: params.runId,
571591
abortSignal: params.abortSignal,
592+
replyOperation: params.replyOperation,
572593
shouldEmitToolResult: params.shouldEmitToolResult,
573594
shouldEmitToolOutput: params.shouldEmitToolOutput,
574595
onPartialReply: params.onPartialReply,
@@ -1503,6 +1524,6 @@ export async function runEmbeddedPiAgent(
15031524
});
15041525
}
15051526
}
1506-
}),
1507-
);
1527+
});
1528+
});
15081529
}

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1372,14 +1372,24 @@ export async function runEmbeddedAttempt(
13721372
getCompactionCount,
13731373
} = subscription;
13741374

1375-
const queueHandle: EmbeddedPiQueueHandle = {
1375+
const queueHandle: EmbeddedPiQueueHandle & {
1376+
kind: "embedded";
1377+
cancel: (reason?: "user_abort" | "restart" | "superseded") => void;
1378+
} = {
1379+
kind: "embedded",
13761380
queueMessage: async (text: string) => {
13771381
await activeSession.steer(text);
13781382
},
13791383
isStreaming: () => activeSession.isStreaming,
13801384
isCompacting: () => subscription.isCompacting(),
1385+
cancel: () => {
1386+
abortRun();
1387+
},
13811388
abort: abortRun,
13821389
};
1390+
if (params.replyOperation) {
1391+
params.replyOperation.attachBackend(queueHandle);
1392+
}
13831393
setActiveEmbeddedRun(params.sessionId, queueHandle, params.sessionKey);
13841394

13851395
let abortWarnTimer: NodeJS.Timeout | undefined;
@@ -1945,6 +1955,9 @@ export async function runEmbeddedAttempt(
19451955
`CRITICAL: unsubscribe failed, possible resource leak: runId=${params.runId} ${String(err)}`,
19461956
);
19471957
}
1958+
if (params.replyOperation) {
1959+
params.replyOperation.detachBackend(queueHandle);
1960+
}
19481961
clearActiveEmbeddedRun(params.sessionId, queueHandle, params.sessionKey);
19491962
params.abortSignal?.removeEventListener?.("abort", onAbort);
19501963
}

src/agents/pi-embedded-runner/run/params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ImageContent } from "@mariozechner/pi-ai";
2+
import type { ReplyOperation } from "../../../auto-reply/reply/reply-run-registry.js";
23
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
34
import type { ReplyPayload } from "../../../auto-reply/types.js";
45
import type { OpenClawConfig } from "../../../config/config.js";
@@ -108,6 +109,7 @@ export type RunEmbeddedPiAgentParams = {
108109
timeoutMs: number;
109110
runId: string;
110111
abortSignal?: AbortSignal;
112+
replyOperation?: ReplyOperation;
111113
shouldEmitToolResult?: () => boolean;
112114
shouldEmitToolOutput?: () => boolean;
113115
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;

0 commit comments

Comments
 (0)