fix: migrate legacy thread binding expiry and reduce hot-path disk writes

This commit is contained in:
Onur Solmaz
2026-02-26 20:20:20 +01:00
parent c7ad7f92c6
commit aa7cf2d363
4 changed files with 126 additions and 4 deletions

View File

@@ -118,7 +118,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}
if (ctx.threadBinding?.threadId) {
threadBindings.touchThread({ threadId: ctx.threadBinding.threadId, persist: true });
threadBindings.touchThread({ threadId: ctx.threadBinding.threadId, persist: false });
}
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "discord",

View File

@@ -272,6 +272,6 @@ export async function deliverDiscordReply(params: {
}
if (binding && deliveredAny) {
params.threadBindings?.touchThread({ threadId: binding.threadId, persist: true });
params.threadBindings?.touchThread({ threadId: binding.threadId, persist: false });
}
}

View File

@@ -746,6 +746,104 @@ describe("thread binding lifecycle", () => {
expect(manager.getByThreadId("thread-acp-uncertain")).toBeDefined();
});
it("migrates legacy expiresAt bindings to idle/max-age semantics", () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
__testing.resetThreadBindingsForTests();
const bindingsPath = __testing.resolveThreadBindingsPath();
fs.mkdirSync(path.dirname(bindingsPath), { recursive: true });
const boundAt = Date.now() - 10_000;
const expiresAt = boundAt + 60_000;
fs.writeFileSync(
bindingsPath,
JSON.stringify(
{
version: 1,
bindings: {
"thread-legacy-active": {
accountId: "default",
channelId: "parent-1",
threadId: "thread-legacy-active",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:legacy-active",
agentId: "main",
boundBy: "system",
boundAt,
expiresAt,
},
"thread-legacy-disabled": {
accountId: "default",
channelId: "parent-1",
threadId: "thread-legacy-disabled",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:legacy-disabled",
agentId: "main",
boundBy: "system",
boundAt,
expiresAt: 0,
},
},
},
null,
2,
),
"utf-8",
);
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const active = manager.getByThreadId("thread-legacy-active");
expect(active).toBeDefined();
expect(active?.idleTimeoutMs).toBe(0);
expect(active?.maxAgeMs).toBe(expiresAt - boundAt);
expect(
resolveThreadBindingMaxAgeExpiresAt({
record: active!,
defaultMaxAgeMs: manager.getMaxAgeMs(),
}),
).toBe(expiresAt);
expect(
resolveThreadBindingInactivityExpiresAt({
record: active!,
defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
}),
).toBeUndefined();
const disabled = manager.getByThreadId("thread-legacy-disabled");
expect(disabled).toBeDefined();
expect(disabled?.idleTimeoutMs).toBe(0);
expect(disabled?.maxAgeMs).toBe(0);
expect(
resolveThreadBindingMaxAgeExpiresAt({
record: disabled!,
defaultMaxAgeMs: manager.getMaxAgeMs(),
}),
).toBeUndefined();
expect(
resolveThreadBindingInactivityExpiresAt({
record: disabled!,
defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
}),
).toBeUndefined();
} finally {
__testing.resetThreadBindingsForTests();
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("persists unbinds even when no manager is active", () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));

View File

@@ -177,6 +177,30 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs)
? Math.max(0, Math.floor(value.maxAgeMs))
: undefined;
const legacyExpiresAt =
typeof (value as { expiresAt?: unknown }).expiresAt === "number" &&
Number.isFinite((value as { expiresAt?: unknown }).expiresAt)
? Math.max(0, Math.floor((value as { expiresAt?: number }).expiresAt ?? 0))
: undefined;
let migratedIdleTimeoutMs = idleTimeoutMs;
let migratedMaxAgeMs = maxAgeMs;
if (
migratedIdleTimeoutMs === undefined &&
migratedMaxAgeMs === undefined &&
legacyExpiresAt != null
) {
if (legacyExpiresAt <= 0) {
migratedIdleTimeoutMs = 0;
migratedMaxAgeMs = 0;
} else {
const baseBoundAt = boundAt > 0 ? boundAt : lastActivityAt;
// Legacy expiresAt represented an absolute timestamp; map it to max-age and disable idle timeout.
migratedIdleTimeoutMs = 0;
migratedMaxAgeMs = Math.max(1, legacyExpiresAt - Math.max(0, baseBoundAt));
}
}
return {
accountId,
channelId,
@@ -190,8 +214,8 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
boundBy,
boundAt,
lastActivityAt,
idleTimeoutMs,
maxAgeMs,
idleTimeoutMs: migratedIdleTimeoutMs,
maxAgeMs: migratedMaxAgeMs,
};
}