Compare commits

...

3 Commits

Author SHA1 Message Date
Josh Lehman
92d0e7dbe6 fix(session): address parent fork review feedback
Handle transcript-read failures when estimating parent fork token counts,
short-circuit the fallback path when the parent fork guard is disabled, and
remove the dead skip-fork log fallback now that the guarded branch only runs
with numeric parent token counts.

Regeneration-Prompt: |
  The rebased PR for the parent fork overflow guard picked up review feedback.
  Keep the original fix intact, but tighten the implementation in three small
  ways: do not pay transcript-read/token-estimation cost when
  session.parentForkMaxTokens is disabled, do not let synchronous transcript
  read failures bubble out of session initialization, and remove any dead
  parentTokens fallback text in the skip-fork log branch once that branch is
  already guarded by typeof parentTokens === "number".
2026-04-03 12:20:53 -07:00
Josh Lehman
706efe3628 docs(changelog): note parent fork overflow guard fix
Add the unreleased changelog entry for PR #60463 so the branch satisfies
this repo's changelog gate for user-facing fixes.

Regeneration-Prompt: |
  After opening PR #60463 for the parent fork overflow guard fix, this repo's
  PR workflow required a matching CHANGELOG.md line under ## Unreleased with
  the PR number and author credit. Append a concise Fixes entry describing
  that thread/session forking now falls back to transcript-estimated parent
  token counts when cached totals are stale or missing, and include
  (#60463) Thanks @jalehman on the same line.
2026-04-03 12:20:03 -07:00
Josh Lehman
4570c91651 fix(session): harden parent fork overflow guard
Prefer fresh persisted token counts when deciding whether a child session
should fork its parent transcript, and fall back to estimating token usage
from the parent transcript when the cached total is stale or missing. Add a
regression test covering a large parent transcript with stale token metadata
so forked thread sessions start fresh instead of cloning an oversized parent.

Regeneration-Prompt: |
  User asked to investigate and fix the broader OpenClaw-side fork/session
  overflow issue related to lossless-claw issue 206, working in
  ~/Projects/openclaw instead of the lossless-claw repo. Current OpenClaw
  already had a parent-fork size guard on main, so the task was not to
  reintroduce that feature, but to verify whether a gap remained.

  Investigation showed initSessionState only looked at the parent session
  entry's cached totalTokens when deciding whether to fork the parent
  transcript into a child thread session. That meant older or sparsely
  accounted sessions with totalTokens missing or marked stale could still
  clone a huge raw parent transcript and recreate the original overflow
  behavior.

  Preserve the existing configurable session.parentForkMaxTokens behavior,
  but make the guard trustworthy by preferring fresh persisted totals and
  otherwise estimating token count from the parent transcript before forking.
  Add a focused regression test that constructs a large parent transcript with
  totalTokensFresh=false and proves the child session starts fresh rather than
  forking the parent's transcript file.
2026-04-03 12:20:02 -07:00
4 changed files with 140 additions and 6 deletions

View File

@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
- Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. (#60463) Thanks @jalehman.
## 2026.4.2

View File

@@ -1,5 +1,8 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { estimateMessagesTokens } from "../../agents/compaction.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { resolveFreshSessionTotalTokens, type SessionEntry } from "../../config/sessions/types.js";
import { readSessionMessages } from "../../gateway/session-utils.fs.js";
/**
* Default max parent token count beyond which thread/session parent forking is skipped.
@@ -16,6 +19,48 @@ export function resolveParentForkMaxTokens(cfg: OpenClawConfig): number {
return DEFAULT_PARENT_FORK_MAX_TOKENS;
}
function resolvePositiveTokenCount(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
/**
* Resolve the best available token estimate for deciding whether parent-session
* forking is safe. Prefer fresh persisted totals, then estimate from the
* transcript when cached totals are stale or missing.
*/
export function resolveParentForkTokenCount(params: {
parentEntry: SessionEntry;
storePath: string;
}): number | undefined {
const freshPersistedTokens = resolveFreshSessionTotalTokens(params.parentEntry);
if (typeof freshPersistedTokens === "number") {
return freshPersistedTokens;
}
try {
const transcriptMessages = readSessionMessages(
params.parentEntry.sessionId,
params.storePath,
params.parentEntry.sessionFile,
) as AgentMessage[];
if (transcriptMessages.length > 0) {
const estimatedTokens = estimateMessagesTokens(transcriptMessages);
const transcriptTokens = resolvePositiveTokenCount(
Number.isFinite(estimatedTokens) ? Math.ceil(estimatedTokens) : undefined,
);
if (typeof transcriptTokens === "number") {
return transcriptTokens;
}
}
} catch {
// Fall back to cached totals/unknown tokens when the transcript cannot be read.
}
return resolvePositiveTokenCount(params.parentEntry.totalTokens);
}
export async function forkSessionFromParent(params: {
parentEntry: SessionEntry;
agentId: string;

View File

@@ -442,6 +442,79 @@ describe("initSessionState thread forking", () => {
expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
});
it("skips fork when parent transcript estimate exceeds threshold and cached total is stale", async () => {
const root = await makeCaseDir("openclaw-thread-session-overflow-transcript-fallback-");
const sessionsDir = path.join(root, "sessions");
await fs.mkdir(sessionsDir);
const parentSessionId = "parent-overflow-transcript";
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
const lines = [
JSON.stringify({
type: "session",
version: 3,
id: parentSessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
}),
];
for (let index = 0; index < 40; index += 1) {
const userId = `u${index}`;
const assistantId = `a${index}`;
const body = `turn-${index} ${"x".repeat(12_000)}`;
lines.push(
JSON.stringify({
type: "message",
id: userId,
parentId: index === 0 ? null : `a${index - 1}`,
timestamp: new Date().toISOString(),
message: { role: "user", content: body },
}),
);
lines.push(
JSON.stringify({
type: "message",
id: assistantId,
parentId: userId,
timestamp: new Date().toISOString(),
message: { role: "assistant", content: body },
}),
);
}
await fs.writeFile(parentSessionFile, `${lines.join("\n")}\n`, "utf-8");
const storePath = path.join(root, "sessions.json");
const parentSessionKey = "agent:main:slack:channel:c1";
await writeSessionStoreFast(storePath, {
[parentSessionKey]: {
sessionId: parentSessionId,
sessionFile: parentSessionFile,
updatedAt: Date.now(),
totalTokens: 1,
totalTokensFresh: false,
},
});
const cfg = {
session: { store: storePath },
} as OpenClawConfig;
const threadSessionKey = "agent:main:slack:channel:c1:thread:457";
const result = await initSessionState({
ctx: {
Body: "Thread reply",
SessionKey: threadSessionKey,
ParentSessionKey: parentSessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.sessionEntry.forkedFromParent).toBe(true);
expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
});
it("respects session.parentForkMaxTokens override", async () => {
const root = await makeCaseDir("openclaw-thread-session-overflow-override-");
const sessionsDir = path.join(root, "sessions");

View File

@@ -47,7 +47,11 @@ import {
resolveLastChannelRaw,
resolveLastToRaw,
} from "./session-delivery.js";
import { forkSessionFromParent, resolveParentForkMaxTokens } from "./session-fork.js";
import {
forkSessionFromParent,
resolveParentForkMaxTokens,
resolveParentForkTokenCount,
} from "./session-fork.js";
import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js";
const log = createSubsystemLogger("session-init");
@@ -597,8 +601,19 @@ export async function initSessionState(params: {
sessionStore[parentSessionKey] &&
!alreadyForked
) {
const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0;
if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) {
const parentEntry = sessionStore[parentSessionKey];
const parentTokens =
parentForkMaxTokens > 0
? resolveParentForkTokenCount({
parentEntry,
storePath,
})
: undefined;
if (
parentForkMaxTokens > 0 &&
typeof parentTokens === "number" &&
parentTokens > parentForkMaxTokens
) {
// Parent context is too large — forking would create a thread session
// that immediately overflows the model's context window. Start fresh
// instead and mark as forked to prevent re-attempts. See #26905.
@@ -610,10 +625,10 @@ export async function initSessionState(params: {
} else {
log.warn(
`forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
`parentTokens=${parentTokens}`,
`parentTokens=${parentTokens ?? "unknown"}`,
);
const forked = await forkSessionFromParent({
parentEntry: sessionStore[parentSessionKey],
parentEntry,
agentId,
sessionsDir: path.dirname(storePath),
});