Skip to content

Commit 36e6ece

Browse files
committed
fix(session): skip fork after parent size timeout
1 parent 7b1f6a6 commit 36e6ece

5 files changed

Lines changed: 150 additions & 23 deletions

File tree

src/auto-reply/reply/session-fork.test.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ vi.mock("./session-fork.runtime.js", () => runtimeMocks);
1515
const roots: string[] = [];
1616
const parentTooLargeMessage =
1717
"Parent context is too large to fork (170000/100000 tokens); starting with isolated context instead.";
18+
const parentSizeTimeoutMessage =
19+
"Parent context size could not be verified within 1000ms; starting with isolated context instead.";
1820

1921
async function makeRoot(prefix: string): Promise<string> {
2022
// realpath first: macOS tmpdir is a /var -> /private/var symlink and the
@@ -54,7 +56,7 @@ describe("resolveParentForkDecision", () => {
5456
expect(runtimeMocks.resolveParentForkTokenCountRuntime).not.toHaveBeenCalled();
5557
});
5658

57-
it("continues with a fork decision when parent token probing stalls", async () => {
59+
it("starts isolated when parent token probing stalls", async () => {
5860
vi.useFakeTimers();
5961
runtimeMocks.resolveParentForkTokenCountRuntime.mockReturnValue(new Promise(() => {}));
6062

@@ -66,8 +68,10 @@ describe("resolveParentForkDecision", () => {
6668
await vi.advanceTimersByTimeAsync(1_000);
6769

6870
await expect(decision).resolves.toEqual({
69-
status: "fork",
71+
status: "skip",
72+
reason: "parent-size-timeout",
7073
maxTokens: 100_000,
74+
message: parentSizeTimeoutMessage,
7175
});
7276
});
7377

@@ -162,4 +166,60 @@ describe("forkSessionEntryFromParent", () => {
162166
expect(stored[sessionKey]?.sessionId).toBe(result.fork.sessionId);
163167
expect(stored[sessionKey]?.sessionFile).toBe(result.fork.sessionFile);
164168
});
169+
170+
it("does not read the parent transcript after a stalled parent size probe", async () => {
171+
vi.useFakeTimers();
172+
const root = await makeRoot("openclaw-session-fork-timeout-");
173+
const activeStoreDir = path.join(root, "active-store");
174+
await fs.mkdir(activeStoreDir, { recursive: true });
175+
const storePath = path.join(activeStoreDir, "sessions.json");
176+
const parentSessionKey = "agent:main:main";
177+
const sessionKey = "agent:main:subagent:child";
178+
await fs.writeFile(
179+
storePath,
180+
JSON.stringify(
181+
{
182+
[parentSessionKey]: {
183+
sessionId: "parent-session",
184+
sessionFile: path.join(activeStoreDir, "missing-parent.jsonl"),
185+
updatedAt: 1,
186+
},
187+
[sessionKey]: { sessionId: "", updatedAt: 2 },
188+
},
189+
null,
190+
2,
191+
),
192+
"utf-8",
193+
);
194+
runtimeMocks.resolveParentForkTokenCountRuntime.mockReturnValue(new Promise(() => {}));
195+
196+
const resultPromise = forkSessionEntryFromParent({
197+
agentId: "main",
198+
decisionSkipPatch: () => ({
199+
forkedFromParent: false,
200+
}),
201+
fallbackEntry: { sessionId: "", updatedAt: 2 },
202+
parentSessionKey,
203+
sessionKey,
204+
storePath,
205+
});
206+
207+
await vi.advanceTimersByTimeAsync(1_000);
208+
const result = await resultPromise;
209+
210+
expect(result).toMatchObject({
211+
status: "skipped",
212+
reason: "decision-skip",
213+
decision: {
214+
status: "skip",
215+
reason: "parent-size-timeout",
216+
maxTokens: 100_000,
217+
message: parentSizeTimeoutMessage,
218+
},
219+
});
220+
if (result.status !== "skipped") {
221+
throw new Error("expected skipped result");
222+
}
223+
expect(result.sessionEntry.forkedFromParent).toBe(false);
224+
});
165225
});

src/auto-reply/reply/session-fork.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const sessionForkRuntimeLoader = createLazyImportLoader(() => import("./session-
2020

2121
export type ParentForkDecision = SessionParentForkDecision;
2222

23+
type ParentForkTokenCountResolution =
24+
| { status: "resolved"; parentTokens?: number }
25+
| { status: "timed-out" };
26+
2327
type ParentForkDecisionParams = {
2428
parentEntry: SessionEntry;
2529
agentId?: string;
@@ -95,6 +99,17 @@ function formatParentForkTooLargeMessage(params: {
9599
);
96100
}
97101

102+
function formatParentForkTokenProbeTimeoutMessage(): string {
103+
return (
104+
`Parent context size could not be verified within ${PARENT_FORK_TOKEN_COUNT_TIMEOUT_MS}ms; ` +
105+
"starting with isolated context instead."
106+
);
107+
}
108+
109+
function formatParentForkTokenProbeUnavailableMessage(): string {
110+
return "Parent context size could not be verified; starting with isolated context instead.";
111+
}
112+
98113
function resolveParentForkStorePath(params: {
99114
agentId?: string;
100115
config?: OpenClawConfig;
@@ -109,12 +124,37 @@ export async function resolveParentForkDecision(
109124
params: ParentForkDecisionParams,
110125
): Promise<ParentForkDecision> {
111126
const maxTokens = DEFAULT_PARENT_FORK_MAX_TOKENS;
112-
const parentTokens =
113-
resolveFreshSessionTotalTokens(params.parentEntry) ??
114-
(await resolveParentForkTokenCountWithTimeout({
115-
parentEntry: params.parentEntry,
116-
storePath: resolveParentForkStorePath(params),
117-
}));
127+
const freshParentTokens = resolveFreshSessionTotalTokens(params.parentEntry);
128+
if (typeof freshParentTokens === "number" && freshParentTokens > maxTokens) {
129+
return {
130+
status: "skip",
131+
reason: "parent-too-large",
132+
maxTokens,
133+
parentTokens: freshParentTokens,
134+
message: formatParentForkTooLargeMessage({ parentTokens: freshParentTokens, maxTokens }),
135+
};
136+
}
137+
if (typeof freshParentTokens === "number") {
138+
return {
139+
status: "fork",
140+
maxTokens,
141+
parentTokens: freshParentTokens,
142+
};
143+
}
144+
145+
const tokenCountResolution = await resolveParentForkTokenCountWithTimeout({
146+
parentEntry: params.parentEntry,
147+
storePath: resolveParentForkStorePath(params),
148+
});
149+
if (tokenCountResolution.status === "timed-out") {
150+
return {
151+
status: "skip",
152+
reason: "parent-size-timeout",
153+
maxTokens,
154+
message: formatParentForkTokenProbeTimeoutMessage(),
155+
};
156+
}
157+
const parentTokens = tokenCountResolution.parentTokens;
118158
if (typeof parentTokens === "number" && parentTokens > maxTokens) {
119159
return {
120160
status: "skip",
@@ -124,10 +164,18 @@ export async function resolveParentForkDecision(
124164
message: formatParentForkTooLargeMessage({ parentTokens, maxTokens }),
125165
};
126166
}
167+
if (typeof parentTokens !== "number") {
168+
return {
169+
status: "skip",
170+
reason: "parent-size-unavailable",
171+
maxTokens,
172+
message: formatParentForkTokenProbeUnavailableMessage(),
173+
};
174+
}
127175
return {
128176
status: "fork",
129177
maxTokens,
130-
...(typeof parentTokens === "number" ? { parentTokens } : {}),
178+
parentTokens,
131179
};
132180
}
133181

@@ -209,14 +257,22 @@ async function resolveParentForkTokenCount(params: {
209257
async function resolveParentForkTokenCountWithTimeout(params: {
210258
parentEntry: SessionEntry;
211259
storePath: string;
212-
}): Promise<number | undefined> {
213-
let timeoutId: number | undefined;
214-
const timeout = new Promise<undefined>((resolve) => {
215-
timeoutId = setTimeout(resolve, PARENT_FORK_TOKEN_COUNT_TIMEOUT_MS);
260+
}): Promise<ParentForkTokenCountResolution> {
261+
let timeoutId: Parameters<typeof clearTimeout>[0];
262+
const tokenCount = resolveParentForkTokenCount(params).then((parentTokens) =>
263+
typeof parentTokens === "number"
264+
? ({ status: "resolved", parentTokens } as const)
265+
: ({ status: "resolved" } as const),
266+
);
267+
const timeout = new Promise<ParentForkTokenCountResolution>((resolve) => {
268+
timeoutId = setTimeout(
269+
() => resolve({ status: "timed-out" }),
270+
PARENT_FORK_TOKEN_COUNT_TIMEOUT_MS,
271+
);
216272
});
217273
try {
218-
// Availability wins over exact sizing when transcript probing stalls.
219-
return await Promise.race([resolveParentForkTokenCount(params), timeout]);
274+
// Isolated context wins when parent sizing stalls or cannot be verified.
275+
return await Promise.race([tokenCount, timeout]);
220276
} finally {
221277
if (timeoutId !== undefined) {
222278
clearTimeout(timeoutId);

src/auto-reply/reply/session-parent-fork-prepare.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,15 @@ export async function prepareReplySessionParentFork(params: {
2929
storePath: params.storePath,
3030
});
3131
if (decision.status === "skip") {
32-
// The parent branch is too large to inherit usefully. Start fresh and
33-
// mark as handled so the thread does not retry this decision every turn.
32+
// The parent branch is not safe to inherit. Start fresh and mark as
33+
// handled so the thread does not retry this decision every turn.
34+
const tokenDetails =
35+
decision.reason === "parent-too-large"
36+
? ` parentTokens=${decision.parentTokens} maxTokens=${decision.maxTokens}`
37+
: "";
3438
params.warn(
35-
`skipping parent fork (parent too large): parentKey=${params.parentSessionKey} → sessionKey=${params.sessionKey} ` +
36-
`parentTokens=${decision.parentTokens} maxTokens=${decision.maxTokens}`,
39+
`skipping parent fork (${decision.reason}): parentKey=${params.parentSessionKey} → sessionKey=${params.sessionKey}` +
40+
tokenDetails,
3741
);
3842
return { ...params.sessionEntry, forkedFromParent: true };
3943
}

src/config/sessions/session-accessor.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,12 @@ export type SessionParentForkDecision =
13941394
maxTokens: number;
13951395
parentTokens: number;
13961396
message: string;
1397+
}
1398+
| {
1399+
status: "skip";
1400+
reason: "parent-size-timeout" | "parent-size-unavailable";
1401+
maxTokens: number;
1402+
message: string;
13971403
};
13981404

13991405
/** Transcript identity created for a child fork. */

src/gateway/session-create-service.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,12 +437,13 @@ export async function createGatewaySession(params: {
437437
storePath: parentSessionTarget.storePath,
438438
});
439439
if (forkDecision.status === "skip") {
440+
const message =
441+
forkDecision.reason === "parent-too-large"
442+
? `parent session is too large to fork (${forkDecision.parentTokens}/${forkDecision.maxTokens} tokens)`
443+
: forkDecision.message;
440444
return {
441445
ok: false,
442-
error: errorShape(
443-
ErrorCodes.INVALID_REQUEST,
444-
`parent session is too large to fork (${forkDecision.parentTokens}/${forkDecision.maxTokens} tokens)`,
445-
),
446+
error: errorShape(ErrorCodes.INVALID_REQUEST, message),
446447
};
447448
}
448449
const fork = await forkSessionFromParent({

0 commit comments

Comments
 (0)