refactor: remove parent fork config knob

This commit is contained in:
Peter Steinberger
2026-05-02 05:46:03 +01:00
parent 4f31cbbf55
commit 10b89a3b55
18 changed files with 80 additions and 168 deletions

View File

@@ -5808,7 +5808,7 @@ Docs: https://docs.openclaw.ai
- Slack/Threading: when `replyToMode="all"` auto-threads top-level Slack DMs, seed the thread session key from the message `ts` so the initial message and later replies share the same isolated `:thread:` session instead of falling back to base DM context. (#26849) Thanks @calder-sandy.
- Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156).
- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and share the PI parent-fork fallback between channel threads and subagents. The legacy `session.parentForkMaxTokens` compatibility guard remains available but is no longer the preferred tuning surface. (#26912) Thanks @markshields-tl.
- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and share the PI parent-fork fallback between channel threads and subagents. The old `session.parentForkMaxTokens` tuning surface is removed; `openclaw doctor --fix` strips it from legacy configs. (#26912) Thanks @markshields-tl.
- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin.

View File

@@ -1,4 +1,4 @@
f40d6dea3b81c42d5af9e340f091d34b9e39321d171fade6115da594ba90522f config-baseline.json
40b1f91714d6f17e2718ea5d34c135550f5a39fe47f781cddc153cf3a59fe2e7 config-baseline.core.json
0020a49cdf07b79fddfed9584f10323ef77e3ca5c7f907d441c1f2e3f45932c9 config-baseline.json
8193fd771c9fa50e77c71ff69d41015e6dc02d140182ceea3baaa17713f04b18 config-baseline.core.json
f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json
af71b84b2411d8ccabcc6e09de0ee41f8212ff9869a6677698b6e7e3afdfaa47 config-baseline.plugin.json

View File

@@ -1210,10 +1210,6 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing. Dock commands such as `/dock_discord` use the same map to switch the active session's reply route to another linked channel peer; see [Channel docking](/concepts/channel-docking).
- **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. Daily reset freshness uses the session row's `sessionStartedAt`; idle reset freshness uses `lastInteractionAt`. Background/system-event writes such as heartbeat, cron wakeups, exec notifications, and gateway bookkeeping can update `updatedAt`, but they do not keep daily/idle sessions fresh.
- **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`.
- **`parentForkMaxTokens`**: deprecated compatibility guard for the historical parent fork ceiling (default `100000`).
- Channel thread sessions and subagent `context="fork"` now use the same parent fork decision path.
- When the active parent branch is too large, OpenClaw starts with isolated context instead of failing or inheriting unusable history.
- Set `0` only if you intentionally want to disable this guard.
- **`mainKey`**: legacy field. Runtime always uses `"main"` for the main direct-chat bucket.
- **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0``5`). `0` disables ping-pong chaining.
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.

View File

@@ -148,7 +148,7 @@ Rules of thumb:
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
- **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but do not extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built.
- **Parent fork policy** uses PI's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The legacy `session.parentForkMaxTokens` key remains as a deprecated compatibility guard; set `0` only if you intentionally want to disable it.
- **Parent fork policy** uses PI's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The sizing policy is automatic; legacy `session.parentForkMaxTokens` config is removed by `openclaw doctor --fix`.
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.

View File

@@ -184,15 +184,8 @@ export async function loadSubagentSpawnModuleForTest(params: {
resolveContextEngine: params.resolveContextEngineMock ?? (async () => ({})),
resolveParentForkDecision:
params.resolveParentForkDecisionMock ??
(async (forkParams: {
cfg?: { session?: { parentForkMaxTokens?: unknown } };
parentEntry?: { totalTokens?: unknown };
}) => {
const configured = forkParams.cfg?.session?.parentForkMaxTokens;
const maxTokens =
typeof configured === "number" && Number.isFinite(configured) && configured >= 0
? Math.floor(configured)
: 100_000;
(async (forkParams: { parentEntry?: { totalTokens?: unknown } }) => {
const maxTokens = 100_000;
const parentTokens =
typeof forkParams.parentEntry?.totalTokens === "number" &&
Number.isFinite(forkParams.parentEntry.totalTokens)

View File

@@ -364,7 +364,6 @@ async function prepareSubagentSessionContext(params: {
);
}
const forkDecision = await subagentSpawnDeps.resolveParentForkDecision({
cfg: params.cfg,
parentEntry,
storePath: parentTarget.storePath,
});

View File

@@ -1,5 +1,4 @@
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
/**
* Default max parent token count beyond which thread/session parent forking is skipped.
@@ -28,19 +27,6 @@ function loadSessionForkRuntime(): Promise<typeof import("./session-fork.runtime
return sessionForkRuntimePromise;
}
/**
* Deprecated compatibility guard for deployments that explicitly tuned the
* historical thread fork ceiling. New behavior should use the shared parent
* fork decision helper so channel threads and subagents degrade the same way.
*/
export function resolveParentForkMaxTokens(cfg: OpenClawConfig): number {
const configured = cfg.session?.parentForkMaxTokens;
if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) {
return Math.floor(configured);
}
return DEFAULT_PARENT_FORK_MAX_TOKENS;
}
export function formatParentForkTooLargeMessage(params: {
parentTokens: number;
maxTokens: number;
@@ -52,14 +38,10 @@ export function formatParentForkTooLargeMessage(params: {
}
export async function resolveParentForkDecision(params: {
cfg: OpenClawConfig;
parentEntry: SessionEntry;
storePath: string;
}): Promise<ParentForkDecision> {
const maxTokens = resolveParentForkMaxTokens(params.cfg);
if (maxTokens <= 0) {
return { status: "fork", maxTokens };
}
const maxTokens = DEFAULT_PARENT_FORK_MAX_TOKENS;
const parentTokens = await resolveParentForkTokenCount({
parentEntry: params.parentEntry,
storePath: params.storePath,

View File

@@ -52,19 +52,8 @@ vi.mock("./session-fork.js", () => ({
sessionForkMocks.forkSessionFromParent(...args),
resolveParentForkTokenCount: (...args: [{ parentEntry: SessionEntry; storePath: string }]) =>
sessionForkMocks.resolveParentForkTokenCount(...args),
resolveParentForkDecision: async (params: {
cfg: { session?: { parentForkMaxTokens?: unknown } };
parentEntry: SessionEntry;
storePath: string;
}) => {
const configured = params.cfg.session?.parentForkMaxTokens;
const maxTokens =
typeof configured === "number" && Number.isFinite(configured) && configured >= 0
? Math.floor(configured)
: 100_000;
if (maxTokens <= 0) {
return { status: "fork", maxTokens };
}
resolveParentForkDecision: async (params: { parentEntry: SessionEntry; storePath: string }) => {
const maxTokens = 100_000;
const parentTokens = await sessionForkMocks.resolveParentForkTokenCount({
parentEntry: params.parentEntry,
storePath: params.storePath,
@@ -625,84 +614,6 @@ describe("initSessionState thread forking", () => {
expect(sessionForkMocks.forkSessionFromParent).not.toHaveBeenCalled();
});
it("respects session.parentForkMaxTokens override", async () => {
const root = await makeCaseDir("openclaw-thread-session-overflow-override-");
const sessionsDir = path.join(root, "sessions");
await fs.mkdir(sessionsDir);
const parentSessionId = "parent-override";
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
const header = {
type: "session",
version: 3,
id: parentSessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
};
const message = {
type: "message",
id: "m1",
parentId: null,
timestamp: new Date().toISOString(),
message: { role: "user", content: "Parent prompt" },
};
const assistantMessage = {
type: "message",
id: "m2",
parentId: "m1",
timestamp: new Date().toISOString(),
message: { role: "assistant", content: "Parent reply" },
};
await fs.writeFile(
parentSessionFile,
`${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantMessage)}\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: 170_000,
},
});
const cfg = {
session: {
store: storePath,
parentForkMaxTokens: 200_000,
},
} as OpenClawConfig;
const threadSessionKey = "agent:main:slack:channel:c1:thread:789";
const result = await initSessionState({
ctx: {
Body: "Thread reply",
SessionKey: threadSessionKey,
ParentSessionKey: parentSessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.sessionEntry.forkedFromParent).toBe(true);
expect(result.sessionEntry.sessionFile).toBeTruthy();
const forkedContent = await fs.readFile(result.sessionEntry.sessionFile ?? "", "utf-8");
const headerLine = forkedContent.split(/\r?\n/).find((line) => line.trim().length > 0);
if (!headerLine) {
throw new Error("Missing session header");
}
const parsedHeader = JSON.parse(headerLine) as { parentSession?: string };
const expectedParentSession = await fs.realpath(parentSessionFile);
const actualParentSession = parsedHeader.parentSession
? await fs.realpath(parsedHeader.parentSession)
: undefined;
expect(actualParentSession).toBe(expectedParentSession);
});
it("records topic-specific session files when MessageThreadId is present", async () => {
const root = await makeCaseDir("openclaw-topic-session-");
const storePath = path.join(root, "sessions.json");

View File

@@ -701,7 +701,6 @@ export async function initSessionState(params: {
) {
const parentEntry = sessionStore[parentSessionKey];
const forkDecision = await resolveParentForkDecision({
cfg,
parentEntry,
storePath,
});

View File

@@ -41,6 +41,24 @@ describe("legacy session maintenance migrate", () => {
});
});
describe("legacy session parent fork migrate", () => {
it("removes legacy session.parentForkMaxTokens", () => {
const res = migrateLegacyConfigForTest({
session: {
store: "sessions.json",
parentForkMaxTokens: 200_000,
},
});
expect(res.config?.session).toEqual({
store: "sessions.json",
});
expect(res.changes).toContain(
"Removed session.parentForkMaxTokens; parent fork sizing is automatic.",
);
});
});
describe("legacy thread binding spawn migrate", () => {
it("moves matching split spawn flags to unified spawnSessions", () => {
const res = migrateLegacyConfigForTest({

View File

@@ -10,6 +10,11 @@ function hasLegacyRotateBytes(value: unknown): boolean {
return Boolean(maintenance && Object.prototype.hasOwnProperty.call(maintenance, "rotateBytes"));
}
function hasLegacyParentForkMaxTokens(value: unknown): boolean {
const session = getRecord(value);
return Boolean(session && Object.prototype.hasOwnProperty.call(session, "parentForkMaxTokens"));
}
const LEGACY_SESSION_MAINTENANCE_ROTATE_BYTES_RULE: LegacyConfigRule = {
path: ["session", "maintenance"],
message:
@@ -17,6 +22,13 @@ const LEGACY_SESSION_MAINTENANCE_ROTATE_BYTES_RULE: LegacyConfigRule = {
match: hasLegacyRotateBytes,
};
const LEGACY_SESSION_PARENT_FORK_MAX_TOKENS_RULE: LegacyConfigRule = {
path: ["session"],
message:
'session.parentForkMaxTokens was removed; parent fork sizing is automatic. Run "openclaw doctor --fix" to remove it.',
match: hasLegacyParentForkMaxTokens,
};
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "session.maintenance.rotateBytes",
@@ -31,4 +43,17 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION: LegacyConfigMigrationSpec
changes.push("Removed deprecated session.maintenance.rotateBytes.");
},
}),
defineLegacyConfigMigration({
id: "session.parentForkMaxTokens",
describe: "Remove legacy session.parentForkMaxTokens",
legacyRules: [LEGACY_SESSION_PARENT_FORK_MAX_TOKENS_RULE],
apply: (raw, changes) => {
const session = getRecord(raw.session);
if (!session || !Object.prototype.hasOwnProperty.call(session, "parentForkMaxTokens")) {
return;
}
delete session.parentForkMaxTokens;
changes.push("Removed session.parentForkMaxTokens; parent fork sizing is automatic.");
},
}),
];

View File

@@ -20692,14 +20692,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
'Controls typing behavior timing: "never", "instant", "thinking", or "message" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.',
},
parentForkMaxTokens: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
title: "Deprecated Session Parent Fork Max Tokens",
description:
"Deprecated compatibility guard for the historical parent-session fork ceiling. OpenClaw now shares the parent fork decision between channel threads and subagents and falls back to isolated context when the active parent branch is too large; set 0 only to disable that guard intentionally.",
},
mainKey: {
type: "string",
title: "Session Main Key",
@@ -27841,11 +27833,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: 'Controls typing behavior timing: "never", "instant", "thinking", or "message" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.',
tags: ["storage"],
},
"session.parentForkMaxTokens": {
label: "Deprecated Session Parent Fork Max Tokens",
help: "Deprecated compatibility guard for the historical parent-session fork ceiling. OpenClaw now shares the parent fork decision between channel threads and subagents and falls back to isolated context when the active parent branch is too large; set 0 only to disable that guard intentionally.",
tags: ["security", "auth", "performance", "storage"],
},
"session.mainKey": {
label: "Session Main Key",
help: 'Overrides the canonical main session key used for continuity when dmScope or routing logic points to "main". Use a stable value only if you intentionally need custom session anchoring.',

View File

@@ -1427,8 +1427,6 @@ export const FIELD_HELP: Record<string, string> = {
"Controls interval for repeated typing indicators while replies are being prepared in typing-capable channels. Increase to reduce chatty updates or decrease for more active typing feedback.",
"session.typingMode":
'Controls typing behavior timing: "never", "instant", "thinking", or "message" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.',
"session.parentForkMaxTokens":
"Deprecated compatibility guard for the historical parent-session fork ceiling. OpenClaw now shares the parent fork decision between channel threads and subagents and falls back to isolated context when the active parent branch is too large; set 0 only to disable that guard intentionally.",
"session.mainKey":
'Overrides the canonical main session key used for continuity when dmScope or routing logic points to "main". Use a stable value only if you intentionally need custom session anchoring.',
"session.sendPolicy":

View File

@@ -706,7 +706,6 @@ export const FIELD_LABELS: Record<string, string> = {
"session.store": "Session Store Path",
"session.typingIntervalSeconds": "Session Typing Interval (seconds)",
"session.typingMode": "Session Typing Mode",
"session.parentForkMaxTokens": "Deprecated Session Parent Fork Max Tokens",
"session.mainKey": "Session Main Key",
"session.sendPolicy": "Session Send Policy",
"session.sendPolicy.default": "Session Send Policy Default Action",

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { validateConfigObjectRaw } from "./validation.js";
describe("session parent fork config keys", () => {
it("rejects legacy session.parentForkMaxTokens with doctor guidance", () => {
const result = validateConfigObjectRaw({
session: {
parentForkMaxTokens: 200_000,
},
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.issues).toContainEqual(
expect.objectContaining({
path: "session",
message: expect.stringContaining("session.parentForkMaxTokens was removed"),
}),
);
expect(result.issues).toContainEqual(
expect.objectContaining({
message: expect.stringContaining('Run "openclaw doctor --fix"'),
}),
);
});
});

View File

@@ -181,14 +181,6 @@ export type SessionConfig = {
store?: string;
typingIntervalSeconds?: number;
typingMode?: TypingMode;
/**
* @deprecated Compatibility guard for the historical parent fork ceiling.
* OpenClaw now uses a shared parent fork decision path for channel threads
* and subagents, and falls back to isolated context when the active parent
* branch is too large. Set to 0 only if you intentionally want to disable
* that compatibility guard.
*/
parentForkMaxTokens?: number;
mainKey?: string;
sendPolicy?: SessionSendPolicyConfig;
agentToAgent?: {

View File

@@ -14,19 +14,6 @@ describe("SessionSchema maintenance extensions", () => {
).not.toThrow();
});
it("accepts deprecated parentForkMaxTokens including 0 to disable the guard", () => {
expect(() => SessionSchema.parse({ parentForkMaxTokens: 100_000 })).not.toThrow();
expect(() => SessionSchema.parse({ parentForkMaxTokens: 0 })).not.toThrow();
});
it("rejects negative parentForkMaxTokens", () => {
expect(() =>
SessionSchema.parse({
parentForkMaxTokens: -1,
}),
).toThrow(/parentForkMaxTokens/i);
});
it("accepts disabling reset archive cleanup", () => {
expect(() =>
SessionSchema.parse({

View File

@@ -53,8 +53,6 @@ export const SessionSchema = z
store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: TypingModeSchema.optional(),
/** @deprecated Compatibility guard for the historical parent fork ceiling. */
parentForkMaxTokens: z.number().int().nonnegative().optional(),
mainKey: z.string().optional(),
sendPolicy: SessionSendPolicySchema.optional(),
agentToAgent: z