From 11b59685342b26ff3089ee2f5d4fdbd7ed9b5ef1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 18:01:36 -0400 Subject: [PATCH] fix(channels): bound thread binding lifecycle durations --- src/channels/thread-bindings-policy.test.ts | 18 ++++++++++++++ src/channels/thread-bindings-policy.ts | 27 ++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/channels/thread-bindings-policy.test.ts b/src/channels/thread-bindings-policy.test.ts index ee8aef12e1b..159b34345fb 100644 --- a/src/channels/thread-bindings-policy.test.ts +++ b/src/channels/thread-bindings-policy.test.ts @@ -1,8 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { MAX_DATE_TIMESTAMP_MS } from "../shared/number-coercion.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { requiresNativeThreadContextForThreadHere, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingMaxAgeMs, resolveThreadBindingPlacementForCurrentContext, resolveThreadBindingSpawnPolicy, supportsAutomaticThreadBindingSpawn, @@ -80,6 +83,21 @@ describe("thread binding spawn policy helpers", () => { expect(policy.defaultSpawnContext).toBe("fork"); }); + it("preserves long lifecycle hour values while capping unsafe conversions", () => { + expect( + resolveThreadBindingIdleTimeoutMs({ + channelIdleHoursRaw: 720, + sessionIdleHoursRaw: undefined, + }), + ).toBe(2_592_000_000); + expect( + resolveThreadBindingMaxAgeMs({ + channelMaxAgeHoursRaw: undefined, + sessionMaxAgeHoursRaw: Number.MAX_SAFE_INTEGER, + }), + ).toBe(MAX_DATE_TIMESTAMP_MS); + }); + it("uses spawnSessions for both subagent and ACP spawn policy", () => { const cfg = { channels: { diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 8a8ab598b3c..a42ca5cbd89 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -1,3 +1,4 @@ +import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAccountId } from "../routing/session-key.js"; @@ -94,26 +95,34 @@ function normalizeThreadBindingHours(raw: unknown): number | undefined { return raw; } +function resolveThreadBindingHoursMs(raw: unknown, fallbackHours: number): number { + const hours = normalizeThreadBindingHours(raw) ?? fallbackHours; + const durationMs = Math.floor(hours * 60 * 60 * 1000); + if (!Number.isFinite(durationMs) || durationMs < 0) { + return 0; + } + return Math.min(durationMs, MAX_DATE_TIMESTAMP_MS); +} + export function resolveThreadBindingIdleTimeoutMs(params: { channelIdleHoursRaw: unknown; sessionIdleHoursRaw: unknown; }): number { - const idleHours = - normalizeThreadBindingHours(params.channelIdleHoursRaw) ?? - normalizeThreadBindingHours(params.sessionIdleHoursRaw) ?? - DEFAULT_THREAD_BINDING_IDLE_HOURS; - return Math.floor(idleHours * 60 * 60 * 1000); + return resolveThreadBindingHoursMs( + params.channelIdleHoursRaw, + normalizeThreadBindingHours(params.sessionIdleHoursRaw) ?? DEFAULT_THREAD_BINDING_IDLE_HOURS, + ); } export function resolveThreadBindingMaxAgeMs(params: { channelMaxAgeHoursRaw: unknown; sessionMaxAgeHoursRaw: unknown; }): number { - const maxAgeHours = - normalizeThreadBindingHours(params.channelMaxAgeHoursRaw) ?? + return resolveThreadBindingHoursMs( + params.channelMaxAgeHoursRaw, normalizeThreadBindingHours(params.sessionMaxAgeHoursRaw) ?? - DEFAULT_THREAD_BINDING_MAX_AGE_HOURS; - return Math.floor(maxAgeHours * 60 * 60 * 1000); + DEFAULT_THREAD_BINDING_MAX_AGE_HOURS, + ); } export function resolveThreadBindingEffectiveExpiresAt(params: {