refactor: share thread binding lifecycle

This commit is contained in:
Peter Steinberger
2026-04-21 00:57:47 +01:00
parent 4ea8063203
commit 7b1f7b179f
4 changed files with 99 additions and 89 deletions

View File

@@ -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: {

View File

@@ -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 {};
}

View File

@@ -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" });
});
});

View File

@@ -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 {};
}