diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 0dc54be7c8d..1d75feedfbc 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -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", diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index b3f4bd2bac1..cb20c840b7c 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -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 }); } } diff --git a/src/discord/monitor/thread-bindings.lifecycle.test.ts b/src/discord/monitor/thread-bindings.lifecycle.test.ts index 02aba36b43e..aa1dac458d2 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.test.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.test.ts @@ -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-")); diff --git a/src/discord/monitor/thread-bindings.state.ts b/src/discord/monitor/thread-bindings.state.ts index b0a77d8690f..af0c251d762 100644 --- a/src/discord/monitor/thread-bindings.state.ts +++ b/src/discord/monitor/thread-bindings.state.ts @@ -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, }; }