From 7b1f7b179f18087ac06e8c8baa69b9cf52897210 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 00:57:47 +0100 Subject: [PATCH] refactor: share thread binding lifecycle --- src/channels/thread-bindings-policy.ts | 55 ++++--------------- .../thread-bindings-session-runtime.ts | 48 ++-------------- src/shared/thread-binding-lifecycle.test.ts | 42 ++++++++++++++ src/shared/thread-binding-lifecycle.ts | 43 +++++++++++++++ 4 files changed, 99 insertions(+), 89 deletions(-) create mode 100644 src/shared/thread-binding-lifecycle.test.ts create mode 100644 src/shared/thread-binding-lifecycle.ts diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index e9101265c22..c204828cd41 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -1,8 +1,17 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAccountId } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { + resolveThreadBindingLifecycle as resolveSharedThreadBindingLifecycle, + type ThreadBindingLifecycleRecord, +} from "../shared/thread-binding-lifecycle.js"; import { getChannelPlugin } from "./plugins/index.js"; +export { + resolveThreadBindingLifecycle, + type ThreadBindingLifecycleRecord, +} from "../shared/thread-binding-lifecycle.js"; + const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; @@ -97,56 +106,12 @@ export function resolveThreadBindingMaxAgeMs(params: { return Math.floor(maxAgeHours * 60 * 60 * 1000); } -type ThreadBindingLifecycleRecord = { - boundAt: number; - lastActivityAt: number; - idleTimeoutMs?: number; - maxAgeMs?: number; -}; - -export function resolveThreadBindingLifecycle(params: { - record: ThreadBindingLifecycleRecord; - defaultIdleTimeoutMs: number; - defaultMaxAgeMs: number; -}): { - expiresAt?: number; - reason?: "idle-expired" | "max-age-expired"; -} { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return inactivityExpiresAt <= maxAgeExpiresAt - ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } - : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - if (inactivityExpiresAt != null) { - return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; - } - if (maxAgeExpiresAt != null) { - return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - return {}; -} - export function resolveThreadBindingEffectiveExpiresAt(params: { record: ThreadBindingLifecycleRecord; defaultIdleTimeoutMs: number; defaultMaxAgeMs: number; }): number | undefined { - return resolveThreadBindingLifecycle(params).expiresAt; + return resolveSharedThreadBindingLifecycle(params).expiresAt; } export function resolveThreadBindingsEnabled(params: { diff --git a/src/plugin-sdk/thread-bindings-session-runtime.ts b/src/plugin-sdk/thread-bindings-session-runtime.ts index ee90a007d77..cee0356c8b3 100644 --- a/src/plugin-sdk/thread-bindings-session-runtime.ts +++ b/src/plugin-sdk/thread-bindings-session-runtime.ts @@ -1,4 +1,8 @@ export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js"; +export { + resolveThreadBindingLifecycle, + type ThreadBindingLifecycleRecord, +} from "../shared/thread-binding-lifecycle.js"; export { registerSessionBindingAdapter, unregisterSessionBindingAdapter, @@ -6,47 +10,3 @@ export { type SessionBindingAdapter, type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; - -type ThreadBindingLifecycleRecord = { - boundAt: number; - lastActivityAt: number; - idleTimeoutMs?: number; - maxAgeMs?: number; -}; - -export function resolveThreadBindingLifecycle(params: { - record: ThreadBindingLifecycleRecord; - defaultIdleTimeoutMs: number; - defaultMaxAgeMs: number; -}): { - expiresAt?: number; - reason?: "idle-expired" | "max-age-expired"; -} { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return inactivityExpiresAt <= maxAgeExpiresAt - ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } - : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - if (inactivityExpiresAt != null) { - return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; - } - if (maxAgeExpiresAt != null) { - return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - return {}; -} diff --git a/src/shared/thread-binding-lifecycle.test.ts b/src/shared/thread-binding-lifecycle.test.ts new file mode 100644 index 00000000000..b5bc56b9fb8 --- /dev/null +++ b/src/shared/thread-binding-lifecycle.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { resolveThreadBindingLifecycle } from "./thread-binding-lifecycle.js"; + +describe("resolveThreadBindingLifecycle", () => { + it("prefers the earliest idle or max-age expiration", () => { + expect( + resolveThreadBindingLifecycle({ + record: { + boundAt: 100, + lastActivityAt: 300, + idleTimeoutMs: 50, + maxAgeMs: 1_000, + }, + defaultIdleTimeoutMs: 24 * 60 * 60 * 1000, + defaultMaxAgeMs: 0, + }), + ).toEqual({ expiresAt: 350, reason: "idle-expired" }); + + expect( + resolveThreadBindingLifecycle({ + record: { + boundAt: 100, + lastActivityAt: 300, + idleTimeoutMs: 1_000, + maxAgeMs: 150, + }, + defaultIdleTimeoutMs: 24 * 60 * 60 * 1000, + defaultMaxAgeMs: 0, + }), + ).toEqual({ expiresAt: 250, reason: "max-age-expired" }); + }); + + it("uses defaults when record-level timeouts are absent", () => { + expect( + resolveThreadBindingLifecycle({ + record: { boundAt: 100, lastActivityAt: 300 }, + defaultIdleTimeoutMs: 200, + defaultMaxAgeMs: 0, + }), + ).toEqual({ expiresAt: 500, reason: "idle-expired" }); + }); +}); diff --git a/src/shared/thread-binding-lifecycle.ts b/src/shared/thread-binding-lifecycle.ts new file mode 100644 index 00000000000..1898d8594e4 --- /dev/null +++ b/src/shared/thread-binding-lifecycle.ts @@ -0,0 +1,43 @@ +export type ThreadBindingLifecycleRecord = { + boundAt: number; + lastActivityAt: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}; + +export function resolveThreadBindingLifecycle(params: { + record: ThreadBindingLifecycleRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): { + expiresAt?: number; + reason?: "idle-expired" | "max-age-expired"; +} { + const idleTimeoutMs = + typeof params.record.idleTimeoutMs === "number" + ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) + : params.defaultIdleTimeoutMs; + const maxAgeMs = + typeof params.record.maxAgeMs === "number" + ? Math.max(0, Math.floor(params.record.maxAgeMs)) + : params.defaultMaxAgeMs; + + const inactivityExpiresAt = + idleTimeoutMs > 0 + ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs + : undefined; + const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; + + if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { + return inactivityExpiresAt <= maxAgeExpiresAt + ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } + : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + if (inactivityExpiresAt != null) { + return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; + } + if (maxAgeExpiresAt != null) { + return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + return {}; +}