fix(plugins): preserve subagent lifecycle results (#126167)

This commit is contained in:
Peter Steinberger
2026-08-18 22:22:20 -07:00
committed by GitHub
parent a6b77ffc07
commit 404eddbc6a
9 changed files with 89 additions and 55 deletions
+2
View File
@@ -439,6 +439,8 @@ snapshots; OpenClaw owns all persistence and lifecycle coordination.
Gateway-backed runs return the canonical accepted `sessionKey` alongside `runId`. The field is optional in the TypeScript result only so explicit custom runtimes remain compatible.
`waitForRun(...)` returns the canonical Gateway wait result. `status` is `"ok"`, `"error"`, `"timeout"`, or `"pending"`; pending is a normal nonterminal observation, not an exception. Optional `error`, `startedAt`, `endedAt`, `stopReason`, `livenessState`, `yielded`, `pendingError`, `timeoutPhase`, `providerStarted`, and `terminalReply` metadata is preserved so callers can distinguish observation timeouts from terminal outcomes. `timeoutMs` bounds the wait call; it does not cancel the run.
<Warning>
Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries.<id>.subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests are rejected.
</Warning>
+4 -20
View File
@@ -25,17 +25,16 @@ import {
buildAgentRunTerminalOutcomeFromWaitResult,
type AgentRunTerminalOutcome,
} from "./agent-run-terminal-outcome.js";
import {
normalizeAgentRunTerminalReplySnapshot,
type AgentRunTerminalReplySnapshot,
} from "./agent-run-terminal-reply.js";
import { normalizeAgentRunTerminalReplySnapshot } from "./agent-run-terminal-reply.js";
import {
normalizeAgentRunTimeoutPhase,
normalizeProviderStarted,
type AgentRunTimeoutPhase,
} from "./run-timeout-attribution.js";
import type { AgentWaitResult } from "./run-wait.types.js";
import { extractStoredAssistantText, stripToolMessages } from "./tools/chat-history-text.js";
export type { AgentWaitResult };
type GatewayCaller = typeof callGateway;
function resolveRunWaitTimeoutMs(value: number | undefined): number {
@@ -58,21 +57,6 @@ export type AssistantReplySnapshot = {
fingerprint?: string;
};
/** Normalized terminal or pending state returned by `agent.wait`. */
export type AgentWaitResult = {
status: "ok" | "timeout" | "error" | "pending";
error?: string;
startedAt?: number;
endedAt?: number;
stopReason?: string;
livenessState?: string;
yielded?: boolean;
pendingError?: boolean;
timeoutPhase?: AgentRunTimeoutPhase;
providerStarted?: boolean;
terminalReply?: AgentRunTerminalReplySnapshot;
};
/** Summary returned after waiting for a dynamic set of pending runs to drain. */
type AgentRunsDrainResult = {
timedOut: boolean;
+17
View File
@@ -0,0 +1,17 @@
import type { AgentRunTerminalReplySnapshot } from "./agent-run-terminal-reply.js";
import type { AgentRunTimeoutPhase } from "./run-timeout-attribution.js";
/** Normalized terminal or pending state returned by `agent.wait`. */
export type AgentWaitResult = {
status: "ok" | "timeout" | "error" | "pending";
error?: string;
startedAt?: number;
endedAt?: number;
stopReason?: string;
livenessState?: string;
yielded?: boolean;
pendingError?: boolean;
timeoutPhase?: AgentRunTimeoutPhase;
providerStarted?: boolean;
terminalReply?: AgentRunTerminalReplySnapshot;
};
+1
View File
@@ -36,6 +36,7 @@ export function isAcceptedAgentDedupePayload(payload: unknown): payload is {
ownerDeviceId?: unknown;
reservationId?: unknown;
runId?: unknown;
runtime?: unknown;
sessionKey?: unknown;
status: "accepted";
} {
@@ -1,3 +1,4 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes, type AgentWaitParams } from "../../../packages/gateway-protocol/src/index.js";
import { scheduleMainSessionRecoveryPendingTarget } from "../../agents/main-session-recovery/main-session-recovery-owner-release.js";
@@ -70,6 +71,7 @@ function replayAgentTurnIfCached(params: {
typeof cached.payload.agentId === "string" && cached.payload.agentId.trim()
? cached.payload.agentId.trim()
: undefined;
const cachedRuntime = asOptionalRecord(cached.payload.runtime);
params.io.emitAcceptance(
[
true,
@@ -78,6 +80,7 @@ function replayAgentTurnIfCached(params: {
status: "in_flight" as const,
...(cachedSessionKey ? { sessionKey: cachedSessionKey } : {}),
...(cachedAgentId ? { agentId: cachedAgentId } : {}),
...(cachedRuntime ? { runtime: cachedRuntime } : {}),
},
undefined,
],
@@ -2553,7 +2553,7 @@ describe("gateway agent handler", () => {
});
});
it("preserves selected-global agent id on cached accepted responses", async () => {
it("preserves accepted session and runtime metadata on cached responses", async () => {
const context = makeContext();
mocks.listAgentIds.mockReturnValue(["main", "work"]);
mocks.loadConfigReturn = {
@@ -2569,6 +2569,11 @@ describe("gateway agent handler", () => {
sessionKey: "global",
agentId: "work",
status: "accepted",
runtime: {
harness: "claude-cli",
provider: "anthropic",
model: "claude-sonnet-4-6",
},
},
});
const respond = vi.fn();
@@ -2588,6 +2593,11 @@ describe("gateway agent handler", () => {
sessionKey: "global",
agentId: "work",
status: "in_flight",
runtime: {
harness: "claude-cli",
provider: "anthropic",
model: "claude-sonnet-4-6",
},
});
expect(mocks.agentCommand).not.toHaveBeenCalled();
});
@@ -341,29 +341,47 @@ describe("createGatewaySubagentRuntime.run subagent_ended tracking (#59164)", ()
).rejects.toThrow(/not found/);
});
test("normalizes completed agent.wait envelopes for plugin subagents", async () => {
test.each([
{
name: "pending queue observation",
result: {
status: "pending",
timeoutPhase: "queue",
providerStarted: false,
},
},
{
name: "metadata-rich observation timeout",
result: {
status: "timeout",
error: "provider retry is still pending",
startedAt: 1_000,
endedAt: 2_000,
stopReason: "timeout",
livenessState: "blocked",
yielded: true,
pendingError: true,
timeoutPhase: "provider",
providerStarted: true,
terminalReply: { disposition: "empty" },
},
},
{
name: "legacy completed status",
result: { status: "completed" },
expected: { status: "ok" },
},
{
name: "legacy completed error",
result: { status: "error", error: "completed" },
expected: { status: "ok" },
},
])("preserves the agent.wait $name result", async ({ result, expected = result }) => {
const serverPlugins = await loadServerPlugins();
const runtime = serverPlugins.createGatewaySubagentRuntime();
serverPlugins.setFallbackGatewayContext(createTestContext("plugin-wait", createTestCfg()));
internalAgentTurnFacade.wait.mockResolvedValue(result);
internalAgentTurnFacade.wait.mockResolvedValue({ status: "completed" });
await expect(runtime.waitForRun({ runId: "plugin-run-completed" })).resolves.toEqual({
status: "ok",
});
});
test("normalizes malformed completed wait errors for plugin subagents", async () => {
const serverPlugins = await loadServerPlugins();
const runtime = serverPlugins.createGatewaySubagentRuntime();
serverPlugins.setFallbackGatewayContext(
createTestContext("plugin-wait-error", createTestCfg()),
);
internalAgentTurnFacade.wait.mockResolvedValue({ status: "error", error: "completed" });
await expect(runtime.waitForRun({ runId: "plugin-run-error-completed" })).resolves.toEqual({
status: "ok",
});
await expect(runtime.waitForRun({ runId: "plugin-run-wait" })).resolves.toEqual(expected);
});
});
+11 -8
View File
@@ -3,6 +3,7 @@
import { randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import type { AgentWaitResult } from "../agents/run-wait.types.js";
import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js";
import { allowsProcessHomeSessionScan } from "../config/paths.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
@@ -322,7 +323,9 @@ export function createGatewaySubagentRuntime(
return { runId, sessionKey, ...(runtime ? { runtime } : {}) };
},
async waitForRun(params) {
const payload = await dispatchGatewayMethodInProcess<{ status?: string; error?: string }>(
const payload = await dispatchGatewayMethodInProcess<
Omit<AgentWaitResult, "status"> & { status?: string }
>(
"agent.wait",
{
runId: params.runId,
@@ -330,20 +333,20 @@ export function createGatewaySubagentRuntime(
},
{ resolveGatewayContext },
);
let status = payload?.status;
const { status: rawStatus, error, ...metadata } = payload;
let status = rawStatus;
if (status === "completed" || status === "succeeded") {
status = "ok";
} else if (status === "error" && payload?.error?.trim().toLowerCase() === "completed") {
} else if (status === "error" && error?.trim().toLowerCase() === "completed") {
status = "ok";
}
if (status !== "ok" && status !== "error" && status !== "timeout") {
throw new Error(`Gateway agent.wait returned unexpected status: ${payload?.status}`);
if (status !== "ok" && status !== "error" && status !== "timeout" && status !== "pending") {
throw new Error(`Gateway agent.wait returned unexpected status: ${rawStatus}`);
}
return {
...metadata,
status,
...(status !== "ok" &&
typeof payload?.error === "string" &&
payload.error && { error: payload.error }),
...(status !== "ok" && error ? { error } : {}),
};
},
getSessionMessages,
+2 -6
View File
@@ -2,6 +2,7 @@
// Owner schema module import keeps the ProtocolSchemas registry out of the
// public plugin-sdk dts graph (check-plugin-sdk-exports guards this).
import type { NodePluginToolDescriptor } from "../../../packages/gateway-protocol/src/schema/nodes.js";
import type { AgentWaitResult } from "../../agents/run-wait.types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { OperatorScope } from "../../gateway/operator-scopes.js";
import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js";
@@ -51,11 +52,6 @@ type SubagentWaitParams = {
timeoutMs?: number;
};
type SubagentWaitResult = {
status: "ok" | "error" | "timeout";
error?: string;
};
type SubagentGetSessionMessagesParams = {
sessionKey: string;
limit?: number;
@@ -127,7 +123,7 @@ export type PluginRuntime = PluginRuntimeCore & {
};
subagent: {
run: (params: SubagentRunParams) => Promise<SubagentRunResult>;
waitForRun: (params: SubagentWaitParams) => Promise<SubagentWaitResult>;
waitForRun: (params: SubagentWaitParams) => Promise<AgentWaitResult>;
getSessionMessages: (
params: SubagentGetSessionMessagesParams,
) => Promise<SubagentGetSessionMessagesResult>;