From a7929abad86159ca92cfd1bb45e0c4a27011d598 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:02:39 +0100 Subject: [PATCH] Discord: thread bindings idle + max-age lifecycle (#27845) (thanks @osolmaz) * refactor discord thread bindings to idle and max-age lifecycle * fix: migrate legacy thread binding expiry and reduce hot-path disk writes * refactor: remove remaining thread-binding ttl legacy paths * fix: harden thread-binding lifecycle persistence * Discord: fix thread binding types in message/reply paths * Infra: handle win32 unknown inode in file identity checks * Infra: relax win32 guarded-open identity checks * Config: migrate threadBindings ttlHours to idleHours * Revert "Infra: relax win32 guarded-open identity checks" This reverts commit de94126771db072ecda6a014e80700310e76df61. * Revert "Infra: handle win32 unknown inode in file identity checks" This reverts commit 96fc5ddfb39762aa078d70dd4b4d3754e49a159b. * Discord: re-read live binding state before sweep unbind * fix: add changelog note for thread binding lifecycle update (#27845) (thanks @osolmaz) --------- Co-authored-by: Onur Solmaz --- CHANGELOG.md | 1 + docs/channels/discord.md | 9 +- .../plans/acp-thread-bound-agents.md | 2 +- docs/gateway/configuration-reference.md | 14 +- docs/gateway/configuration.md | 5 +- docs/help/faq.md | 6 +- docs/tools/acp-agents.md | 5 +- docs/tools/slash-commands.md | 5 +- docs/tools/subagents.md | 11 +- src/agents/acp-spawn.ts | 10 +- src/auto-reply/commands-registry.data.ts | 6 +- .../reply/commands-acp/lifecycle.ts | 10 +- .../reply/commands-session-lifecycle.test.ts | 198 ++++++++ .../reply/commands-session-ttl.test.ts | 147 ------ src/auto-reply/reply/commands-session.ts | 156 +++++-- .../reply/commands-subagents-focus.test.ts | 5 +- .../reply/commands-subagents/action-focus.ts | 9 +- .../reply/commands-subagents/shared.ts | 3 +- src/channels/thread-bindings-messages.ts | 59 ++- src/channels/thread-bindings-policy.ts | 61 ++- src/config/legacy.migrations.part-1.ts | 81 ++++ src/config/legacy.rules.ts | 35 ++ src/config/schema.help.quality.test.ts | 3 +- src/config/schema.help.ts | 12 +- src/config/schema.labels.ts | 6 +- .../thread-bindings-config-keys.test.ts | 146 ++++++ 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 | 7 +- .../native-command.model-picker.test.ts | 7 +- src/discord/monitor/provider.test.ts | 8 - src/discord/monitor/provider.ts | 79 +++- src/discord/monitor/reply-delivery.test.ts | 25 + src/discord/monitor/reply-delivery.ts | 9 + src/discord/monitor/thread-bindings.config.ts | 30 +- ...t.ts => thread-bindings.lifecycle.test.ts} | 429 ++++++++++++++++-- .../monitor/thread-bindings.lifecycle.ts | 52 ++- .../monitor/thread-bindings.manager.ts | 186 ++++++-- .../monitor/thread-bindings.messages.ts | 2 +- .../monitor/thread-bindings.persona.test.ts | 1 + src/discord/monitor/thread-bindings.state.ts | 150 ++++-- src/discord/monitor/thread-bindings.ts | 17 +- src/discord/monitor/thread-bindings.types.ts | 23 +- 45 files changed, 1656 insertions(+), 402 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 create mode 100644 src/config/thread-bindings-config-keys.test.ts rename src/discord/monitor/{thread-bindings.ttl.test.ts => thread-bindings.lifecycle.test.ts} (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6fc62e991..e8226632166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Highlight: External Secrets Management introduces a full `openclaw secrets` workflow (`audit`, `configure`, `apply`, `reload`) with runtime snapshot activation, strict `secrets apply` target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant. - ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with `acp` spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz. +- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz. - Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras. - Codex/WebSocket transport: make `openai-codex` WebSocket-first by default (`transport: "auto"` with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection. - Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 58483ef22b6..392c734be94 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -642,7 +642,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: @@ -651,14 +652,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/experiments/plans/acp-thread-bound-agents.md b/docs/experiments/plans/acp-thread-bound-agents.md index 3ca509c9492..a0637cedee5 100644 --- a/docs/experiments/plans/acp-thread-bound-agents.md +++ b/docs/experiments/plans/acp-thread-bound-agents.md @@ -638,7 +638,7 @@ Add independent ACP dispatch kill switch: - `/focus ` continues to support ACP targets - `/unfocus` keeps current semantics -- `/session ttl` remains the top level TTL override +- `/session idle` and `/session max-age` replace the old TTL override ## Phased rollout diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 3feb7462d3f..9bbf0328fc9 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -268,7 +268,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: { @@ -303,8 +304,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. @@ -1374,7 +1376,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 }, @@ -1411,7 +1414,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`. - **`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 46756dbc01a..16e1deb253d 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 cd12c790f53..14757d24e09 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/acp-agents.md b/docs/tools/acp-agents.md index 0b1ec4510c3..33e59aeb15f 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -68,7 +68,7 @@ When thread bindings are enabled for a channel adapter, ACP sessions can be boun - OpenClaw binds a thread to a target ACP session. - Follow-up messages in that thread route to the bound ACP session. - ACP output is delivered back to the same thread. -- Unfocus/close/archive/TTL expiry removes the binding. +- Unfocus/close/archive/idle-timeout or max-age expiry removes the binding. Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message. @@ -272,7 +272,8 @@ Thread binding config is channel-adapter specific. Example for Discord: session: { threadBindings: { enabled: true, - ttlHours: 24, + idleHours: 24, + maxAgeHours: 0, }, }, channels: { diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 4d045d4ee71..b6cd63ba6cf 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) - `/acp spawn|cancel|steer|close|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|sessions` (inspect and control ACP runtime sessions) - `/agents` (list thread-bound agents for this session) @@ -125,7 +126,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`). - ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents). - `/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`. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 8d066a94e7f..77a95d6f836 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). @@ -95,14 +96,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: @@ -110,11 +111,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/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 1ebd7b9d856..1cce4399ddc 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -17,7 +17,8 @@ import { import { formatThreadBindingDisabledError, formatThreadBindingSpawnDisabledError, - resolveThreadBindingSessionTtlMsForChannel, + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingSpawnPolicy, } from "../channels/thread-bindings-policy.js"; import { loadConfig } from "../config/config.js"; @@ -329,7 +330,12 @@ export async function spawnAcpDirect( introText: resolveThreadBindingIntroText({ agentId: targetAgentId, label: params.label || undefined, - sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({ + idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({ + cfg, + channel: preparedBinding.channel, + accountId: preparedBinding.accountId, + }), + maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({ cfg, channel: preparedBinding.channel, accountId: preparedBinding.accountId, diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 3cb2e4ff9f9..bdefb3ba16c 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-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 9039cfe64e0..ddb943cbbe4 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -22,7 +22,8 @@ import { import { formatThreadBindingDisabledError, formatThreadBindingSpawnDisabledError, - resolveThreadBindingSessionTtlMsForChannel, + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingSpawnPolicy, } from "../../../channels/thread-bindings-policy.js"; import type { OpenClawConfig } from "../../../config/config.js"; @@ -196,7 +197,12 @@ async function bindSpawnedAcpSessionToThread(params: { introText: resolveThreadBindingIntroText({ agentId: params.agentId, label, - sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({ + idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({ + cfg: commandParams.cfg, + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + }), + maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({ cfg: commandParams.cfg, channel: spawnPolicy.channel, accountId: spawnPolicy.accountId, 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 d6e21eda15d..70bea0f2944 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -1,9 +1,14 @@ import { parseDurationMs } from "../../cli/parse-duration.js"; import { isRestartEnabled } from "../../config/commands.js"; import { - formatThreadBindingTtlLabel, + formatThreadBindingDurationLabel, getThreadBindingManager, - setThreadBindingTtlBySessionKey, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingInactivityExpiresAt, + resolveThreadBindingMaxAgeExpiresAt, + resolveThreadBindingMaxAgeMs, + setThreadBindingIdleTimeoutBySessionKey, + setThreadBindingMaxAgeBySessionKey, } from "../../discord/monitor/thread-bindings.js"; import { logVerbose } from "../../globals.js"; import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js"; @@ -17,7 +22,9 @@ import { persistSessionEntry } from "./commands-session-store.js"; import type { CommandHandler } from "./commands-types.js"; 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 = @@ -38,21 +45,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); } @@ -246,7 +253,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() }, @@ -256,7 +263,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.", + }, }; } @@ -265,7 +274,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.", + }, }; } @@ -286,20 +297,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." }, }; } @@ -307,13 +357,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, @@ -321,40 +373,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 75164f00581..7a9f5ca34cc 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -112,7 +112,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), @@ -286,7 +287,7 @@ describe("/focus, /unfocus, /agents", () => { targetSessionKey: "agent:codex-acp:session-1", metadata: expect.objectContaining({ 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 e1f083f7104..ecab2bff434 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -4,7 +4,8 @@ import { } from "../../../acp/runtime/session-identifiers.js"; import { readAcpSessionEntry } from "../../../acp/runtime/session-meta.js"; import { - resolveDiscordThreadBindingSessionTtlMs, + resolveDiscordThreadBindingIdleTimeoutMs, + resolveDiscordThreadBindingMaxAgeMs, resolveThreadBindingIntroText, resolveThreadBindingThreadName, } from "../../../discord/monitor/thread-bindings.js"; @@ -109,7 +110,11 @@ export async function handleSubagentsFocusAction( introText: resolveThreadBindingIntroText({ agentId: focusTarget.agentId, label, - sessionTtlMs: resolveDiscordThreadBindingSessionTtlMs({ + idleTimeoutMs: resolveDiscordThreadBindingIdleTimeoutMs({ + cfg: params.cfg, + accountId, + }), + maxAgeMs: resolveDiscordThreadBindingMaxAgeMs({ cfg: params.cfg, accountId, }), 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/channels/thread-bindings-messages.ts b/src/channels/thread-bindings-messages.ts index f3537dead79..fd3d4ccdeaa 100644 --- a/src/channels/thread-bindings-messages.ts +++ b/src/channels/thread-bindings-messages.ts @@ -3,25 +3,25 @@ import { prefixSystemMessage } from "../infra/system-message.js"; const DEFAULT_THREAD_BINDING_FAREWELL_TEXT = "Session ended. Messages here will no longer be routed."; -function normalizeThreadBindingMessageTtlMs(raw: unknown): number { +function normalizeThreadBindingDurationMs(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`; } @@ -41,14 +41,16 @@ export function resolveThreadBindingThreadName(params: { export function resolveThreadBindingIntroText(params: { agentId?: string; label?: string; - sessionTtlMs?: number; + idleTimeoutMs?: number; + maxAgeMs?: number; sessionCwd?: string; sessionDetails?: string[]; }): 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); + const idleTimeoutMs = normalizeThreadBindingDurationMs(params.idleTimeoutMs); + const maxAgeMs = normalizeThreadBindingDurationMs(params.maxAgeMs); const cwd = params.sessionCwd?.trim(); const details = (params.sessionDetails ?? []) .map((entry) => entry.trim()) @@ -56,10 +58,22 @@ export function resolveThreadBindingIntroText(params: { if (cwd) { details.unshift(`cwd: ${cwd}`); } + + const lifecycle: string[] = []; + if (idleTimeoutMs > 0) { + lifecycle.push( + `idle auto-unfocus after ${formatThreadBindingDurationLabel(idleTimeoutMs)} inactivity`, + ); + } + if (maxAgeMs > 0) { + lifecycle.push(`max age ${formatThreadBindingDurationLabel(maxAgeMs)}`); + } + const intro = - ttlMs > 0 - ? `${normalized} session active (auto-unfocus in ${formatThreadBindingTtlLabel(ttlMs)}). Messages here go directly to this session.` + lifecycle.length > 0 + ? `${normalized} session active (${lifecycle.join("; ")}). Messages here go directly to this session.` : `${normalized} session active. Messages here go directly to this session.`; + if (details.length === 0) { return prefixSystemMessage(intro); } @@ -69,16 +83,31 @@ export function resolveThreadBindingIntroText(params: { export function resolveThreadBindingFarewellText(params: { reason?: string; farewellText?: string; - sessionTtlMs: number; + idleTimeoutMs: number; + maxAgeMs: number; }): string { const custom = params.farewellText?.trim(); if (custom) { return prefixSystemMessage(custom); } - if (params.reason === "ttl-expired") { + + if (params.reason === "idle-expired") { + const label = formatThreadBindingDurationLabel( + normalizeThreadBindingDurationMs(params.idleTimeoutMs), + ); return prefixSystemMessage( - `Session ended automatically after ${formatThreadBindingTtlLabel(params.sessionTtlMs)}. Messages here will no longer be routed.`, + `Session ended automatically after ${label} of inactivity. Messages here will no longer be routed.`, ); } + + if (params.reason === "max-age-expired") { + const label = formatThreadBindingDurationLabel( + normalizeThreadBindingDurationMs(params.maxAgeMs), + ); + return prefixSystemMessage( + `Session ended automatically at max age of ${label}. Messages here will no longer be routed.`, + ); + } + return prefixSystemMessage(DEFAULT_THREAD_BINDING_FAREWELL_TEXT); } diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index d5a1f4dd78e..655a03c2e2c 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -2,11 +2,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeAccountId } from "../routing/session-key.js"; export const DISCORD_THREAD_BINDING_CHANNEL = "discord"; -const DEFAULT_THREAD_BINDING_TTL_HOURS = 24; +const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; +const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; type SessionThreadBindingsConfigShape = { enabled?: unknown; - ttlHours?: unknown; + idleHours?: unknown; + maxAgeHours?: unknown; spawnSubagentSessions?: unknown; spawnAcpSessions?: unknown; }; @@ -38,7 +40,7 @@ function normalizeBoolean(value: unknown): boolean | undefined { return value; } -function normalizeThreadBindingTtlHours(raw: unknown): number | undefined { +function normalizeThreadBindingHours(raw: unknown): number | undefined { if (typeof raw !== "number" || !Number.isFinite(raw)) { return undefined; } @@ -48,15 +50,26 @@ function normalizeThreadBindingTtlHours(raw: unknown): number | undefined { return raw; } -export function resolveThreadBindingSessionTtlMs(params: { - channelTtlHoursRaw: unknown; - sessionTtlHoursRaw: unknown; +export 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); +} + +export 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); } export function resolveThreadBindingsEnabled(params: { @@ -124,7 +137,7 @@ export function resolveThreadBindingSpawnPolicy(params: { }; } -export function resolveThreadBindingSessionTtlMsForChannel(params: { +export function resolveThreadBindingIdleTimeoutMsForChannel(params: { cfg: OpenClawConfig; channel: string; accountId?: string; @@ -136,9 +149,27 @@ export function resolveThreadBindingSessionTtlMsForChannel(params: { channel, accountId, }); - return resolveThreadBindingSessionTtlMs({ - channelTtlHoursRaw: account?.ttlHours ?? root?.ttlHours, - sessionTtlHoursRaw: params.cfg.session?.threadBindings?.ttlHours, + return resolveThreadBindingIdleTimeoutMs({ + channelIdleHoursRaw: account?.idleHours ?? root?.idleHours, + sessionIdleHoursRaw: params.cfg.session?.threadBindings?.idleHours, + }); +} + +export function resolveThreadBindingMaxAgeMsForChannel(params: { + cfg: OpenClawConfig; + channel: string; + accountId?: string; +}): number { + const channel = normalizeChannelId(params.channel); + const accountId = normalizeAccountId(params.accountId); + const { root, account } = resolveChannelThreadBindings({ + cfg: params.cfg, + channel, + accountId, + }); + return resolveThreadBindingMaxAgeMs({ + channelMaxAgeHoursRaw: account?.maxAgeHours ?? root?.maxAgeHours, + sessionMaxAgeHoursRaw: params.cfg.session?.threadBindings?.maxAgeHours, }); } diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts index 8bdecabe8c1..70e6dadbbfa 100644 --- a/src/config/legacy.migrations.part-1.ts +++ b/src/config/legacy.migrations.part-1.ts @@ -55,6 +55,39 @@ function ensureDefaultGroupEntry(section: Record): { return { groups, entry }; } +function hasOwnKey(target: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(target, key); +} + +function migrateThreadBindingsTtlHoursForPath(params: { + owner: Record; + pathPrefix: string; + changes: string[]; +}): boolean { + const threadBindings = getRecord(params.owner.threadBindings); + if (!threadBindings || !hasOwnKey(threadBindings, "ttlHours")) { + return false; + } + + const hadIdleHours = threadBindings.idleHours !== undefined; + if (!hadIdleHours) { + threadBindings.idleHours = threadBindings.ttlHours; + } + delete threadBindings.ttlHours; + params.owner.threadBindings = threadBindings; + + if (hadIdleHours) { + params.changes.push( + `Removed ${params.pathPrefix}.threadBindings.ttlHours (${params.pathPrefix}.threadBindings.idleHours already set).`, + ); + } else { + params.changes.push( + `Moved ${params.pathPrefix}.threadBindings.ttlHours → ${params.pathPrefix}.threadBindings.idleHours.`, + ); + } + return true; +} + export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ { id: "bindings.match.provider->bindings.match.channel", @@ -212,6 +245,54 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ raw.channels = channels; }, }, + { + id: "thread-bindings.ttlHours->idleHours", + describe: + "Move legacy threadBindings.ttlHours keys to threadBindings.idleHours (session + channels.discord)", + apply: (raw, changes) => { + const session = getRecord(raw.session); + if (session) { + migrateThreadBindingsTtlHoursForPath({ + owner: session, + pathPrefix: "session", + changes, + }); + raw.session = session; + } + + const channels = getRecord(raw.channels); + const discord = getRecord(channels?.discord); + if (!channels || !discord) { + return; + } + + migrateThreadBindingsTtlHoursForPath({ + owner: discord, + pathPrefix: "channels.discord", + changes, + }); + + const accounts = getRecord(discord.accounts); + if (accounts) { + for (const [accountId, accountRaw] of Object.entries(accounts)) { + const account = getRecord(accountRaw); + if (!account) { + continue; + } + migrateThreadBindingsTtlHoursForPath({ + owner: account, + pathPrefix: `channels.discord.accounts.${accountId}`, + changes, + }); + accounts[accountId] = account; + } + discord.accounts = accounts; + } + + channels.discord = discord; + raw.channels = channels; + }, + }, { id: "channels.streaming-keys->channels.streaming", describe: diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts index 1f959c99448..2e34e440017 100644 --- a/src/config/legacy.rules.ts +++ b/src/config/legacy.rules.ts @@ -1,5 +1,22 @@ import type { LegacyConfigRule } from "./legacy.shared.js"; +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyThreadBindingTtl(value: unknown): boolean { + return isRecord(value) && Object.prototype.hasOwnProperty.call(value, "ttlHours"); +} + +function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return Object.values(value).some((entry) => + hasLegacyThreadBindingTtl(isRecord(entry) ? entry.threadBindings : undefined), + ); +} + export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ { path: ["whatsapp"], @@ -29,6 +46,24 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ path: ["msteams"], message: "msteams config moved to channels.msteams (auto-migrated on load).", }, + { + path: ["session", "threadBindings"], + message: + "session.threadBindings.ttlHours was renamed to session.threadBindings.idleHours (auto-migrated on load).", + match: (value) => hasLegacyThreadBindingTtl(value), + }, + { + path: ["channels", "discord", "threadBindings"], + message: + "channels.discord.threadBindings.ttlHours was renamed to channels.discord.threadBindings.idleHours (auto-migrated on load).", + match: (value) => hasLegacyThreadBindingTtl(value), + }, + { + path: ["channels", "discord", "accounts"], + message: + "channels.discord.accounts..threadBindings.ttlHours was renamed to channels.discord.accounts..threadBindings.idleHours (auto-migrated on load).", + match: (value) => hasLegacyThreadBindingTtlInAccounts(value), + }, { path: ["routing", "allowFrom"], message: diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 8771090cbff..33c583578d6 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -147,7 +147,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 e07e3ea6bd1..e99b6ed0ce8 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1029,8 +1029,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": @@ -1386,8 +1388,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.threadBindings.spawnAcpSessions": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 5372bb9cccc..4c466f09992 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -486,7 +486,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", @@ -687,7 +688,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.threadBindings.spawnAcpSessions": "Discord Thread-Bound ACP Spawn", "channels.discord.ui.components.accentColor": "Discord Component Accent Color", diff --git a/src/config/thread-bindings-config-keys.test.ts b/src/config/thread-bindings-config-keys.test.ts new file mode 100644 index 00000000000..3733017ecac --- /dev/null +++ b/src/config/thread-bindings-config-keys.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { migrateLegacyConfig } from "./legacy-migrate.js"; +import { validateConfigObjectRaw } from "./validation.js"; + +describe("thread binding config keys", () => { + it("rejects legacy session.threadBindings.ttlHours", () => { + const result = validateConfigObjectRaw({ + session: { + threadBindings: { + ttlHours: 24, + }, + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.issues).toContainEqual( + expect.objectContaining({ + path: "session.threadBindings", + message: expect.stringContaining("ttlHours"), + }), + ); + }); + + it("rejects legacy channels.discord.threadBindings.ttlHours", () => { + const result = validateConfigObjectRaw({ + channels: { + discord: { + threadBindings: { + ttlHours: 24, + }, + }, + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.issues).toContainEqual( + expect.objectContaining({ + path: "channels.discord.threadBindings", + message: expect.stringContaining("ttlHours"), + }), + ); + }); + + it("rejects legacy channels.discord.accounts..threadBindings.ttlHours", () => { + const result = validateConfigObjectRaw({ + channels: { + discord: { + accounts: { + alpha: { + threadBindings: { + ttlHours: 24, + }, + }, + }, + }, + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.issues).toContainEqual( + expect.objectContaining({ + path: "channels.discord.accounts", + message: expect.stringContaining("ttlHours"), + }), + ); + }); + + it("migrates session.threadBindings.ttlHours to idleHours", () => { + const result = migrateLegacyConfig({ + session: { + threadBindings: { + ttlHours: 24, + }, + }, + }); + + expect(result.config?.session?.threadBindings?.idleHours).toBe(24); + const normalized = result.config?.session?.threadBindings as + | Record + | undefined; + expect(normalized?.ttlHours).toBeUndefined(); + expect(result.changes).toContain( + "Moved session.threadBindings.ttlHours → session.threadBindings.idleHours.", + ); + }); + + it("migrates Discord threadBindings.ttlHours for root and account entries", () => { + const result = migrateLegacyConfig({ + channels: { + discord: { + threadBindings: { + ttlHours: 12, + }, + accounts: { + alpha: { + threadBindings: { + ttlHours: 6, + }, + }, + beta: { + threadBindings: { + idleHours: 4, + ttlHours: 9, + }, + }, + }, + }, + }, + }); + + const discord = result.config?.channels?.discord; + expect(discord?.threadBindings?.idleHours).toBe(12); + expect( + (discord?.threadBindings as Record | undefined)?.ttlHours, + ).toBeUndefined(); + + expect(discord?.accounts?.alpha?.threadBindings?.idleHours).toBe(6); + expect( + (discord?.accounts?.alpha?.threadBindings as Record | undefined)?.ttlHours, + ).toBeUndefined(); + + expect(discord?.accounts?.beta?.threadBindings?.idleHours).toBe(4); + expect( + (discord?.accounts?.beta?.threadBindings as Record | undefined)?.ttlHours, + ).toBeUndefined(); + + expect(result.changes).toContain( + "Moved channels.discord.threadBindings.ttlHours → channels.discord.threadBindings.idleHours.", + ); + expect(result.changes).toContain( + "Moved channels.discord.accounts.alpha.threadBindings.ttlHours → channels.discord.accounts.alpha.threadBindings.idleHours.", + ); + expect(result.changes).toContain( + "Removed channels.discord.accounts.beta.threadBindings.ttlHours (channels.discord.accounts.beta.threadBindings.idleHours already set).", + ); + }); +}); diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 676767fc901..bcc3bf6b969 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 b5b414153b5..d57a7b57416 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -154,10 +154,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 5c69682123e..1079cecdaf5 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -484,7 +484,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(), spawnAcpSessions: z.boolean().optional(), }) diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index de23c50846e..c33c18f4066 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -64,7 +64,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 b6a73bc18a2..e31ec0bac97 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -113,9 +113,14 @@ 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; } + + const boundThreadId = ctx.threadBinding?.conversation?.conversationId?.trim(); + if (boundThreadId && typeof threadBindings.touchThread === "function") { + threadBindings.touchThread({ threadId: boundThreadId }); + } 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.test.ts b/src/discord/monitor/provider.test.ts index f7a767c596a..731f38c32ea 100644 --- a/src/discord/monitor/provider.test.ts +++ b/src/discord/monitor/provider.test.ts @@ -18,8 +18,6 @@ const { resolveDiscordAllowlistConfigMock, resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabledMock, - resolveThreadBindingSessionTtlMsMock, - resolveThreadBindingsEnabledMock, } = vi.hoisted(() => { const createdBindingManagers: Array<{ stop: ReturnType }> = []; return { @@ -63,8 +61,6 @@ const { })), resolveNativeCommandsEnabledMock: vi.fn(() => true), resolveNativeSkillsEnabledMock: vi.fn(() => false), - resolveThreadBindingSessionTtlMsMock: vi.fn(() => undefined), - resolveThreadBindingsEnabledMock: vi.fn(() => true), }; }); @@ -235,8 +231,6 @@ vi.mock("./thread-bindings.js", () => ({ createNoopThreadBindingManager: createNoopThreadBindingManagerMock, createThreadBindingManager: createThreadBindingManagerMock, reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, - resolveThreadBindingSessionTtlMs: resolveThreadBindingSessionTtlMsMock, - resolveThreadBindingsEnabled: resolveThreadBindingsEnabledMock, })); describe("monitorDiscordProvider", () => { @@ -283,8 +277,6 @@ describe("monitorDiscordProvider", () => { }); resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); - resolveThreadBindingSessionTtlMsMock.mockClear().mockReturnValue(undefined); - resolveThreadBindingsEnabledMock.mockClear().mockReturnValue(true); }); it("stops thread bindings when startup fails before lifecycle begins", async () => { diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 5949b67ce9d..942651525a6 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -74,11 +74,9 @@ import { resolveDiscordRestFetch } from "./rest-fetch.js"; import { createNoopThreadBindingManager, createThreadBindingManager, - resolveThreadBindingSessionTtlMs, - resolveThreadBindingsEnabled, reconcileAcpThreadBindingsOnStartup, } from "./thread-bindings.js"; -import { formatThreadBindingTtlLabel } from "./thread-bindings.messages.js"; +import { formatThreadBindingDurationLabel } from "./thread-bindings.messages.js"; export type MonitorDiscordOpts = { token?: string; @@ -110,8 +108,61 @@ function summarizeGuilds(entries?: Record) { return `${sample.join(", ")}${suffix}`; } -function formatThreadBindingSessionTtlLabel(ttlMs: number): string { - const label = formatThreadBindingTtlLabel(ttlMs); +const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; +const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; + +function normalizeThreadBindingHours(raw: unknown): number | undefined { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + if (raw < 0) { + return undefined; + } + return raw; +} + +function resolveThreadBindingIdleTimeoutMs(params: { + channelIdleHoursRaw: unknown; + sessionIdleHoursRaw: unknown; +}): number { + 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 { + if (typeof raw !== "boolean") { + return undefined; + } + return raw; +} + +function resolveThreadBindingsEnabled(params: { + channelEnabledRaw: unknown; + sessionEnabledRaw: unknown; +}): boolean { + return ( + normalizeThreadBindingsEnabled(params.channelEnabledRaw) ?? + normalizeThreadBindingsEnabled(params.sessionEnabledRaw) ?? + true + ); +} + +function formatThreadBindingDurationForConfigLabel(durationMs: number): string { + const label = formatThreadBindingDurationLabel(durationMs); return label === "disabled" ? "off" : label; } @@ -245,10 +296,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, @@ -288,7 +344,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)}`, ); } @@ -327,7 +383,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ? createThreadBindingManager({ accountId: account.accountId, token, - sessionTtlMs: threadBindingSessionTtlMs, + idleTimeoutMs: threadBindingIdleTimeoutMs, + maxAgeMs: threadBindingMaxAgeMs, }) : createNoopThreadBindingManager(account.accountId); if (threadBindingsEnabled) { diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 7a585a7d84b..d15a9e01c06 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -210,6 +210,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 c82d6c77894..1c79e216555 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -20,6 +20,7 @@ export type DiscordThreadBindingLookupRecord = { export type DiscordThreadBindingLookup = { listBySessionKey: (targetSessionKey: string) => DiscordThreadBindingLookupRecord[]; + touchThread?: (params: { threadId: string; at?: number; persist?: boolean }) => unknown; }; function resolveTargetChannelId(target: string): string | undefined { @@ -173,6 +174,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 ?? ""; @@ -207,6 +209,7 @@ export async function deliverDiscordReply(params: { username: persona.username, avatarUrl: persona.avatarUrl, }); + deliveredAny = true; } continue; } @@ -225,6 +228,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, @@ -257,6 +261,7 @@ export async function deliverDiscordReply(params: { accountId: params.accountId, replyTo, }); + deliveredAny = true; await sendAdditionalDiscordMedia({ target: params.target, token: params.token, @@ -266,4 +271,8 @@ export async function deliverDiscordReply(params: { resolveReplyTo, }); } + + if (binding && deliveredAny) { + params.threadBindings?.touchThread?.({ threadId: binding.threadId }); + } } diff --git a/src/discord/monitor/thread-bindings.config.ts b/src/discord/monitor/thread-bindings.config.ts index dddd42c61ad..364ac9900a2 100644 --- a/src/discord/monitor/thread-bindings.config.ts +++ b/src/discord/monitor/thread-bindings.config.ts @@ -1,21 +1,39 @@ import { - resolveThreadBindingSessionTtlMs, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, } from "../../channels/thread-bindings-policy.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeAccountId } from "../../routing/session-key.js"; -export { resolveThreadBindingSessionTtlMs, resolveThreadBindingsEnabled }; +export { + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingMaxAgeMs, + resolveThreadBindingsEnabled, +}; -export function resolveDiscordThreadBindingSessionTtlMs(params: { +export function resolveDiscordThreadBindingIdleTimeoutMs(params: { cfg: OpenClawConfig; accountId?: string; }): number { const accountId = normalizeAccountId(params.accountId); const root = params.cfg.channels?.discord?.threadBindings; const account = params.cfg.channels?.discord?.accounts?.[accountId]?.threadBindings; - return resolveThreadBindingSessionTtlMs({ - channelTtlHoursRaw: account?.ttlHours ?? root?.ttlHours, - sessionTtlHoursRaw: params.cfg.session?.threadBindings?.ttlHours, + return resolveThreadBindingIdleTimeoutMs({ + channelIdleHoursRaw: account?.idleHours ?? root?.idleHours, + sessionIdleHoursRaw: params.cfg.session?.threadBindings?.idleHours, + }); +} + +export function resolveDiscordThreadBindingMaxAgeMs(params: { + cfg: OpenClawConfig; + accountId?: string; +}): number { + const accountId = normalizeAccountId(params.accountId); + const root = params.cfg.channels?.discord?.threadBindings; + const account = params.cfg.channels?.discord?.accounts?.[accountId]?.threadBindings; + return resolveThreadBindingMaxAgeMs({ + channelMaxAgeHoursRaw: account?.maxAgeHours ?? root?.maxAgeHours, + sessionMaxAgeHoursRaw: params.cfg.session?.threadBindings?.maxAgeHours, }); } diff --git a/src/discord/monitor/thread-bindings.ttl.test.ts b/src/discord/monitor/thread-bindings.lifecycle.test.ts similarity index 59% rename from src/discord/monitor/thread-bindings.ttl.test.ts rename to src/discord/monitor/thread-bindings.lifecycle.test.ts index 0c122eedab8..0e5518d928a 100644 --- a/src/discord/monitor/thread-bindings.ttl.test.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.test.ts @@ -57,12 +57,15 @@ const { autoBindSpawnedDiscordSubagent, createThreadBindingManager, reconcileAcpThreadBindingsOnStartup, + 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(); @@ -80,7 +83,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 ( @@ -97,33 +101,36 @@ 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("includes cwd near the top of intro text", () => { const intro = resolveThreadBindingIntroText({ agentId: "codex", - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, sessionCwd: "/home/bob/clawd", sessionDetails: ["session ids: pending (available after the first reply)"], }); expect(intro).toContain("\ncwd: /home/bob/clawd\nsession ids: pending"); }); - 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({ @@ -147,7 +154,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(); } @@ -190,7 +231,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")); @@ -198,7 +239,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({ @@ -210,33 +252,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({ @@ -249,30 +338,187 @@ 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("keeps a binding when activity is touched during the same sweep pass", async () => { + vi.useFakeTimers(); + try { + const manager = createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: true, + idleTimeoutMs: 60_000, + maxAgeMs: 0, + }); + + await manager.bindTarget({ + threadId: "thread-1", + channelId: "parent-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:first", + agentId: "main", + webhookId: "wh-1", + webhookToken: "tok-1", + }); + await manager.bindTarget({ + threadId: "thread-2", + channelId: "parent-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:second", + agentId: "main", + webhookId: "wh-2", + webhookToken: "tok-2", + }); + + // Keep the first binding off the idle-expire path so the sweep performs + // an awaited probe and gives a window for in-pass touches. + setThreadBindingIdleTimeoutBySessionKey({ + accountId: "default", + targetSessionKey: "agent:main:subagent:first", + idleTimeoutMs: 0, + }); + + hoisted.restGet.mockImplementation(async (...args: unknown[]) => { + const route = typeof args[0] === "string" ? args[0] : ""; + if (route.includes("thread-1")) { + manager.touchThread({ threadId: "thread-2", persist: false }); + } + return { + id: route.split("/").at(-1) ?? "thread-1", + type: 11, + parent_id: "parent-1", + }; + }); + hoisted.sendMessageDiscord.mockClear(); + + await vi.advanceTimersByTimeAsync(120_000); + + expect(manager.getByThreadId("thread-2")).toBeDefined(); + expect(hoisted.sendMessageDiscord).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(); + } + }); + + it("persists touched activity timestamps across restart when persistence is enabled", async () => { + vi.useFakeTimers(); + 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(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + const manager = createThreadBindingManager({ + accountId: "default", + persist: true, + 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", + webhookId: "wh-1", + webhookToken: "tok-1", + }); + + const touchedAt = new Date("2026-02-20T00:00:30.000Z").getTime(); + vi.setSystemTime(touchedAt); + manager.touchThread({ threadId: "thread-1" }); + + __testing.resetThreadBindingsForTests(); + const reloaded = createThreadBindingManager({ + accountId: "default", + persist: true, + enableSweeper: false, + idleTimeoutMs: 60_000, + maxAgeMs: 0, + }); + + const record = reloaded.getByThreadId("thread-1"); + expect(record).toBeDefined(); + expect(record?.lastActivityAt).toBe(touchedAt); + expect( + resolveThreadBindingInactivityExpiresAt({ + record: record!, + defaultIdleTimeoutMs: reloaded.getIdleTimeoutMs(), + }), + ).toBe(new Date("2026-02-20T00:01:30.000Z").getTime()); + } 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 }); + vi.useRealTimers(); + } + }); + it("reuses webhook credentials after unbind when rebinding in the same channel", async () => { const manager = createThreadBindingManager({ accountId: "default", persist: false, enableSweeper: false, - sessionTtlMs: 24 * 60 * 60 * 1000, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, }); const first = await manager.bindTarget({ @@ -308,7 +554,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({ @@ -348,7 +595,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(); @@ -384,7 +632,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(); @@ -421,14 +670,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(); @@ -460,13 +711,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 +756,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({ @@ -577,7 +831,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({ @@ -611,6 +866,104 @@ describe("thread binding ttl", () => { 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-")); @@ -635,7 +988,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 282cac42537..bfc6c8513fb 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.ts @@ -13,7 +13,6 @@ import { MANAGERS_BY_ACCOUNT_ID, ensureBindingsLoaded, getThreadBindingToken, - normalizeThreadBindingTtlMs, normalizeThreadId, rememberRecentUnboundWebhookEcho, removeBindingRecord, @@ -30,6 +29,13 @@ export type AcpThreadBindingReconciliationResult = { staleSessionKeys: string[]; }; +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; @@ -139,7 +145,8 @@ export async function autoBindSpawnedDiscordSubagent(params: { introText: resolveThreadBindingIntroText({ agentId: params.agentId, label: params.label, - sessionTtlMs: manager.getSessionTtlMs(), + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), }), }); } @@ -189,18 +196,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); @@ -209,8 +215,40 @@ export function setThreadBindingTtlBySessionKey(params: { } const nextRecord: ThreadBindingRecord = { ...existing, + 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, - expiresAt, + 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 6b50028b8a3..9592962f368 100644 --- a/src/discord/monitor/thread-bindings.manager.ts +++ b/src/discord/monitor/thread-bindings.manager.ts @@ -31,21 +31,26 @@ import { ensureBindingsLoaded, rememberThreadBindingToken, normalizeTargetKind, - normalizeThreadBindingTtlMs, + normalizeThreadBindingDurationMs, normalizeThreadId, rememberRecentUnboundWebhookEcho, removeBindingRecord, resolveBindingIdsForSession, resolveBindingRecordKey, - resolveThreadBindingExpiresAt, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingInactivityExpiresAt, + resolveThreadBindingMaxAgeExpiresAt, + resolveThreadBindingMaxAgeMs, resolveThreadBindingsPath, saveBindingsToDisk, setBindingRecord, + THREAD_BINDING_TOUCH_PERSIST_MIN_INTERVAL_MS, shouldDefaultPersist, 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 +67,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 +112,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 +133,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 +179,8 @@ export function createThreadBindingManager( token?: string; persist?: boolean; enableSweeper?: boolean; - sessionTtlMs?: number; + idleTimeoutMs?: number; + maxAgeMs?: number; } = {}, ): ThreadBindingManager { ensureBindingsLoaded(); @@ -152,14 +195,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 +240,35 @@ 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({ + minIntervalMs: THREAD_BINDING_TOUCH_PERSIST_MIN_INTERVAL_MS, + }); + } + return nextRecord; + }, bindTarget: async (bindParams) => { let threadId = normalizeThreadId(bindParams.threadId); let channelId = bindParams.channelId?.trim() || ""; @@ -250,7 +330,7 @@ export function createThreadBindingManager( webhookToken = createdWebhook.webhookToken ?? ""; } - const boundAt = Date.now(); + const now = Date.now(); const record: ThreadBindingRecord = { accountId, channelId, @@ -262,8 +342,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 +383,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. @@ -366,20 +455,50 @@ export function createThreadBindingManager( } catch { return; } - for (const binding of bindings) { - const expiresAt = resolveThreadBindingExpiresAt({ + for (const snapshotBinding of bindings) { + // Re-read live state after any awaited work from earlier iterations. + // This avoids unbinding based on stale snapshot data when activity touches + // happen while the sweeper loop is in-flight. + const binding = manager.getByThreadId(snapshotBinding.threadId); + if (!binding) { + continue; + } + 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; @@ -479,19 +598,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()) { @@ -499,7 +629,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, @@ -512,7 +642,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 27363cb3215..2460ac07020 100644 --- a/src/discord/monitor/thread-bindings.messages.ts +++ b/src/discord/monitor/thread-bindings.messages.ts @@ -1,5 +1,5 @@ export { - formatThreadBindingTtlLabel, + formatThreadBindingDurationLabel, resolveThreadBindingFarewellText, resolveThreadBindingIntroText, resolveThreadBindingThreadName, diff --git a/src/discord/monitor/thread-bindings.persona.test.ts b/src/discord/monitor/thread-bindings.persona.test.ts index 7087cff09a4..91b337d868c 100644 --- a/src/discord/monitor/thread-bindings.persona.test.ts +++ b/src/discord/monitor/thread-bindings.persona.test.ts @@ -26,6 +26,7 @@ describe("thread binding persona", () => { agentId: "codex", boundBy: "system", boundAt: Date.now(), + lastActivityAt: Date.now(), label: "codex-thread", } satisfies ThreadBindingRecord; expect(resolveThreadBindingPersonaFromRecord(record)).toBe("⚙️ codex-thread"); diff --git a/src/discord/monitor/thread-bindings.state.ts b/src/discord/monitor/thread-bindings.state.ts index 44091d92047..a5d865b2c09 100644 --- a/src/discord/monitor/thread-bindings.state.ts +++ b/src/discord/monitor/thread-bindings.state.ts @@ -4,8 +4,9 @@ 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, - RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS, + DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, + DEFAULT_THREAD_BINDING_MAX_AGE_MS, + RECENT_UNBOUND_WEBHOOK_ECHO_WINDOW_MS, THREAD_BINDINGS_VERSION, type PersistedThreadBindingRecord, type PersistedThreadBindingsPayload, @@ -23,6 +24,7 @@ type ThreadBindingsGlobalState = { reusableWebhooksByAccountChannel: Map; persistByAccountId: Map; loadedBindings: boolean; + lastPersistedAtMs: number; }; // Plugin hooks can load this module via Jiti while core imports it via ESM. @@ -45,6 +47,7 @@ function createThreadBindingsGlobalState(): ThreadBindingsGlobalState { >(), persistByAccountId: new Map(), loadedBindings: false, + lastPersistedAtMs: 0, }; } @@ -69,6 +72,7 @@ export const RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY = export const REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL = THREAD_BINDINGS_STATE.reusableWebhooksByAccountChannel; export const PERSIST_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.persistByAccountId; +export const THREAD_BINDING_TOUCH_PERSIST_MIN_INTERVAL_MS = 15_000; export function rememberThreadBindingToken(params: { accountId?: string; token?: string }) { const normalizedAccountId = normalizeAccountId(params.accountId); @@ -164,10 +168,42 @@ 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; + 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, @@ -180,41 +216,79 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin webhookToken, boundBy, boundAt, - expiresAt, + lastActivityAt, + idleTimeoutMs: migratedIdleTimeoutMs, + maxAgeMs: migratedMaxAgeMs, }; } -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) { @@ -273,7 +347,7 @@ export function rememberRecentUnboundWebhookEcho(record: ThreadBindingRecord) { } RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.set(bindingKey, { webhookId, - expiresAt: Date.now() + RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS, + expiresAt: Date.now() + RECENT_UNBOUND_WEBHOOK_ECHO_WINDOW_MS, }); } @@ -357,10 +431,23 @@ export function shouldPersistBindingMutations(): boolean { return fs.existsSync(resolveThreadBindingsPath()); } -export function saveBindingsToDisk(params: { force?: boolean } = {}) { +export function saveBindingsToDisk(params: { force?: boolean; minIntervalMs?: number } = {}) { if (!params.force && !shouldPersistAnyBindingState()) { return; } + const minIntervalMs = + typeof params.minIntervalMs === "number" && Number.isFinite(params.minIntervalMs) + ? Math.max(0, Math.floor(params.minIntervalMs)) + : 0; + const now = Date.now(); + if ( + !params.force && + minIntervalMs > 0 && + THREAD_BINDINGS_STATE.lastPersistedAtMs > 0 && + now - THREAD_BINDINGS_STATE.lastPersistedAtMs < minIntervalMs + ) { + return; + } const bindings: Record = {}; for (const [bindingKey, record] of BINDINGS_BY_THREAD_ID.entries()) { bindings[bindingKey] = { ...record }; @@ -370,6 +457,7 @@ export function saveBindingsToDisk(params: { force?: boolean } = {}) { bindings, }; saveJsonFile(resolveThreadBindingsPath(), payload); + THREAD_BINDINGS_STATE.lastPersistedAtMs = now; } export function ensureBindingsLoaded() { @@ -429,6 +517,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(); @@ -441,4 +536,5 @@ export function resetThreadBindingsForTests() { TOKENS_BY_ACCOUNT_ID.clear(); PERSIST_BY_ACCOUNT_ID.clear(); THREAD_BINDINGS_STATE.loadedBindings = false; + THREAD_BINDINGS_STATE.lastPersistedAtMs = 0; } diff --git a/src/discord/monitor/thread-bindings.ts b/src/discord/monitor/thread-bindings.ts index 6bde0daff2b..c4609ff500e 100644 --- a/src/discord/monitor/thread-bindings.ts +++ b/src/discord/monitor/thread-bindings.ts @@ -5,7 +5,7 @@ export type { } from "./thread-bindings.types.js"; export { - formatThreadBindingTtlLabel, + formatThreadBindingDurationLabel, resolveThreadBindingIntroText, resolveThreadBindingThreadName, } from "./thread-bindings.messages.js"; @@ -15,19 +15,26 @@ export { } from "./thread-bindings.persona.js"; export { - resolveDiscordThreadBindingSessionTtlMs, - resolveThreadBindingSessionTtlMs, + resolveDiscordThreadBindingIdleTimeoutMs, + resolveDiscordThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, } from "./thread-bindings.config.js"; -export { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.state.js"; +export { + isRecentlyUnboundThreadWebhookMessage, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingInactivityExpiresAt, + resolveThreadBindingMaxAgeExpiresAt, + resolveThreadBindingMaxAgeMs, +} from "./thread-bindings.state.js"; export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, listThreadBindingsForAccount, reconcileAcpThreadBindingsOnStartup, - 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..228c81c58cc 100644 --- a/src/discord/monitor/thread-bindings.types.ts +++ b/src/discord/monitor/thread-bindings.types.ts @@ -12,11 +12,17 @@ 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 & { sessionKey?: string; + /** @deprecated Legacy absolute expiry timestamp; migrated on load. */ + expiresAt?: number; }; export type PersistedThreadBindingsPayload = { @@ -26,11 +32,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 +75,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; +export const RECENT_UNBOUND_WEBHOOK_ECHO_WINDOW_MS = 30_000;