Skip to content

Commit 7f4bd45

Browse files
clawsweeper[bot]samzongTakhoffman
authored
fix(agents): preserve accepted spawn terminal success (#85135)
Summary: - The branch adds accepted `sessions_spawn` tracking through embedded Pi subscribe, runner, fallback, replay, lifecycle, tests, deadcode allowlist, and changelog surfaces. - Reproducibility: yes. at source level. Current main documents accepted `sessions_spawn` results but the pre- ... and classifier paths do not carry that accepted child-run fact into incomplete-turn or fallback decisions. Automerge notes: - PR branch already contained follow-up commit before automerge: test(qa-lab): allow codex fixtures in deadcode - PR branch already contained follow-up commit before automerge: fix(agents): preserve accepted spawn terminal success Validation: - ClawSweeper review passed for head 0f6d92b. - Required merge gates passed before the squash merge. Prepared head SHA: 0f6d92b Review: #85135 (comment) Co-authored-by: samzong <samzong.lu@gmail.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 221f534 commit 7f4bd45

23 files changed

Lines changed: 454 additions & 11 deletions

CHANGELOG.md

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

2626
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
27+
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
2728
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
2829
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
2930
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.

scripts/deadcode-unused-files.allowlist.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,8 @@ export const KNIP_UNUSED_FILE_ALLOWLIST = [
3737
// Knip can disagree across supported local/CI platforms for files that are
3838
// only reachable through test-only import graphs. Ignore these when reported,
3939
// but do not require them to be reported.
40-
export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = ["src/gateway/test/server-sessions-helpers.ts"];
40+
export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
41+
"extensions/qa-lab/src/auth-profile-fixture.ts",
42+
"extensions/qa-lab/src/codex-plugin-fixture.ts",
43+
"src/gateway/test/server-sessions-helpers.ts",
44+
];
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { normalizeOptionalString } from "../shared/string-coerce.js";
2+
3+
export type AcceptedSessionSpawn = {
4+
runId: string;
5+
childSessionKey: string;
6+
};
7+
8+
function asRecord(value: unknown): Record<string, unknown> | undefined {
9+
return value && typeof value === "object" && !Array.isArray(value)
10+
? (value as Record<string, unknown>)
11+
: undefined;
12+
}
13+
14+
export function normalizeAcceptedSessionSpawnResult(result: unknown): AcceptedSessionSpawn | null {
15+
const details = asRecord(asRecord(result)?.details);
16+
if (!details || details.status !== "accepted") {
17+
return null;
18+
}
19+
const runId = normalizeOptionalString(details.runId);
20+
const childSessionKey = normalizeOptionalString(details.childSessionKey);
21+
if (!runId || !childSessionKey) {
22+
return null;
23+
}
24+
return { runId, childSessionKey };
25+
}
26+
27+
export function hasAcceptedSessionSpawn(acceptedSessionSpawns?: readonly unknown[]): boolean {
28+
return (acceptedSessionSpawns ?? []).some((spawn) => {
29+
const record = asRecord(spawn);
30+
if (!record) {
31+
return false;
32+
}
33+
return Boolean(
34+
normalizeOptionalString(record.runId) && normalizeOptionalString(record.childSessionKey),
35+
);
36+
});
37+
}

src/agents/pi-embedded-runner/delivery-evidence.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { hasAcceptedSessionSpawn } from "../accepted-session-spawn.js";
2+
13
type AgentPayloadLike = {
24
text?: unknown;
35
mediaUrl?: unknown;
@@ -19,6 +21,7 @@ export type AgentDeliveryEvidence = {
1921
messagingToolSentTexts?: unknown;
2022
messagingToolSentMediaUrls?: unknown;
2123
messagingToolSentTargets?: unknown;
24+
acceptedSessionSpawns?: unknown;
2225
successfulCronAdds?: unknown;
2326
meta?: {
2427
toolSummary?: {
@@ -129,6 +132,7 @@ function hasAgentDeliveryEvidenceShape(value: object): boolean {
129132
"messagingToolSentTexts" in value ||
130133
"messagingToolSentMediaUrls" in value ||
131134
"messagingToolSentTargets" in value ||
135+
"acceptedSessionSpawns" in value ||
132136
"successfulCronAdds" in value ||
133137
"meta" in value
134138
);
@@ -186,6 +190,8 @@ export function hasCommittedMessagingToolDeliveryEvidence(
186190
export function hasOutboundDeliveryEvidence(result: AgentDeliveryEvidence): boolean {
187191
return (
188192
hasMessagingToolDeliveryEvidence(result) ||
193+
(Array.isArray(result.acceptedSessionSpawns) &&
194+
hasAcceptedSessionSpawn(result.acceptedSessionSpawns)) ||
189195
hasPositiveNumber(result.successfulCronAdds) ||
190196
hasPositiveNumber(result.meta?.toolSummary?.calls)
191197
);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from "vitest";
2+
import { classifyEmbeddedPiRunResultForModelFallback } from "./result-fallback-classifier.js";
3+
4+
describe("classifyEmbeddedPiRunResultForModelFallback", () => {
5+
it("does not fallback when sessions_spawn accepted a child session", () => {
6+
expect(
7+
classifyEmbeddedPiRunResultForModelFallback({
8+
provider: "mock-openai",
9+
model: "gpt-5.5",
10+
result: {
11+
meta: { durationMs: 1 },
12+
acceptedSessionSpawns: [
13+
{
14+
runId: "run-child",
15+
childSessionKey: "agent:qa:subagent:child",
16+
},
17+
],
18+
},
19+
}),
20+
).toBeNull();
21+
});
22+
});

src/agents/pi-embedded-runner/run.incomplete-turn.test.ts

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../../config/config.js";
3-
import { hasCommittedMessagingToolDeliveryEvidence } from "./delivery-evidence.js";
3+
import {
4+
hasCommittedMessagingToolDeliveryEvidence,
5+
hasOutboundDeliveryEvidence,
6+
} from "./delivery-evidence.js";
47
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
58
import {
69
loadRunOverflowCompactionHarness,
@@ -1484,6 +1487,34 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
14841487
expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION);
14851488
});
14861489

1490+
it("does not retry empty turns after an accepted sessions_spawn delivery", () => {
1491+
const retryInstruction = resolveEmptyResponseRetryInstruction({
1492+
provider: "ollama",
1493+
modelId: "gemma4:31b",
1494+
payloadCount: 0,
1495+
aborted: false,
1496+
timedOut: false,
1497+
attempt: makeAttemptResult({
1498+
assistantTexts: [],
1499+
acceptedSessionSpawns: [
1500+
{
1501+
runId: "run-child",
1502+
childSessionKey: "agent:claude:subagent:child",
1503+
},
1504+
],
1505+
lastAssistant: {
1506+
role: "assistant",
1507+
stopReason: "end_turn",
1508+
provider: "ollama",
1509+
model: "gemma4:31b",
1510+
content: [{ type: "text", text: "" }],
1511+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1512+
}),
1513+
});
1514+
1515+
expect(retryInstruction).toBeNull();
1516+
});
1517+
14871518
it("retries generic empty OpenAI-compatible turns from custom endpoints", () => {
14881519
const retryInstruction = resolveEmptyResponseRetryInstruction({
14891520
provider: "llama-cpp-local",
@@ -1654,6 +1685,100 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
16541685
expect(incompleteTurnText).toBeNull();
16551686
});
16561687

1688+
it("suppresses the incomplete-turn warning after an accepted sessions_spawn terminal success", () => {
1689+
const attemptWithAcceptedSpawn: Partial<EmbeddedRunAttemptResult> & {
1690+
acceptedSessionSpawns: Array<{ runId: string; childSessionKey: string }>;
1691+
} = {
1692+
assistantTexts: [],
1693+
acceptedSessionSpawns: [
1694+
{
1695+
runId: "run-child",
1696+
childSessionKey: "agent:claude:subagent:child",
1697+
},
1698+
],
1699+
lastAssistant: {
1700+
role: "assistant",
1701+
stopReason: "stop",
1702+
provider: "anthropic",
1703+
model: "sonnet-4.6",
1704+
content: [],
1705+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1706+
};
1707+
1708+
const incompleteTurnText = resolveIncompleteTurnPayloadText({
1709+
payloadCount: 0,
1710+
aborted: false,
1711+
timedOut: false,
1712+
attempt: makeAttemptResult(attemptWithAcceptedSpawn),
1713+
});
1714+
1715+
expect(incompleteTurnText).toBeNull();
1716+
});
1717+
1718+
it("still returns a timeout payload when the parent prompt times out after an accepted sessions_spawn", async () => {
1719+
const acceptedSessionSpawns = [
1720+
{
1721+
runId: "run-child",
1722+
childSessionKey: "agent:claude:subagent:child",
1723+
},
1724+
];
1725+
mockedClassifyFailoverReason.mockReturnValue(null);
1726+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
1727+
makeAttemptResult({
1728+
assistantTexts: [],
1729+
acceptedSessionSpawns,
1730+
timedOut: true,
1731+
lastAssistant: {
1732+
role: "assistant",
1733+
stopReason: "toolUse",
1734+
provider: "openai",
1735+
model: "gpt-5.4",
1736+
content: [],
1737+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1738+
}),
1739+
);
1740+
1741+
const result = await runEmbeddedPiAgent({
1742+
...overflowBaseRunParams,
1743+
provider: "openai",
1744+
model: "gpt-5.4",
1745+
runId: "run-timeout-after-accepted-spawn",
1746+
});
1747+
1748+
expect(result.payloads).toEqual([
1749+
{
1750+
text: "Request timed out before a response was generated. Please try again, or increase `agents.defaults.timeoutSeconds` in your config.",
1751+
isError: true,
1752+
},
1753+
]);
1754+
expect(result.acceptedSessionSpawns).toEqual(acceptedSessionSpawns);
1755+
});
1756+
1757+
it("still surfaces the incomplete-turn warning without an accepted sessions_spawn success", () => {
1758+
const attemptWithMalformedSpawn: Partial<EmbeddedRunAttemptResult> & {
1759+
acceptedSessionSpawns: Array<{ runId: string; childSessionKey: string }>;
1760+
} = {
1761+
assistantTexts: [],
1762+
acceptedSessionSpawns: [],
1763+
lastAssistant: {
1764+
role: "assistant",
1765+
stopReason: "stop",
1766+
provider: "anthropic",
1767+
model: "sonnet-4.6",
1768+
content: [],
1769+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1770+
};
1771+
1772+
const incompleteTurnText = resolveIncompleteTurnPayloadText({
1773+
payloadCount: 0,
1774+
aborted: false,
1775+
timedOut: false,
1776+
attempt: makeAttemptResult(attemptWithMalformedSpawn),
1777+
});
1778+
1779+
expect(incompleteTurnText).toContain("couldn't generate a response");
1780+
});
1781+
16571782
it("still surfaces the incomplete-turn warning when no messaging delivery was committed", () => {
16581783
const incompleteTurnText = resolveIncompleteTurnPayloadText({
16591784
payloadCount: 0,
@@ -1738,6 +1863,40 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
17381863
).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
17391864
});
17401865

1866+
it("treats accepted sessions_spawn as replay-invalid outbound delivery", () => {
1867+
const acceptedSessionSpawns = [
1868+
{
1869+
runId: "run-child",
1870+
childSessionKey: "agent:claude:subagent:child",
1871+
},
1872+
];
1873+
1874+
expect(
1875+
buildAttemptReplayMetadata({
1876+
toolMetas: [],
1877+
didSendViaMessagingTool: false,
1878+
messagingToolSentTexts: [],
1879+
messagingToolSentMediaUrls: [],
1880+
acceptedSessionSpawns,
1881+
}),
1882+
).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
1883+
expect(hasOutboundDeliveryEvidence({ acceptedSessionSpawns })).toBe(true);
1884+
});
1885+
1886+
it("ignores malformed accepted sessions_spawn delivery evidence", () => {
1887+
expect(
1888+
hasOutboundDeliveryEvidence({
1889+
acceptedSessionSpawns: [
1890+
null,
1891+
{
1892+
runId: "run-child",
1893+
childSessionKey: " ",
1894+
},
1895+
],
1896+
}),
1897+
).toBe(false);
1898+
});
1899+
17411900
it("leaves committed delivery plus tool errors to the tool-error payload path", () => {
17421901
const incompleteTurnText = resolveIncompleteTurnPayloadText({
17431902
payloadCount: 0,

src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function makeAttemptResult(
3939
const messagingToolSentMediaUrls = overrides.messagingToolSentMediaUrls ?? [];
4040
const messagingToolSentTargets = overrides.messagingToolSentTargets ?? [];
4141
const successfulCronAdds = overrides.successfulCronAdds;
42+
const acceptedSessionSpawns = overrides.acceptedSessionSpawns ?? [];
4243
return {
4344
aborted: false,
4445
externalAbort: false,
@@ -51,6 +52,7 @@ export function makeAttemptResult(
5152
sessionIdUsed: "test-session",
5253
assistantTexts: ["Hello!"],
5354
toolMetas,
55+
acceptedSessionSpawns,
5456
lastAssistant: undefined,
5557
messagesSnapshot: [],
5658
replayMetadata:
@@ -61,6 +63,7 @@ export function makeAttemptResult(
6163
messagingToolSentTexts,
6264
messagingToolSentMediaUrls,
6365
messagingToolSentTargets,
66+
acceptedSessionSpawns,
6467
successfulCronAdds,
6568
}),
6669
itemLifecycle: {

0 commit comments

Comments
 (0)