From 41eacccc42225cc067a71b9b2edca4ed48cbfebb Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Thu, 26 Feb 2026 19:14:28 +0100 Subject: [PATCH] refactor discord thread bindings to idle and max-age lifecycle --- docs/channels/discord.md | 9 +- docs/gateway/configuration-reference.md | 14 +- docs/gateway/configuration.md | 5 +- docs/help/faq.md | 6 +- docs/tools/slash-commands.md | 5 +- docs/tools/subagents.md | 11 +- src/auto-reply/commands-registry.data.ts | 6 +- .../reply/commands-session-lifecycle.test.ts | 198 +++++++++++++++++ .../reply/commands-session-ttl.test.ts | 147 ------------- src/auto-reply/reply/commands-session.ts | 157 ++++++++++---- .../reply/commands-subagents-focus.test.ts | 5 +- .../reply/commands-subagents/action-focus.ts | 3 +- .../reply/commands-subagents/shared.ts | 3 +- src/config/schema.help.quality.test.ts | 3 +- src/config/schema.help.ts | 12 +- src/config/schema.labels.ts | 6 +- src/config/types.base.ts | 11 +- src/config/types.discord.ts | 11 +- src/config/zod-schema.providers-core.ts | 3 +- src/config/zod-schema.session.ts | 3 +- .../monitor/message-handler.process.ts | 6 +- .../native-command.model-picker.test.ts | 7 +- src/discord/monitor/provider.ts | 56 +++-- src/discord/monitor/reply-delivery.test.ts | 25 +++ src/discord/monitor/reply-delivery.ts | 8 + ...t.ts => thread-bindings.lifecycle.test.ts} | 203 +++++++++++++++--- .../monitor/thread-bindings.lifecycle.ts | 54 ++++- .../monitor/thread-bindings.manager.ts | 174 ++++++++++++--- .../monitor/thread-bindings.messages.ts | 45 ++-- src/discord/monitor/thread-bindings.state.ts | 102 ++++++--- src/discord/monitor/thread-bindings.ts | 13 +- src/discord/monitor/thread-bindings.types.ts | 19 +- 32 files changed, 965 insertions(+), 365 deletions(-) create mode 100644 src/auto-reply/reply/commands-session-lifecycle.test.ts delete mode 100644 src/auto-reply/reply/commands-session-ttl.test.ts rename src/discord/monitor/{thread-bindings.ttl.test.ts => thread-bindings.lifecycle.test.ts} (72%) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 334c6d78ee5..15a84d4cf11 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -635,7 +635,8 @@ Default slash command settings: - `/focus ` bind current/new thread to a subagent/session target - `/unfocus` remove current thread binding - `/agents` show active runs and binding state - - `/session ttl ` inspect/update auto-unfocus TTL for focused bindings + - `/session idle ` inspect/update inactivity auto-unfocus for focused bindings + - `/session max-age ` inspect/update hard max age for focused bindings Config: @@ -644,14 +645,16 @@ Default slash command settings: session: { threadBindings: { enabled: true, - ttlHours: 24, + idleHours: 24, + maxAgeHours: 0, }, }, channels: { discord: { threadBindings: { enabled: true, - ttlHours: 24, + idleHours: 24, + maxAgeHours: 0, spawnSubagentSessions: false, // opt-in }, }, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 209427ca277..3023776fea6 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -244,7 +244,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat }, threadBindings: { enabled: true, - ttlHours: 24, + idleHours: 24, + maxAgeHours: 0, spawnSubagentSessions: false, // opt-in for sessions_spawn({ thread: true }) }, voice: { @@ -277,8 +278,9 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered). - `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars. - `channels.discord.threadBindings` controls Discord thread-bound routing: - - `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session ttl`, and bound delivery/routing) - - `ttlHours`: Discord override for auto-unfocus TTL (`0` disables) + - `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing) + - `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables) + - `maxAgeHours`: Discord override for hard max age in hours (`0` disables) - `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides. @@ -1246,7 +1248,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden }, threadBindings: { enabled: true, - ttlHours: 24, // default auto-unfocus TTL for thread-bound sessions (0 disables) + idleHours: 24, // default inactivity auto-unfocus in hours (`0` disables) + maxAgeHours: 0, // default hard max age in hours (`0` disables) }, mainKey: "main", // legacy (runtime always uses "main") agentToAgent: { maxPingPongTurns: 5 }, @@ -1273,7 +1276,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`maintenance`**: `warn` warns the active session on eviction; `enforce` applies pruning and rotation. - **`threadBindings`**: global defaults for thread-bound session features. - `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`) - - `ttlHours`: default auto-unfocus TTL in hours (`0` disables; providers can override) + - `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override) + - `maxAgeHours`: default hard max age in hours (`0` disables; providers can override) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index e367b4caf0d..da71afd8780 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -184,7 +184,8 @@ When validation fails: dmScope: "per-channel-peer", // recommended for multi-user threadBindings: { enabled: true, - ttlHours: 24, + idleHours: 24, + maxAgeHours: 0, }, reset: { mode: "daily", @@ -196,7 +197,7 @@ When validation fails: ``` - `dmScope`: `main` (shared) | `per-peer` | `per-channel-peer` | `per-account-channel-peer` - - `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, and `/session ttl`). + - `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, `/session idle`, and `/session max-age`). - See [Session Management](/concepts/session) for scoping, identity links, and send policy. - See [full reference](/gateway/configuration-reference#session) for all fields. diff --git a/docs/help/faq.md b/docs/help/faq.md index d6a5f3f1205..fc043048cac 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1050,13 +1050,13 @@ Basic flow: - Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"` for persistent follow-up). - Or manually bind with `/focus `. - Use `/agents` to inspect binding state. -- Use `/session ttl ` to control auto-unfocus. +- Use `/session idle ` and `/session max-age ` to control auto-unfocus. - Use `/unfocus` to detach the thread. Required config: -- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`. -- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`. +- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours`. +- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`. - Auto-bind on spawn: set `channels.discord.threadBindings.spawnSubagentSessions: true`. Docs: [Sub-agents](/tools/subagents), [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), [Slash commands](/tools/slash-commands). diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 86dd32a83c8..fc0b9765052 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -78,7 +78,8 @@ Text + native (when enabled): - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt) - `/whoami` (show your sender id; alias: `/id`) -- `/session ttl ` (manage session-level settings, such as TTL) +- `/session idle ` (manage inactivity auto-unfocus for focused thread bindings) +- `/session max-age ` (manage hard max-age auto-unfocus for focused thread bindings) - `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session) - `/agents` (list thread-bound agents for this session) - `/focus ` (Discord: bind this thread, or a new thread, to a session/subagent target) @@ -124,7 +125,7 @@ Notes: - `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs. - `/restart` is enabled by default; set `commands.restart: false` to disable it. - Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text). -- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session ttl`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`). +- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`). - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 7334da1ec40..578eeaa2ae6 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -30,7 +30,8 @@ These commands work on channels that support persistent thread bindings. See **T - `/focus ` - `/unfocus` - `/agents` -- `/session ttl ` +- `/session idle ` +- `/session max-age ` `/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup). @@ -93,14 +94,14 @@ When thread bindings are enabled for a channel, a sub-agent can stay bound to a ### Thread supporting channels -- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session ttl`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`, and `channels.discord.threadBindings.spawnSubagentSessions`. +- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`, and `channels.discord.threadBindings.spawnSubagentSessions`. Quick flow: 1. Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"`). 2. OpenClaw creates or binds a thread to that session target in the active channel. 3. Replies and follow-up messages in that thread route to the bound session. -4. Use `/session ttl` to inspect/update auto-unfocus TTL. +4. Use `/session idle` to inspect/update inactivity auto-unfocus and `/session max-age` to control the hard cap. 5. Use `/unfocus` to detach manually. Manual controls: @@ -108,11 +109,11 @@ Manual controls: - `/focus ` binds the current thread (or creates one) to a sub-agent/session target. - `/unfocus` removes the binding for the current bound thread. - `/agents` lists active runs and binding state (`thread:` or `unbound`). -- `/session ttl` only works for focused bound threads. +- `/session idle` and `/session max-age` only work for focused bound threads. Config switches: -- Global default: `session.threadBindings.enabled`, `session.threadBindings.ttlHours` +- Global default: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours` - Channel override and spawn auto-bind keys are adapter-specific. See **Thread supporting channels** above. See [Configuration Reference](/gateway/configuration-reference) and [Slash commands](/tools/slash-commands) for current adapter details. diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index eb3e6f6d5a2..ddeec1f6f28 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -265,15 +265,15 @@ function buildChatCommands(): ChatCommandDefinition[] { defineChatCommand({ key: "session", nativeName: "session", - description: "Manage session-level settings (for example /session ttl).", + description: "Manage session-level settings (for example /session idle).", textAlias: "/session", category: "session", args: [ { name: "action", - description: "ttl", + description: "idle | max-age", type: "string", - choices: ["ttl"], + choices: ["idle", "max-age"], }, { name: "value", diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts new file mode 100644 index 00000000000..2e1341781ed --- /dev/null +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const hoisted = vi.hoisted(() => { + const getThreadBindingManagerMock = vi.fn(); + const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); + const setThreadBindingMaxAgeBySessionKeyMock = vi.fn(); + return { + getThreadBindingManagerMock, + setThreadBindingIdleTimeoutBySessionKeyMock, + setThreadBindingMaxAgeBySessionKeyMock, + }; +}); + +vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getThreadBindingManager: hoisted.getThreadBindingManagerMock, + setThreadBindingIdleTimeoutBySessionKey: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock, + setThreadBindingMaxAgeBySessionKey: hoisted.setThreadBindingMaxAgeBySessionKeyMock, + }; +}); + +const { handleSessionCommand } = await import("./commands-session.js"); +const { buildCommandTestParams } = await import("./commands.test-harness.js"); + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +type FakeBinding = { + accountId: string; + channelId: string; + threadId: string; + targetKind: "subagent" | "acp"; + targetSessionKey: string; + agentId: string; + boundBy: string; + boundAt: number; + lastActivityAt: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}; + +function createDiscordCommandParams(commandBody: string, overrides?: Record) { + return buildCommandTestParams(commandBody, baseCfg, { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "channel:thread-1", + AccountId: "default", + MessageThreadId: "thread-1", + ...overrides, + }); +} + +function createFakeBinding(overrides: Partial = {}): FakeBinding { + const now = Date.now(); + return { + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + boundBy: "user-1", + boundAt: now, + lastActivityAt: now, + ...overrides, + }; +} + +function createFakeThreadBindingManager(binding: FakeBinding | null) { + return { + getByThreadId: vi.fn((_threadId: string) => binding), + getIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000), + getMaxAgeMs: vi.fn(() => 0), + }; +} + +describe("/session idle and /session max-age", () => { + beforeEach(() => { + hoisted.getThreadBindingManagerMock.mockClear(); + hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockClear(); + hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockClear(); + vi.useRealTimers(); + }); + + it("sets idle timeout for the focused session", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + + const binding = createFakeBinding(); + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([ + { + ...binding, + lastActivityAt: Date.now(), + idleTimeoutMs: 2 * 60 * 60 * 1000, + }, + ]); + + const result = await handleSessionCommand(createDiscordCommandParams("/session idle 2h"), true); + const text = result?.reply?.text ?? ""; + + expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + idleTimeoutMs: 2 * 60 * 60 * 1000, + }); + expect(text).toContain("Idle timeout set to 2h"); + expect(text).toContain("2026-02-20T02:00:00.000Z"); + }); + + it("shows active idle timeout when no value is provided", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + + const binding = createFakeBinding({ + idleTimeoutMs: 2 * 60 * 60 * 1000, + lastActivityAt: Date.now(), + }); + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + + const result = await handleSessionCommand(createDiscordCommandParams("/session idle"), true); + expect(result?.reply?.text).toContain("Idle timeout active (2h"); + expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z"); + }); + + it("sets max age for the focused session", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + + const binding = createFakeBinding(); + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([ + { + ...binding, + boundAt: Date.now(), + maxAgeMs: 3 * 60 * 60 * 1000, + }, + ]); + + const result = await handleSessionCommand( + createDiscordCommandParams("/session max-age 3h"), + true, + ); + const text = result?.reply?.text ?? ""; + + expect(hoisted.setThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + maxAgeMs: 3 * 60 * 60 * 1000, + }); + expect(text).toContain("Max age set to 3h"); + expect(text).toContain("2026-02-20T03:00:00.000Z"); + }); + + it("disables max age when set to off", async () => { + const binding = createFakeBinding({ maxAgeMs: 2 * 60 * 60 * 1000 }); + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([{ ...binding, maxAgeMs: 0 }]); + + const result = await handleSessionCommand( + createDiscordCommandParams("/session max-age off"), + true, + ); + + expect(hoisted.setThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + maxAgeMs: 0, + }); + expect(result?.reply?.text).toContain("Max age disabled"); + }); + + it("is unavailable outside discord", async () => { + const params = buildCommandTestParams("/session idle 2h", baseCfg); + const result = await handleSessionCommand(params, true); + expect(result?.reply?.text).toContain("currently available for Discord thread-bound sessions"); + }); + + it("requires binding owner for lifecycle updates", async () => { + const binding = createFakeBinding({ boundBy: "owner-1" }); + hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + + const result = await handleSessionCommand( + createDiscordCommandParams("/session idle 2h", { + SenderId: "other-user", + }), + true, + ); + + expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled(); + expect(result?.reply?.text).toContain("Only owner-1 can update session lifecycle settings"); + }); +}); diff --git a/src/auto-reply/reply/commands-session-ttl.test.ts b/src/auto-reply/reply/commands-session-ttl.test.ts deleted file mode 100644 index 33becc62901..00000000000 --- a/src/auto-reply/reply/commands-session-ttl.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; - -const hoisted = vi.hoisted(() => { - const getThreadBindingManagerMock = vi.fn(); - const setThreadBindingTtlBySessionKeyMock = vi.fn(); - return { - getThreadBindingManagerMock, - setThreadBindingTtlBySessionKeyMock, - }; -}); - -vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getThreadBindingManager: hoisted.getThreadBindingManagerMock, - setThreadBindingTtlBySessionKey: hoisted.setThreadBindingTtlBySessionKeyMock, - }; -}); - -const { handleSessionCommand } = await import("./commands-session.js"); -const { buildCommandTestParams } = await import("./commands.test-harness.js"); - -const baseCfg = { - session: { mainKey: "main", scope: "per-sender" }, -} satisfies OpenClawConfig; - -type FakeBinding = { - threadId: string; - targetSessionKey: string; - expiresAt?: number; - boundBy?: string; -}; - -function createDiscordCommandParams(commandBody: string, overrides?: Record) { - return buildCommandTestParams(commandBody, baseCfg, { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - OriginatingTo: "channel:thread-1", - AccountId: "default", - MessageThreadId: "thread-1", - ...overrides, - }); -} - -function createFakeThreadBindingManager(binding: FakeBinding | null) { - return { - getByThreadId: vi.fn((_threadId: string) => binding), - }; -} - -describe("/session ttl", () => { - beforeEach(() => { - hoisted.getThreadBindingManagerMock.mockClear(); - hoisted.setThreadBindingTtlBySessionKeyMock.mockClear(); - vi.useRealTimers(); - }); - - it("sets ttl for the focused session", async () => { - const binding: FakeBinding = { - threadId: "thread-1", - targetSessionKey: "agent:main:subagent:child", - }; - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); - hoisted.setThreadBindingTtlBySessionKeyMock.mockReturnValue([ - { - ...binding, - boundAt: Date.now(), - expiresAt: new Date("2026-02-21T02:00:00.000Z").getTime(), - }, - ]); - - const result = await handleSessionCommand(createDiscordCommandParams("/session ttl 2h"), true); - const text = result?.reply?.text ?? ""; - - expect(hoisted.setThreadBindingTtlBySessionKeyMock).toHaveBeenCalledWith({ - targetSessionKey: "agent:main:subagent:child", - accountId: "default", - ttlMs: 2 * 60 * 60 * 1000, - }); - expect(text).toContain("Session TTL set to 2h"); - expect(text).toContain("2026-02-21T02:00:00.000Z"); - }); - - it("shows active ttl when no value is provided", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); - - const binding: FakeBinding = { - threadId: "thread-1", - targetSessionKey: "agent:main:subagent:child", - expiresAt: new Date("2026-02-20T02:00:00.000Z").getTime(), - }; - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); - - const result = await handleSessionCommand(createDiscordCommandParams("/session ttl"), true); - expect(result?.reply?.text).toContain("Session TTL active (2h"); - }); - - it("disables ttl when set to off", async () => { - const binding: FakeBinding = { - threadId: "thread-1", - targetSessionKey: "agent:main:subagent:child", - expiresAt: new Date("2026-02-20T02:00:00.000Z").getTime(), - }; - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); - hoisted.setThreadBindingTtlBySessionKeyMock.mockReturnValue([ - { ...binding, boundAt: Date.now(), expiresAt: undefined }, - ]); - - const result = await handleSessionCommand(createDiscordCommandParams("/session ttl off"), true); - - expect(hoisted.setThreadBindingTtlBySessionKeyMock).toHaveBeenCalledWith({ - targetSessionKey: "agent:main:subagent:child", - accountId: "default", - ttlMs: 0, - }); - expect(result?.reply?.text).toContain("Session TTL disabled"); - }); - - it("is unavailable outside discord", async () => { - const params = buildCommandTestParams("/session ttl 2h", baseCfg); - const result = await handleSessionCommand(params, true); - expect(result?.reply?.text).toContain("currently available for Discord thread-bound sessions"); - }); - - it("requires binding owner for ttl updates", async () => { - const binding: FakeBinding = { - threadId: "thread-1", - targetSessionKey: "agent:main:subagent:child", - boundBy: "owner-1", - }; - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); - - const result = await handleSessionCommand( - createDiscordCommandParams("/session ttl 2h", { - SenderId: "other-user", - }), - true, - ); - - expect(hoisted.setThreadBindingTtlBySessionKeyMock).not.toHaveBeenCalled(); - expect(result?.reply?.text).toContain("Only owner-1 can update session TTL"); - }); -}); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index ea5bd9200f6..741aaaefe68 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -4,9 +4,14 @@ import { isRestartEnabled } from "../../config/commands.js"; import type { SessionEntry } from "../../config/sessions.js"; import { updateSessionStore } from "../../config/sessions.js"; import { - formatThreadBindingTtlLabel, + formatThreadBindingDurationLabel, getThreadBindingManager, - setThreadBindingTtlBySessionKey, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingInactivityExpiresAt, + resolveThreadBindingMaxAgeExpiresAt, + resolveThreadBindingMaxAgeMs, + setThreadBindingIdleTimeoutBySessionKey, + setThreadBindingMaxAgeBySessionKey, } from "../../discord/monitor/thread-bindings.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; @@ -48,7 +53,9 @@ function resolveAbortTarget(params: { } const SESSION_COMMAND_PREFIX = "/session"; -const SESSION_TTL_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]); +const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]); +const SESSION_ACTION_IDLE = "idle"; +const SESSION_ACTION_MAX_AGE = "max-age"; function isDiscordSurface(params: Parameters[0]): boolean { const channel = @@ -69,21 +76,21 @@ function resolveDiscordAccountId(params: Parameters[0]): string } function resolveSessionCommandUsage() { - return "Usage: /session ttl (example: /session ttl 24h)"; + return "Usage: /session idle | /session max-age (example: /session idle 24h)"; } -function parseSessionTtlMs(raw: string): number { +function parseSessionDurationMs(raw: string): number { const normalized = raw.trim().toLowerCase(); if (!normalized) { - throw new Error("missing ttl"); + throw new Error("missing duration"); } - if (SESSION_TTL_OFF_VALUES.has(normalized)) { + if (SESSION_DURATION_OFF_VALUES.has(normalized)) { return 0; } if (/^\d+(?:\.\d+)?$/.test(normalized)) { const hours = Number(normalized); if (!Number.isFinite(hours) || hours < 0) { - throw new Error("invalid ttl"); + throw new Error("invalid duration"); } return Math.round(hours * 60 * 60 * 1000); } @@ -93,7 +100,6 @@ function parseSessionTtlMs(raw: string): number { function formatSessionExpiry(expiresAt: number) { return new Date(expiresAt).toISOString(); } - async function applyAbortTarget(params: { abortTarget: ReturnType; sessionStore?: Record; @@ -315,7 +321,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const rest = normalized.slice(SESSION_COMMAND_PREFIX.length).trim(); const tokens = rest.split(/\s+/).filter(Boolean); const action = tokens[0]?.toLowerCase(); - if (action !== "ttl") { + if (action !== SESSION_ACTION_IDLE && action !== SESSION_ACTION_MAX_AGE) { return { shouldContinue: false, reply: { text: resolveSessionCommandUsage() }, @@ -325,7 +331,9 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm if (!isDiscordSurface(params)) { return { shouldContinue: false, - reply: { text: "⚠️ /session ttl is currently available for Discord thread-bound sessions." }, + reply: { + text: "⚠️ /session idle and /session max-age are currently available for Discord thread-bound sessions.", + }, }; } @@ -334,7 +342,9 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm if (!threadId) { return { shouldContinue: false, - reply: { text: "⚠️ /session ttl must be run inside a focused Discord thread." }, + reply: { + text: "⚠️ /session idle and /session max-age must be run inside a focused Discord thread.", + }, }; } @@ -355,20 +365,59 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm }; } - const ttlArgRaw = tokens.slice(1).join(""); - if (!ttlArgRaw) { - const expiresAt = binding.expiresAt; - if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > Date.now()) { + const idleTimeoutMs = resolveThreadBindingIdleTimeoutMs({ + record: binding, + defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(), + }); + const idleExpiresAt = resolveThreadBindingInactivityExpiresAt({ + record: binding, + defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(), + }); + const maxAgeMs = resolveThreadBindingMaxAgeMs({ + record: binding, + defaultMaxAgeMs: threadBindings.getMaxAgeMs(), + }); + const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({ + record: binding, + defaultMaxAgeMs: threadBindings.getMaxAgeMs(), + }); + + const durationArgRaw = tokens.slice(1).join(""); + if (!durationArgRaw) { + if (action === SESSION_ACTION_IDLE) { + if ( + typeof idleExpiresAt === "number" && + Number.isFinite(idleExpiresAt) && + idleExpiresAt > Date.now() + ) { + return { + shouldContinue: false, + reply: { + text: `ℹ️ Idle timeout active (${formatThreadBindingDurationLabel(idleTimeoutMs)}, next auto-unfocus at ${formatSessionExpiry(idleExpiresAt)}).`, + }, + }; + } + return { + shouldContinue: false, + reply: { text: "ℹ️ Idle timeout is currently disabled for this focused session." }, + }; + } + + if ( + typeof maxAgeExpiresAt === "number" && + Number.isFinite(maxAgeExpiresAt) && + maxAgeExpiresAt > Date.now() + ) { return { shouldContinue: false, reply: { - text: `ℹ️ Session TTL active (${formatThreadBindingTtlLabel(expiresAt - Date.now())}, auto-unfocus at ${formatSessionExpiry(expiresAt)}).`, + text: `ℹ️ Max age active (${formatThreadBindingDurationLabel(maxAgeMs)}, hard auto-unfocus at ${formatSessionExpiry(maxAgeExpiresAt)}).`, }, }; } return { shouldContinue: false, - reply: { text: "ℹ️ Session TTL is currently disabled for this focused session." }, + reply: { text: "ℹ️ Max age is currently disabled for this focused session." }, }; } @@ -376,13 +425,15 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) { return { shouldContinue: false, - reply: { text: `⚠️ Only ${binding.boundBy} can update session TTL for this thread.` }, + reply: { + text: `⚠️ Only ${binding.boundBy} can update session lifecycle settings for this thread.`, + }, }; } - let ttlMs: number; + let durationMs: number; try { - ttlMs = parseSessionTtlMs(ttlArgRaw); + durationMs = parseSessionDurationMs(durationArgRaw); } catch { return { shouldContinue: false, @@ -390,40 +441,68 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm }; } - const updatedBindings = setThreadBindingTtlBySessionKey({ - targetSessionKey: binding.targetSessionKey, - accountId, - ttlMs, - }); + const updatedBindings = + action === SESSION_ACTION_IDLE + ? setThreadBindingIdleTimeoutBySessionKey({ + targetSessionKey: binding.targetSessionKey, + accountId, + idleTimeoutMs: durationMs, + }) + : setThreadBindingMaxAgeBySessionKey({ + targetSessionKey: binding.targetSessionKey, + accountId, + maxAgeMs: durationMs, + }); if (updatedBindings.length === 0) { - return { - shouldContinue: false, - reply: { text: "⚠️ Failed to update session TTL for the current binding." }, - }; - } - - if (ttlMs <= 0) { return { shouldContinue: false, reply: { - text: `✅ Session TTL disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`, + text: + action === SESSION_ACTION_IDLE + ? "⚠️ Failed to update idle timeout for the current binding." + : "⚠️ Failed to update max age for the current binding.", }, }; } - const expiresAt = updatedBindings[0]?.expiresAt; + if (durationMs <= 0) { + return { + shouldContinue: false, + reply: { + text: + action === SESSION_ACTION_IDLE + ? `✅ Idle timeout disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.` + : `✅ Max age disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`, + }, + }; + } + + const nextBinding = updatedBindings[0]; + const nextExpiry = + action === SESSION_ACTION_IDLE + ? resolveThreadBindingInactivityExpiresAt({ + record: nextBinding, + defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(), + }) + : resolveThreadBindingMaxAgeExpiresAt({ + record: nextBinding, + defaultMaxAgeMs: threadBindings.getMaxAgeMs(), + }); const expiryLabel = - typeof expiresAt === "number" && Number.isFinite(expiresAt) - ? formatSessionExpiry(expiresAt) + typeof nextExpiry === "number" && Number.isFinite(nextExpiry) + ? formatSessionExpiry(nextExpiry) : "n/a"; + return { shouldContinue: false, reply: { - text: `✅ Session TTL set to ${formatThreadBindingTtlLabel(ttlMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (auto-unfocus at ${expiryLabel}).`, + text: + action === SESSION_ACTION_IDLE + ? `✅ Idle timeout set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (next auto-unfocus at ${expiryLabel}).` + : `✅ Max age set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (hard auto-unfocus at ${expiryLabel}).`, }, }; }; - export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index 8ecad26cd87..389dd5f0ae3 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -74,7 +74,8 @@ function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) { ); const manager = { - getSessionTtlMs: vi.fn(() => 24 * 60 * 60 * 1000), + getIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000), + getMaxAgeMs: vi.fn(() => 0), getByThreadId: vi.fn((threadId: string) => byThread.get(threadId)), listBySessionKey: vi.fn((targetSessionKey: string) => [...byThread.values()].filter((binding) => binding.targetSessionKey === targetSessionKey), @@ -189,7 +190,7 @@ describe("/focus, /unfocus, /agents", () => { targetKind: "acp", targetSessionKey: "agent:codex-acp:session-1", introText: - "🤖 codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.", + "🤖 codex-acp session active (idle auto-unfocus after 24h inactivity). Messages here go directly to this session.", }), ); }); diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts index 1329c718637..52055beb73b 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -75,7 +75,8 @@ export async function handleSubagentsFocusAction( introText: resolveThreadBindingIntroText({ agentId: focusTarget.agentId, label, - sessionTtlMs: threadBindings.getSessionTtlMs(), + idleTimeoutMs: threadBindings.getIdleTimeoutMs(), + maxAgeMs: threadBindings.getMaxAgeMs(), }), }); diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index 237b6c5b7b0..0d2b23a19b6 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -372,7 +372,8 @@ export function buildSubagentsHelp() { "- /focus ", "- /unfocus", "- /agents", - "- /session ttl ", + "- /session idle ", + "- /session max-age ", "- /kill ", "- /steer ", "- /tell ", diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 286005b0aa2..255b01c463d 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -143,7 +143,8 @@ const TARGET_KEYS = [ "session.agentToAgent.maxPingPongTurns", "session.threadBindings", "session.threadBindings.enabled", - "session.threadBindings.ttlHours", + "session.threadBindings.idleHours", + "session.threadBindings.maxAgeHours", "session.maintenance", "session.maintenance.mode", "session.maintenance.pruneAfter", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4aed9c674ce..f30fd720b1f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -970,8 +970,10 @@ export const FIELD_HELP: Record = { "Shared defaults for thread-bound session routing behavior across providers that support thread focus workflows. Configure global defaults here and override per channel only when behavior differs.", "session.threadBindings.enabled": "Global master switch for thread-bound session routing features and focused thread delivery behavior. Keep enabled for modern thread workflows unless you need to disable thread binding globally.", - "session.threadBindings.ttlHours": - "Default auto-unfocus TTL in hours for thread-bound sessions across providers/channels (0 disables). Keep 24h-like values for practical focus windows unless your team needs longer-lived thread binding.", + "session.threadBindings.idleHours": + "Default inactivity window in hours for thread-bound sessions across providers/channels (0 disables idle auto-unfocus). Default: 24.", + "session.threadBindings.maxAgeHours": + "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.", "session.maintenance": "Automatic session-store maintenance controls for pruning age, entry caps, and file rotation behavior. Start in warn mode to observe impact, then enforce once thresholds are tuned.", "session.maintenance.mode": @@ -1311,8 +1313,10 @@ export const FIELD_HELP: Record = { "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", "channels.discord.threadBindings.enabled": "Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.", - "channels.discord.threadBindings.ttlHours": - "Auto-unfocus TTL in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable (default: 24). Overrides session.threadBindings.ttlHours when set.", + "channels.discord.threadBindings.idleHours": + "Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", + "channels.discord.threadBindings.maxAgeHours": + "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", "channels.discord.threadBindings.spawnSubagentSessions": "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.", "channels.discord.ui.components.accentColor": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 0f85a61d0b9..2a17c8d20eb 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -455,7 +455,8 @@ export const FIELD_LABELS: Record = { "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", "session.threadBindings": "Session Thread Bindings", "session.threadBindings.enabled": "Thread Binding Enabled", - "session.threadBindings.ttlHours": "Thread Binding TTL (hours)", + "session.threadBindings.idleHours": "Thread Binding Idle Timeout (hours)", + "session.threadBindings.maxAgeHours": "Thread Binding Max Age (hours)", "session.maintenance": "Session Maintenance", "session.maintenance.mode": "Session Maintenance Mode", "session.maintenance.pruneAfter": "Session Prune After", @@ -643,7 +644,8 @@ export const FIELD_LABELS: Record = { "channels.discord.retry.jitter": "Discord Retry Jitter", "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", "channels.discord.threadBindings.enabled": "Discord Thread Binding Enabled", - "channels.discord.threadBindings.ttlHours": "Discord Thread Binding TTL (hours)", + "channels.discord.threadBindings.idleHours": "Discord Thread Binding Idle Timeout (hours)", + "channels.discord.threadBindings.maxAgeHours": "Discord Thread Binding Max Age (hours)", "channels.discord.threadBindings.spawnSubagentSessions": "Discord Thread-Bound Subagent Spawn", "channels.discord.ui.components.accentColor": "Discord Component Accent Color", "channels.discord.intents.presence": "Discord Presence Intent", diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 1f59ed08069..c26653d779d 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -91,10 +91,15 @@ export type SessionThreadBindingsConfig = { */ enabled?: boolean; /** - * Auto-unfocus TTL for thread-bound sessions (hours). - * Set to 0 to disable. Default: 24. + * Inactivity window for thread-bound sessions (hours). + * Session auto-unfocuses after this amount of idle time. Set to 0 to disable. Default: 24. */ - ttlHours?: number; + idleHours?: number; + /** + * Optional hard max age for thread-bound sessions (hours). + * Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0. + */ + maxAgeHours?: number; }; export type SessionConfig = { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index a5ef6c6465a..b10e788644f 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -150,10 +150,15 @@ export type DiscordThreadBindingsConfig = { */ enabled?: boolean; /** - * Auto-unfocus TTL for thread-bound sessions in hours. - * Set to 0 to disable TTL. Default: 24. + * Inactivity window for thread-bound sessions in hours. + * Session auto-unfocuses after this amount of idle time. Set to 0 to disable. Default: 24. */ - ttlHours?: number; + idleHours?: number; + /** + * Optional hard max age for thread-bound sessions in hours. + * Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0. + */ + maxAgeHours?: number; /** * Allow `sessions_spawn({ thread: true })` to auto-create + bind Discord * threads for subagent sessions. Default: false (opt-in). diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 7282bc4792d..3850dad92f7 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -400,7 +400,8 @@ export const DiscordAccountSchema = z threadBindings: z .object({ enabled: z.boolean().optional(), - ttlHours: z.number().nonnegative().optional(), + idleHours: z.number().nonnegative().optional(), + maxAgeHours: z.number().nonnegative().optional(), spawnSubagentSessions: z.boolean().optional(), }) .strict() diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 0f38fafd887..e13985a8231 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -63,7 +63,8 @@ export const SessionSchema = z threadBindings: z .object({ enabled: z.boolean().optional(), - ttlHours: z.number().nonnegative().optional(), + idleHours: z.number().nonnegative().optional(), + maxAgeHours: z.number().nonnegative().optional(), }) .strict() .optional(), diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 80a63fdf49c..7ebb18ea3a7 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -106,9 +106,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) mediaList.push(...forwardedMediaList); const text = messageText; if (!text) { - logVerbose(`discord: drop message ${message.id} (empty content)`); + logVerbose("discord: drop message " + message.id + " (empty content)"); return; } + + if (ctx.threadBinding?.threadId) { + threadBindings.touchThread({ threadId: ctx.threadBinding.threadId, persist: true }); + } const ackReaction = resolveAckReaction(cfg, route.agentId, { channel: "discord", accountId, diff --git a/src/discord/monitor/native-command.model-picker.test.ts b/src/discord/monitor/native-command.model-picker.test.ts index 8c5ad9382c2..c913379a8b6 100644 --- a/src/discord/monitor/native-command.model-picker.test.ts +++ b/src/discord/monitor/native-command.model-picker.test.ts @@ -179,7 +179,8 @@ function createBoundThreadBindingManager(params: { }): ThreadBindingManager { return { accountId: params.accountId, - getSessionTtlMs: () => 24 * 60 * 60 * 1000, + getIdleTimeoutMs: () => 24 * 60 * 60 * 1000, + getMaxAgeMs: () => 0, getByThreadId: (threadId: string) => threadId === params.threadId ? { @@ -191,11 +192,15 @@ function createBoundThreadBindingManager(params: { agentId: params.agentId, boundBy: "system", boundAt: Date.now(), + lastActivityAt: Date.now(), + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, } : undefined, getBySessionKey: () => undefined, listBySessionKey: () => [], listBindings: () => [], + touchThread: () => null, bindTarget: async () => null, unbindThread: () => null, unbindBySessionKey: () => [], diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 8f3d5d7ac73..056b088e76b 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -70,7 +70,7 @@ import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js"; import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js"; import { resolveDiscordRestFetch } from "./rest-fetch.js"; import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js"; -import { formatThreadBindingTtlLabel } from "./thread-bindings.messages.js"; +import { formatThreadBindingDurationLabel } from "./thread-bindings.messages.js"; export type MonitorDiscordOpts = { token?: string; @@ -102,9 +102,10 @@ function summarizeGuilds(entries?: Record) { return `${sample.join(", ")}${suffix}`; } -const DEFAULT_THREAD_BINDING_TTL_HOURS = 24; +const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; +const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; -function normalizeThreadBindingTtlHours(raw: unknown): number | undefined { +function normalizeThreadBindingHours(raw: unknown): number | undefined { if (typeof raw !== "number" || !Number.isFinite(raw)) { return undefined; } @@ -114,15 +115,26 @@ function normalizeThreadBindingTtlHours(raw: unknown): number | undefined { return raw; } -function resolveThreadBindingSessionTtlMs(params: { - channelTtlHoursRaw: unknown; - sessionTtlHoursRaw: unknown; +function resolveThreadBindingIdleTimeoutMs(params: { + channelIdleHoursRaw: unknown; + sessionIdleHoursRaw: unknown; }): number { - const ttlHours = - normalizeThreadBindingTtlHours(params.channelTtlHoursRaw) ?? - normalizeThreadBindingTtlHours(params.sessionTtlHoursRaw) ?? - DEFAULT_THREAD_BINDING_TTL_HOURS; - return Math.floor(ttlHours * 60 * 60 * 1000); + const idleHours = + normalizeThreadBindingHours(params.channelIdleHoursRaw) ?? + normalizeThreadBindingHours(params.sessionIdleHoursRaw) ?? + DEFAULT_THREAD_BINDING_IDLE_HOURS; + return Math.floor(idleHours * 60 * 60 * 1000); +} + +function resolveThreadBindingMaxAgeMs(params: { + channelMaxAgeHoursRaw: unknown; + sessionMaxAgeHoursRaw: unknown; +}): number { + const maxAgeHours = + normalizeThreadBindingHours(params.channelMaxAgeHoursRaw) ?? + normalizeThreadBindingHours(params.sessionMaxAgeHoursRaw) ?? + DEFAULT_THREAD_BINDING_MAX_AGE_HOURS; + return Math.floor(maxAgeHours * 60 * 60 * 1000); } function normalizeThreadBindingsEnabled(raw: unknown): boolean | undefined { @@ -143,8 +155,8 @@ function resolveThreadBindingsEnabled(params: { ); } -function formatThreadBindingSessionTtlLabel(ttlMs: number): string { - const label = formatThreadBindingTtlLabel(ttlMs); +function formatThreadBindingDurationForConfigLabel(durationMs: number): string { + const label = formatThreadBindingDurationLabel(durationMs); return label === "disabled" ? "off" : label; } @@ -278,10 +290,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off"; const dmEnabled = dmConfig?.enabled ?? true; const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing"; - const threadBindingSessionTtlMs = resolveThreadBindingSessionTtlMs({ - channelTtlHoursRaw: - discordAccountThreadBindings?.ttlHours ?? discordRootThreadBindings?.ttlHours, - sessionTtlHoursRaw: cfg.session?.threadBindings?.ttlHours, + const threadBindingIdleTimeoutMs = resolveThreadBindingIdleTimeoutMs({ + channelIdleHoursRaw: + discordAccountThreadBindings?.idleHours ?? discordRootThreadBindings?.idleHours, + sessionIdleHoursRaw: cfg.session?.threadBindings?.idleHours, + }); + const threadBindingMaxAgeMs = resolveThreadBindingMaxAgeMs({ + channelMaxAgeHoursRaw: + discordAccountThreadBindings?.maxAgeHours ?? discordRootThreadBindings?.maxAgeHours, + sessionMaxAgeHoursRaw: cfg.session?.threadBindings?.maxAgeHours, }); const threadBindingsEnabled = resolveThreadBindingsEnabled({ channelEnabledRaw: discordAccountThreadBindings?.enabled ?? discordRootThreadBindings?.enabled, @@ -321,7 +338,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (shouldLogVerbose()) { logVerbose( - `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadSessionTtl=${formatThreadBindingSessionTtlLabel(threadBindingSessionTtlMs)}`, + `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadIdleTimeout=${formatThreadBindingDurationForConfigLabel(threadBindingIdleTimeoutMs)} threadMaxAge=${formatThreadBindingDurationForConfigLabel(threadBindingMaxAgeMs)}`, ); } @@ -360,7 +377,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ? createThreadBindingManager({ accountId: account.accountId, token, - sessionTtlMs: threadBindingSessionTtlMs, + idleTimeoutMs: threadBindingIdleTimeoutMs, + maxAgeMs: threadBindingMaxAgeMs, }) : createNoopThreadBindingManager(account.accountId); let lifecycleStarted = false; diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 1eb3200baca..a524f22d24c 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -193,6 +193,31 @@ describe("deliverDiscordReply", () => { expect(sendMessageDiscordMock).not.toHaveBeenCalled(); }); + it("touches bound-thread activity after outbound delivery", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + const threadBindings = await createBoundThreadBindings(); + vi.setSystemTime(new Date("2026-02-20T00:02:00.000Z")); + + await deliverDiscordReply({ + replies: [{ text: "Activity ping" }], + target: "channel:thread-1", + token: "token", + runtime, + textLimit: 2000, + sessionKey: "agent:main:subagent:child", + threadBindings, + }); + + expect(threadBindings.getByThreadId("thread-1")?.lastActivityAt).toBe( + new Date("2026-02-20T00:02:00.000Z").getTime(), + ); + } finally { + vi.useRealTimers(); + } + }); + it("falls back to bot send when webhook delivery fails", async () => { const threadBindings = await createBoundThreadBindings(); sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited")); diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index 0ee36b57654..d424f4f2b8c 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -161,6 +161,7 @@ export async function deliverDiscordReply(params: { target: params.target, }); const persona = resolveBindingPersona(binding); + let deliveredAny = false; for (const payload of params.replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const rawText = payload.text ?? ""; @@ -195,6 +196,7 @@ export async function deliverDiscordReply(params: { username: persona.username, avatarUrl: persona.avatarUrl, }); + deliveredAny = true; } continue; } @@ -213,6 +215,7 @@ export async function deliverDiscordReply(params: { accountId: params.accountId, replyTo, }); + deliveredAny = true; // Voice messages cannot include text; send remaining text separately if present. await sendDiscordChunkWithFallback({ target: params.target, @@ -245,6 +248,7 @@ export async function deliverDiscordReply(params: { accountId: params.accountId, replyTo, }); + deliveredAny = true; await sendAdditionalDiscordMedia({ target: params.target, token: params.token, @@ -254,4 +258,8 @@ export async function deliverDiscordReply(params: { resolveReplyTo, }); } + + if (binding && deliveredAny) { + params.threadBindings?.touchThread({ threadId: binding.threadId, persist: true }); + } } diff --git a/src/discord/monitor/thread-bindings.ttl.test.ts b/src/discord/monitor/thread-bindings.lifecycle.test.ts similarity index 72% rename from src/discord/monitor/thread-bindings.ttl.test.ts rename to src/discord/monitor/thread-bindings.lifecycle.test.ts index a452c581327..18ade95734b 100644 --- a/src/discord/monitor/thread-bindings.ttl.test.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.test.ts @@ -49,12 +49,15 @@ const { __testing, autoBindSpawnedDiscordSubagent, createThreadBindingManager, + resolveThreadBindingInactivityExpiresAt, resolveThreadBindingIntroText, - setThreadBindingTtlBySessionKey, + resolveThreadBindingMaxAgeExpiresAt, + setThreadBindingIdleTimeoutBySessionKey, + setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, } = await import("./thread-bindings.js"); -describe("thread binding ttl", () => { +describe("thread binding lifecycle", () => { beforeEach(() => { __testing.resetThreadBindingsForTests(); hoisted.sendMessageDiscord.mockClear(); @@ -71,7 +74,8 @@ describe("thread binding ttl", () => { accountId: "default", persist: false, enableSweeper: true, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); const bindDefaultThreadTarget = async ( @@ -88,23 +92,26 @@ describe("thread binding ttl", () => { }); }; - it("includes ttl in intro text", () => { + it("includes idle and max-age details in intro text", () => { const intro = resolveThreadBindingIntroText({ agentId: "main", label: "worker", - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 48 * 60 * 60 * 1000, }); - expect(intro).toContain("auto-unfocus in 24h"); + expect(intro).toContain("idle auto-unfocus after 24h inactivity"); + expect(intro).toContain("max age 48h"); }); - it("auto-unfocuses expired bindings and sends a ttl-expired message", async () => { + it("auto-unfocuses idle-expired bindings and sends inactivity message", async () => { vi.useFakeTimers(); try { const manager = createThreadBindingManager({ accountId: "default", persist: false, enableSweeper: true, - sessionTtlMs: 60_000, + idleTimeoutMs: 60_000, + maxAgeMs: 0, }); const binding = await manager.bindTarget({ @@ -128,7 +135,41 @@ describe("thread binding ttl", () => { expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled(); expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1); const farewell = hoisted.sendMessageDiscord.mock.calls[0]?.[1] as string | undefined; - expect(farewell).toContain("Session ended automatically after 1m"); + expect(farewell).toContain("after 1m of inactivity"); + } finally { + vi.useRealTimers(); + } + }); + + it("auto-unfocuses max-age-expired bindings and sends max-age message", async () => { + vi.useFakeTimers(); + try { + const manager = createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: true, + idleTimeoutMs: 0, + maxAgeMs: 60_000, + }); + + const binding = await manager.bindTarget({ + threadId: "thread-1", + channelId: "parent-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + webhookId: "wh-1", + webhookToken: "tok-1", + }); + expect(binding).not.toBeNull(); + hoisted.sendMessageDiscord.mockClear(); + + await vi.advanceTimersByTimeAsync(120_000); + + expect(manager.getByThreadId("thread-1")).toBeUndefined(); + expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1); + const farewell = hoisted.sendMessageDiscord.mock.calls[0]?.[1] as string | undefined; + expect(farewell).toContain("max age of 1m"); } finally { vi.useRealTimers(); } @@ -171,7 +212,7 @@ describe("thread binding ttl", () => { } }); - it("updates ttl by target session key", async () => { + it("updates idle timeout by target session key", async () => { vi.useFakeTimers(); try { vi.setSystemTime(new Date("2026-02-20T23:00:00.000Z")); @@ -179,7 +220,8 @@ describe("thread binding ttl", () => { accountId: "default", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); await manager.bindTarget({ @@ -191,33 +233,80 @@ describe("thread binding ttl", () => { webhookId: "wh-1", webhookToken: "tok-1", }); + + const boundAt = manager.getByThreadId("thread-1")?.boundAt; vi.setSystemTime(new Date("2026-02-20T23:15:00.000Z")); - const updated = setThreadBindingTtlBySessionKey({ + const updated = setThreadBindingIdleTimeoutBySessionKey({ accountId: "default", targetSessionKey: "agent:main:subagent:child", - ttlMs: 2 * 60 * 60 * 1000, + idleTimeoutMs: 2 * 60 * 60 * 1000, }); expect(updated).toHaveLength(1); - expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime()); - expect(updated[0]?.expiresAt).toBe(new Date("2026-02-21T01:15:00.000Z").getTime()); - expect(manager.getByThreadId("thread-1")?.expiresAt).toBe( - new Date("2026-02-21T01:15:00.000Z").getTime(), - ); + expect(updated[0]?.lastActivityAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime()); + expect(updated[0]?.boundAt).toBe(boundAt); + expect( + resolveThreadBindingInactivityExpiresAt({ + record: updated[0], + defaultIdleTimeoutMs: manager.getIdleTimeoutMs(), + }), + ).toBe(new Date("2026-02-21T01:15:00.000Z").getTime()); } finally { vi.useRealTimers(); } }); - it("keeps binding when ttl is disabled per session key", async () => { + it("updates max age by target session key", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-02-20T10:00:00.000Z")); + const manager = createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + await manager.bindTarget({ + threadId: "thread-1", + channelId: "parent-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + }); + + vi.setSystemTime(new Date("2026-02-20T10:30:00.000Z")); + const updated = setThreadBindingMaxAgeBySessionKey({ + accountId: "default", + targetSessionKey: "agent:main:subagent:child", + maxAgeMs: 3 * 60 * 60 * 1000, + }); + + expect(updated).toHaveLength(1); + expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T10:30:00.000Z").getTime()); + expect(updated[0]?.lastActivityAt).toBe(new Date("2026-02-20T10:30:00.000Z").getTime()); + expect( + resolveThreadBindingMaxAgeExpiresAt({ + record: updated[0], + defaultMaxAgeMs: manager.getMaxAgeMs(), + }), + ).toBe(new Date("2026-02-20T13:30:00.000Z").getTime()); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps binding when idle timeout is disabled per session key", async () => { vi.useFakeTimers(); try { const manager = createThreadBindingManager({ accountId: "default", persist: false, enableSweeper: true, - sessionTtlMs: 60_000, + idleTimeoutMs: 60_000, + maxAgeMs: 0, }); await manager.bindTarget({ @@ -230,19 +319,55 @@ describe("thread binding ttl", () => { webhookToken: "tok-1", }); - const updated = setThreadBindingTtlBySessionKey({ + const updated = setThreadBindingIdleTimeoutBySessionKey({ accountId: "default", targetSessionKey: "agent:main:subagent:child", - ttlMs: 0, + idleTimeoutMs: 0, }); expect(updated).toHaveLength(1); - expect(updated[0]?.expiresAt).toBe(0); - hoisted.sendWebhookMessageDiscord.mockClear(); + expect(updated[0]?.idleTimeoutMs).toBe(0); await vi.advanceTimersByTimeAsync(240_000); expect(manager.getByThreadId("thread-1")).toBeDefined(); - expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("refreshes inactivity window when thread activity is touched", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + const manager = createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 60_000, + maxAgeMs: 0, + }); + + await manager.bindTarget({ + threadId: "thread-1", + channelId: "parent-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + }); + + vi.setSystemTime(new Date("2026-02-20T00:00:30.000Z")); + const touched = manager.touchThread({ threadId: "thread-1", persist: false }); + expect(touched).not.toBeNull(); + + const record = manager.getByThreadId("thread-1"); + expect(record).toBeDefined(); + expect(record?.lastActivityAt).toBe(new Date("2026-02-20T00:00:30.000Z").getTime()); + expect( + resolveThreadBindingInactivityExpiresAt({ + record: record!, + defaultIdleTimeoutMs: manager.getIdleTimeoutMs(), + }), + ).toBe(new Date("2026-02-20T00:01:30.000Z").getTime()); } finally { vi.useRealTimers(); } @@ -253,7 +378,8 @@ describe("thread binding ttl", () => { accountId: "default", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); const first = await manager.bindTarget({ @@ -289,7 +415,8 @@ describe("thread binding ttl", () => { accountId: "default", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); await manager.bindTarget({ @@ -329,7 +456,8 @@ describe("thread binding ttl", () => { accountId: "default", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); hoisted.restGet.mockClear(); @@ -365,7 +493,8 @@ describe("thread binding ttl", () => { token: "runtime-token", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); hoisted.createDiscordRestClient.mockClear(); @@ -402,14 +531,16 @@ describe("thread binding ttl", () => { token: "token-old", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); const manager = createThreadBindingManager({ accountId: "runtime", token: "token-new", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); hoisted.createThreadDiscord.mockClear(); @@ -441,13 +572,15 @@ describe("thread binding ttl", () => { accountId: "a", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); const b = createThreadBindingManager({ accountId: "b", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); const aBinding = await a.bindTarget({ @@ -503,7 +636,9 @@ describe("thread binding ttl", () => { agentId: "main", boundBy: "system", boundAt: now, - expiresAt: now + 60_000, + lastActivityAt: now, + idleTimeoutMs: 60_000, + maxAgeMs: 0, }, }, }, diff --git a/src/discord/monitor/thread-bindings.lifecycle.ts b/src/discord/monitor/thread-bindings.lifecycle.ts index b741b38483f..5ff2da2377c 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.ts @@ -11,7 +11,6 @@ import { MANAGERS_BY_ACCOUNT_ID, ensureBindingsLoaded, getThreadBindingToken, - normalizeThreadBindingTtlMs, normalizeThreadId, rememberRecentUnboundWebhookEcho, removeBindingRecord, @@ -22,6 +21,13 @@ import { } from "./thread-bindings.state.js"; import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js"; +function normalizeNonNegativeMs(raw: number): number { + if (!Number.isFinite(raw)) { + return 0; + } + return Math.max(0, Math.floor(raw)); +} + function resolveBindingIdsForTargetSession(params: { targetSessionKey: string; accountId?: string; @@ -131,7 +137,8 @@ export async function autoBindSpawnedDiscordSubagent(params: { introText: resolveThreadBindingIntroText({ agentId: params.agentId, label: params.label, - sessionTtlMs: manager.getSessionTtlMs(), + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), }), }); } @@ -181,18 +188,17 @@ export function unbindThreadBindingsBySessionKey(params: { return removed; } -export function setThreadBindingTtlBySessionKey(params: { +export function setThreadBindingIdleTimeoutBySessionKey(params: { targetSessionKey: string; accountId?: string; - ttlMs: number; + idleTimeoutMs: number; }): ThreadBindingRecord[] { const ids = resolveBindingIdsForTargetSession(params); if (ids.length === 0) { return []; } - const ttlMs = normalizeThreadBindingTtlMs(params.ttlMs); + const idleTimeoutMs = normalizeNonNegativeMs(params.idleTimeoutMs); const now = Date.now(); - const expiresAt = ttlMs > 0 ? now + ttlMs : 0; const updated: ThreadBindingRecord[] = []; for (const bindingKey of ids) { const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); @@ -201,8 +207,40 @@ export function setThreadBindingTtlBySessionKey(params: { } const nextRecord: ThreadBindingRecord = { ...existing, - boundAt: now, - expiresAt, + idleTimeoutMs, + lastActivityAt: now, + }; + setBindingRecord(nextRecord); + updated.push(nextRecord); + } + if (updated.length > 0 && shouldPersistBindingMutations()) { + saveBindingsToDisk({ force: true }); + } + return updated; +} + +export function setThreadBindingMaxAgeBySessionKey(params: { + targetSessionKey: string; + accountId?: string; + maxAgeMs: number; +}): ThreadBindingRecord[] { + const ids = resolveBindingIdsForTargetSession(params); + if (ids.length === 0) { + return []; + } + const maxAgeMs = normalizeNonNegativeMs(params.maxAgeMs); + const now = Date.now(); + const updated: ThreadBindingRecord[] = []; + for (const bindingKey of ids) { + const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); + if (!existing) { + continue; + } + const nextRecord: ThreadBindingRecord = { + ...existing, + maxAgeMs, + boundAt: now, + lastActivityAt: now, }; setBindingRecord(nextRecord); updated.push(nextRecord); diff --git a/src/discord/monitor/thread-bindings.manager.ts b/src/discord/monitor/thread-bindings.manager.ts index a4fd5f63cef..342483a3d78 100644 --- a/src/discord/monitor/thread-bindings.manager.ts +++ b/src/discord/monitor/thread-bindings.manager.ts @@ -31,13 +31,16 @@ import { ensureBindingsLoaded, rememberThreadBindingToken, normalizeTargetKind, - normalizeThreadBindingTtlMs, + normalizeThreadBindingDurationMs, normalizeThreadId, rememberRecentUnboundWebhookEcho, removeBindingRecord, resolveBindingIdsForSession, resolveBindingRecordKey, - resolveThreadBindingExpiresAt, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingInactivityExpiresAt, + resolveThreadBindingMaxAgeExpiresAt, + resolveThreadBindingMaxAgeMs, resolveThreadBindingsPath, saveBindingsToDisk, setBindingRecord, @@ -45,7 +48,8 @@ import { resetThreadBindingsForTests, } from "./thread-bindings.state.js"; import { - DEFAULT_THREAD_BINDING_TTL_MS, + DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, + DEFAULT_THREAD_BINDING_MAX_AGE_MS, THREAD_BINDINGS_SWEEP_INTERVAL_MS, type ThreadBindingManager, type ThreadBindingRecord, @@ -62,15 +66,36 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) { } } +function resolveEffectiveBindingExpiresAt(params: { + record: ThreadBindingRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): number | undefined { + const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({ + record: params.record, + defaultIdleTimeoutMs: params.defaultIdleTimeoutMs, + }); + const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({ + record: params.record, + defaultMaxAgeMs: params.defaultMaxAgeMs, + }); + if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { + return Math.min(inactivityExpiresAt, maxAgeExpiresAt); + } + return inactivityExpiresAt ?? maxAgeExpiresAt; +} + function createNoopManager(accountIdRaw?: string): ThreadBindingManager { const accountId = normalizeAccountId(accountIdRaw); return { accountId, - getSessionTtlMs: () => DEFAULT_THREAD_BINDING_TTL_MS, + getIdleTimeoutMs: () => DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, + getMaxAgeMs: () => DEFAULT_THREAD_BINDING_MAX_AGE_MS, getByThreadId: () => undefined, getBySessionKey: () => undefined, listBySessionKey: () => [], listBindings: () => [], + touchThread: () => null, bindTarget: async () => null, unbindThread: () => null, unbindBySessionKey: () => [], @@ -86,7 +111,10 @@ function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" { return raw === "subagent" ? "subagent" : "acp"; } -function toSessionBindingRecord(record: ThreadBindingRecord): SessionBindingRecord { +function toSessionBindingRecord( + record: ThreadBindingRecord, + defaults: { idleTimeoutMs: number; maxAgeMs: number }, +): SessionBindingRecord { const bindingId = resolveBindingRecordKey({ accountId: record.accountId, @@ -104,13 +132,26 @@ function toSessionBindingRecord(record: ThreadBindingRecord): SessionBindingReco }, status: "active", boundAt: record.boundAt, - expiresAt: record.expiresAt, + expiresAt: resolveEffectiveBindingExpiresAt({ + record, + defaultIdleTimeoutMs: defaults.idleTimeoutMs, + defaultMaxAgeMs: defaults.maxAgeMs, + }), metadata: { agentId: record.agentId, label: record.label, webhookId: record.webhookId, webhookToken: record.webhookToken, boundBy: record.boundBy, + lastActivityAt: record.lastActivityAt, + idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({ + record, + defaultIdleTimeoutMs: defaults.idleTimeoutMs, + }), + maxAgeMs: resolveThreadBindingMaxAgeMs({ + record, + defaultMaxAgeMs: defaults.maxAgeMs, + }), }, }; } @@ -137,7 +178,8 @@ export function createThreadBindingManager( token?: string; persist?: boolean; enableSweeper?: boolean; - sessionTtlMs?: number; + idleTimeoutMs?: number; + maxAgeMs?: number; } = {}, ): ThreadBindingManager { ensureBindingsLoaded(); @@ -152,14 +194,22 @@ export function createThreadBindingManager( const persist = params.persist ?? shouldDefaultPersist(); PERSIST_BY_ACCOUNT_ID.set(accountId, persist); - const sessionTtlMs = normalizeThreadBindingTtlMs(params.sessionTtlMs); + const idleTimeoutMs = normalizeThreadBindingDurationMs( + params.idleTimeoutMs, + DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, + ); + const maxAgeMs = normalizeThreadBindingDurationMs( + params.maxAgeMs, + DEFAULT_THREAD_BINDING_MAX_AGE_MS, + ); const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token; let sweepTimer: NodeJS.Timeout | null = null; const manager: ThreadBindingManager = { accountId, - getSessionTtlMs: () => sessionTtlMs, + getIdleTimeoutMs: () => idleTimeoutMs, + getMaxAgeMs: () => maxAgeMs, getByThreadId: (threadId) => { const key = resolveBindingRecordKey({ accountId, @@ -189,6 +239,33 @@ export function createThreadBindingManager( }, listBindings: () => [...BINDINGS_BY_THREAD_ID.values()].filter((entry) => entry.accountId === accountId), + touchThread: (touchParams) => { + const key = resolveBindingRecordKey({ + accountId, + threadId: touchParams.threadId, + }); + if (!key) { + return null; + } + const existing = BINDINGS_BY_THREAD_ID.get(key); + if (!existing || existing.accountId !== accountId) { + return null; + } + const now = Date.now(); + const at = + typeof touchParams.at === "number" && Number.isFinite(touchParams.at) + ? Math.max(0, Math.floor(touchParams.at)) + : now; + const nextRecord: ThreadBindingRecord = { + ...existing, + lastActivityAt: Math.max(existing.lastActivityAt || 0, at), + }; + setBindingRecord(nextRecord); + if (touchParams.persist ?? persist) { + saveBindingsToDisk(); + } + return nextRecord; + }, bindTarget: async (bindParams) => { let threadId = normalizeThreadId(bindParams.threadId); let channelId = bindParams.channelId?.trim() || ""; @@ -250,7 +327,7 @@ export function createThreadBindingManager( webhookToken = createdWebhook.webhookToken ?? ""; } - const boundAt = Date.now(); + const now = Date.now(); const record: ThreadBindingRecord = { accountId, channelId, @@ -262,8 +339,10 @@ export function createThreadBindingManager( webhookId: webhookId || undefined, webhookToken: webhookToken || undefined, boundBy: bindParams.boundBy?.trim() || "system", - boundAt, - expiresAt: sessionTtlMs > 0 ? boundAt + sessionTtlMs : undefined, + boundAt: now, + lastActivityAt: now, + idleTimeoutMs, + maxAgeMs, }; setBindingRecord(record); @@ -301,7 +380,14 @@ export function createThreadBindingManager( const farewell = resolveThreadBindingFarewellText({ reason: unbindParams.reason, farewellText: unbindParams.farewellText, - sessionTtlMs, + idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({ + record: removed, + defaultIdleTimeoutMs: idleTimeoutMs, + }), + maxAgeMs: resolveThreadBindingMaxAgeMs({ + record: removed, + defaultMaxAgeMs: maxAgeMs, + }), }); // Use bot send path for farewell messages so unbound threads don't process // webhook echoes as fresh inbound turns when allowBots is enabled. @@ -367,19 +453,42 @@ export function createThreadBindingManager( return; } for (const binding of bindings) { - const expiresAt = resolveThreadBindingExpiresAt({ + const now = Date.now(); + const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({ record: binding, - sessionTtlMs, + defaultIdleTimeoutMs: idleTimeoutMs, }); - if (expiresAt != null && Date.now() >= expiresAt) { - const ttlFromBinding = Math.max(0, expiresAt - binding.boundAt); + const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({ + record: binding, + defaultMaxAgeMs: maxAgeMs, + }); + const expirationCandidates: Array<{ + reason: "idle-expired" | "max-age-expired"; + at: number; + }> = []; + if (inactivityExpiresAt != null && now >= inactivityExpiresAt) { + expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt }); + } + if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) { + expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt }); + } + if (expirationCandidates.length > 0) { + expirationCandidates.sort((a, b) => a.at - b.at); + const reason = expirationCandidates[0]?.reason ?? "idle-expired"; manager.unbindThread({ threadId: binding.threadId, - reason: "ttl-expired", + reason, sendFarewell: true, farewellText: resolveThreadBindingFarewellText({ - reason: "ttl-expired", - sessionTtlMs: ttlFromBinding, + reason, + idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({ + record: binding, + defaultIdleTimeoutMs: idleTimeoutMs, + }), + maxAgeMs: resolveThreadBindingMaxAgeMs({ + record: binding, + defaultMaxAgeMs: maxAgeMs, + }), }), }); continue; @@ -458,19 +567,30 @@ export function createThreadBindingManager( boundBy, introText, }); - return bound ? toSessionBindingRecord(bound) : null; + return bound + ? toSessionBindingRecord(bound, { + idleTimeoutMs, + maxAgeMs, + }) + : null; }, listBySession: (targetSessionKey) => - manager.listBySessionKey(targetSessionKey).map(toSessionBindingRecord), + manager + .listBySessionKey(targetSessionKey) + .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })), resolveByConversation: (ref) => { if (ref.channel !== "discord") { return null; } const binding = manager.getByThreadId(ref.conversationId); - return binding ? toSessionBindingRecord(binding) : null; + return binding ? toSessionBindingRecord(binding, { idleTimeoutMs, maxAgeMs }) : null; }, - touch: () => { - // Thread bindings are activity-touched by inbound/outbound message flows. + touch: (bindingId, at) => { + const threadId = resolveThreadIdFromBindingId({ accountId, bindingId }); + if (!threadId) { + return; + } + manager.touchThread({ threadId, at, persist: true }); }, unbind: async (input) => { if (input.targetSessionKey?.trim()) { @@ -478,7 +598,7 @@ export function createThreadBindingManager( targetSessionKey: input.targetSessionKey, reason: input.reason, }); - return removed.map(toSessionBindingRecord); + return removed.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })); } const threadId = resolveThreadIdFromBindingId({ accountId, @@ -491,7 +611,7 @@ export function createThreadBindingManager( threadId, reason: input.reason, }); - return removed ? [toSessionBindingRecord(removed)] : []; + return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : []; }, }); diff --git a/src/discord/monitor/thread-bindings.messages.ts b/src/discord/monitor/thread-bindings.messages.ts index e6691949c6c..6348f7b2044 100644 --- a/src/discord/monitor/thread-bindings.messages.ts +++ b/src/discord/monitor/thread-bindings.messages.ts @@ -1,24 +1,24 @@ import { DEFAULT_FAREWELL_TEXT, type ThreadBindingRecord } from "./thread-bindings.types.js"; -function normalizeThreadBindingMessageTtlMs(raw: unknown): number { +function normalizeDurationMs(raw: unknown): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { return 0; } - const ttlMs = Math.floor(raw); - if (ttlMs < 0) { + const durationMs = Math.floor(raw); + if (durationMs < 0) { return 0; } - return ttlMs; + return durationMs; } -export function formatThreadBindingTtlLabel(ttlMs: number): string { - if (ttlMs <= 0) { +export function formatThreadBindingDurationLabel(durationMs: number): string { + if (durationMs <= 0) { return "disabled"; } - if (ttlMs < 60_000) { + if (durationMs < 60_000) { return "<1m"; } - const totalMinutes = Math.floor(ttlMs / 60_000); + const totalMinutes = Math.floor(durationMs / 60_000); if (totalMinutes % 60 === 0) { return `${Math.floor(totalMinutes / 60)}h`; } @@ -38,29 +38,42 @@ export function resolveThreadBindingThreadName(params: { export function resolveThreadBindingIntroText(params: { agentId?: string; label?: string; - sessionTtlMs?: number; + idleTimeoutMs?: number; + maxAgeMs?: number; }): string { const label = params.label?.trim(); const base = label || params.agentId?.trim() || "agent"; const normalized = base.replace(/\s+/g, " ").trim().slice(0, 100) || "agent"; - const ttlMs = normalizeThreadBindingMessageTtlMs(params.sessionTtlMs); - if (ttlMs > 0) { - return `🤖 ${normalized} session active (auto-unfocus in ${formatThreadBindingTtlLabel(ttlMs)}). Messages here go directly to this session.`; + const idleTimeoutMs = normalizeDurationMs(params.idleTimeoutMs); + const maxAgeMs = normalizeDurationMs(params.maxAgeMs); + + let lifecycleHint = ""; + if (idleTimeoutMs > 0 && maxAgeMs > 0) { + lifecycleHint = ` (idle auto-unfocus after ${formatThreadBindingDurationLabel(idleTimeoutMs)} inactivity; max age ${formatThreadBindingDurationLabel(maxAgeMs)})`; + } else if (idleTimeoutMs > 0) { + lifecycleHint = ` (idle auto-unfocus after ${formatThreadBindingDurationLabel(idleTimeoutMs)} inactivity)`; + } else if (maxAgeMs > 0) { + lifecycleHint = ` (max age ${formatThreadBindingDurationLabel(maxAgeMs)})`; } - return `🤖 ${normalized} session active. Messages here go directly to this session.`; + + return `🤖 ${normalized} session active${lifecycleHint}. Messages here go directly to this session.`; } export function resolveThreadBindingFarewellText(params: { reason?: string; farewellText?: string; - sessionTtlMs: number; + idleTimeoutMs: number; + maxAgeMs: number; }): string { const custom = params.farewellText?.trim(); if (custom) { return custom; } - if (params.reason === "ttl-expired") { - return `Session ended automatically after ${formatThreadBindingTtlLabel(params.sessionTtlMs)}. Messages here will no longer be routed.`; + if (params.reason === "idle-expired") { + return `Session unfocused after ${formatThreadBindingDurationLabel(params.idleTimeoutMs)} of inactivity. Messages here will no longer be routed.`; + } + if (params.reason === "max-age-expired") { + return `Session unfocused after reaching max age of ${formatThreadBindingDurationLabel(params.maxAgeMs)}. Messages here will no longer be routed.`; } return DEFAULT_FAREWELL_TEXT; } diff --git a/src/discord/monitor/thread-bindings.state.ts b/src/discord/monitor/thread-bindings.state.ts index 44091d92047..b0a77d8690f 100644 --- a/src/discord/monitor/thread-bindings.state.ts +++ b/src/discord/monitor/thread-bindings.state.ts @@ -4,7 +4,8 @@ import { resolveStateDir } from "../../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { - DEFAULT_THREAD_BINDING_TTL_MS, + DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, + DEFAULT_THREAD_BINDING_MAX_AGE_MS, RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS, THREAD_BINDINGS_VERSION, type PersistedThreadBindingRecord, @@ -164,9 +165,17 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin typeof value.boundAt === "number" && Number.isFinite(value.boundAt) ? Math.floor(value.boundAt) : Date.now(); - const expiresAt = - typeof value.expiresAt === "number" && Number.isFinite(value.expiresAt) - ? Math.max(0, Math.floor(value.expiresAt)) + const lastActivityAt = + typeof value.lastActivityAt === "number" && Number.isFinite(value.lastActivityAt) + ? Math.max(0, Math.floor(value.lastActivityAt)) + : boundAt; + const idleTimeoutMs = + typeof value.idleTimeoutMs === "number" && Number.isFinite(value.idleTimeoutMs) + ? Math.max(0, Math.floor(value.idleTimeoutMs)) + : undefined; + const maxAgeMs = + typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs) + ? Math.max(0, Math.floor(value.maxAgeMs)) : undefined; return { accountId, @@ -180,41 +189,79 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin webhookToken, boundBy, boundAt, - expiresAt, + lastActivityAt, + idleTimeoutMs, + maxAgeMs, }; } -export function normalizeThreadBindingTtlMs(raw: unknown): number { +export function normalizeThreadBindingDurationMs(raw: unknown, defaultsTo: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { - return DEFAULT_THREAD_BINDING_TTL_MS; + return defaultsTo; } - const ttlMs = Math.floor(raw); - if (ttlMs < 0) { - return DEFAULT_THREAD_BINDING_TTL_MS; + const durationMs = Math.floor(raw); + if (durationMs < 0) { + return defaultsTo; } - return ttlMs; + return durationMs; } -export function resolveThreadBindingExpiresAt(params: { - record: Pick; - sessionTtlMs: number; -}): number | undefined { - if (typeof params.record.expiresAt === "number" && Number.isFinite(params.record.expiresAt)) { - const explicitExpiresAt = Math.floor(params.record.expiresAt); - if (explicitExpiresAt <= 0) { - // 0 is an explicit per-binding TTL disable sentinel. - return undefined; - } - return explicitExpiresAt; +export function resolveThreadBindingIdleTimeoutMs(params: { + record: Pick; + defaultIdleTimeoutMs: number; +}): number { + const explicit = params.record.idleTimeoutMs; + if (typeof explicit === "number" && Number.isFinite(explicit)) { + return Math.max(0, Math.floor(explicit)); } - if (params.sessionTtlMs <= 0) { + return Math.max(0, Math.floor(params.defaultIdleTimeoutMs)); +} + +export function resolveThreadBindingMaxAgeMs(params: { + record: Pick; + defaultMaxAgeMs: number; +}): number { + const explicit = params.record.maxAgeMs; + if (typeof explicit === "number" && Number.isFinite(explicit)) { + return Math.max(0, Math.floor(explicit)); + } + return Math.max(0, Math.floor(params.defaultMaxAgeMs)); +} + +export function resolveThreadBindingInactivityExpiresAt(params: { + record: Pick; + defaultIdleTimeoutMs: number; +}): number | undefined { + const idleTimeoutMs = resolveThreadBindingIdleTimeoutMs({ + record: params.record, + defaultIdleTimeoutMs: params.defaultIdleTimeoutMs, + }); + if (idleTimeoutMs <= 0) { + return undefined; + } + const lastActivityAt = Math.floor(params.record.lastActivityAt); + if (!Number.isFinite(lastActivityAt) || lastActivityAt <= 0) { + return undefined; + } + return lastActivityAt + idleTimeoutMs; +} + +export function resolveThreadBindingMaxAgeExpiresAt(params: { + record: Pick; + defaultMaxAgeMs: number; +}): number | undefined { + const maxAgeMs = resolveThreadBindingMaxAgeMs({ + record: params.record, + defaultMaxAgeMs: params.defaultMaxAgeMs, + }); + if (maxAgeMs <= 0) { return undefined; } const boundAt = Math.floor(params.record.boundAt); if (!Number.isFinite(boundAt) || boundAt <= 0) { return undefined; } - return boundAt + params.sessionTtlMs; + return boundAt + maxAgeMs; } function linkSessionBinding(targetSessionKey: string, bindingKey: string) { @@ -429,6 +476,13 @@ export function resolveBindingIdsForSession(params: { return out; } +export function resolveDefaultThreadBindingDurations() { + return { + defaultIdleTimeoutMs: DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, + defaultMaxAgeMs: DEFAULT_THREAD_BINDING_MAX_AGE_MS, + }; +} + export function resetThreadBindingsForTests() { for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { manager.stop(); diff --git a/src/discord/monitor/thread-bindings.ts b/src/discord/monitor/thread-bindings.ts index 88802151093..d6e0642fe00 100644 --- a/src/discord/monitor/thread-bindings.ts +++ b/src/discord/monitor/thread-bindings.ts @@ -5,18 +5,25 @@ export type { } from "./thread-bindings.types.js"; export { - formatThreadBindingTtlLabel, + formatThreadBindingDurationLabel, resolveThreadBindingIntroText, resolveThreadBindingThreadName, } from "./thread-bindings.messages.js"; -export { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.state.js"; +export { + isRecentlyUnboundThreadWebhookMessage, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingInactivityExpiresAt, + resolveThreadBindingMaxAgeExpiresAt, + resolveThreadBindingMaxAgeMs, +} from "./thread-bindings.state.js"; export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, listThreadBindingsForAccount, - setThreadBindingTtlBySessionKey, + setThreadBindingIdleTimeoutBySessionKey, + setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, } from "./thread-bindings.lifecycle.js"; diff --git a/src/discord/monitor/thread-bindings.types.ts b/src/discord/monitor/thread-bindings.types.ts index ab5e77ec905..4eedb18be5f 100644 --- a/src/discord/monitor/thread-bindings.types.ts +++ b/src/discord/monitor/thread-bindings.types.ts @@ -12,7 +12,11 @@ export type ThreadBindingRecord = { webhookToken?: string; boundBy: string; boundAt: number; - expiresAt?: number; + lastActivityAt: number; + /** Inactivity timeout window in milliseconds (0 disables inactivity auto-unfocus). */ + idleTimeoutMs?: number; + /** Hard max-age window in milliseconds from bind time (0 disables hard cap). */ + maxAgeMs?: number; }; export type PersistedThreadBindingRecord = ThreadBindingRecord & { @@ -26,11 +30,17 @@ export type PersistedThreadBindingsPayload = { export type ThreadBindingManager = { accountId: string; - getSessionTtlMs: () => number; + getIdleTimeoutMs: () => number; + getMaxAgeMs: () => number; getByThreadId: (threadId: string) => ThreadBindingRecord | undefined; getBySessionKey: (targetSessionKey: string) => ThreadBindingRecord | undefined; listBySessionKey: (targetSessionKey: string) => ThreadBindingRecord[]; listBindings: () => ThreadBindingRecord[]; + touchThread: (params: { + threadId: string; + at?: number; + persist?: boolean; + }) => ThreadBindingRecord | null; bindTarget: (params: { threadId?: string | number; channelId?: string; @@ -63,7 +73,8 @@ export type ThreadBindingManager = { export const THREAD_BINDINGS_VERSION = 1 as const; export const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 120_000; -export const DEFAULT_THREAD_BINDING_TTL_MS = 24 * 60 * 60 * 1000; // 24h -export const DEFAULT_FAREWELL_TEXT = "Session ended. Messages here will no longer be routed."; +export const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24h +export const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0; // disabled +export const DEFAULT_FAREWELL_TEXT = "Thread unfocused. Messages here will no longer be routed."; export const DISCORD_UNKNOWN_CHANNEL_ERROR_CODE = 10_003; export const RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS = 30_000;