mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
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 commitde94126771. * Revert "Infra: handle win32 unknown inode in file identity checks" This reverts commit96fc5ddfb3. * 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 <onur@textcortex.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -642,7 +642,8 @@ Default slash command settings:
|
||||
- `/focus <target>` bind current/new thread to a subagent/session target
|
||||
- `/unfocus` remove current thread binding
|
||||
- `/agents` show active runs and binding state
|
||||
- `/session ttl <duration|off>` inspect/update auto-unfocus TTL for focused bindings
|
||||
- `/session idle <duration|off>` inspect/update inactivity auto-unfocus for focused bindings
|
||||
- `/session max-age <duration|off>` 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
|
||||
},
|
||||
},
|
||||
|
||||
@@ -638,7 +638,7 @@ Add independent ACP dispatch kill switch:
|
||||
|
||||
- `/focus <sessionKey>` 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <target>`.
|
||||
- Use `/agents` to inspect binding state.
|
||||
- Use `/session ttl <duration|off>` to control auto-unfocus.
|
||||
- Use `/session idle <duration|off>` and `/session max-age <duration|off>` 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).
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 <duration|off>` (manage session-level settings, such as TTL)
|
||||
- `/session idle <duration|off>` (manage inactivity auto-unfocus for focused thread bindings)
|
||||
- `/session max-age <duration|off>` (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`.
|
||||
|
||||
@@ -30,7 +30,8 @@ These commands work on channels that support persistent thread bindings. See **T
|
||||
- `/focus <subagent-label|session-key|session-id|session-label>`
|
||||
- `/unfocus`
|
||||
- `/agents`
|
||||
- `/session ttl <duration|off>`
|
||||
- `/session idle <duration|off>`
|
||||
- `/session max-age <duration|off>`
|
||||
|
||||
`/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 <target>` 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:<id>` 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
198
src/auto-reply/reply/commands-session-lifecycle.test.ts
Normal file
198
src/auto-reply/reply/commands-session-lifecycle.test.ts
Normal file
@@ -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<typeof import("../../discord/monitor/thread-bindings.js")>();
|
||||
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<string, unknown>) {
|
||||
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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("../../discord/monitor/thread-bindings.js")>();
|
||||
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<string, unknown>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<CommandHandler>[0]): boolean {
|
||||
const channel =
|
||||
@@ -38,21 +45,21 @@ function resolveDiscordAccountId(params: Parameters<CommandHandler>[0]): string
|
||||
}
|
||||
|
||||
function resolveSessionCommandUsage() {
|
||||
return "Usage: /session ttl <duration|off> (example: /session ttl 24h)";
|
||||
return "Usage: /session idle <duration|off> | /session max-age <duration|off> (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;
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -372,7 +372,8 @@ export function buildSubagentsHelp() {
|
||||
"- /focus <subagent-label|session-key|session-id|session-label>",
|
||||
"- /unfocus",
|
||||
"- /agents",
|
||||
"- /session ttl <duration|off>",
|
||||
"- /session idle <duration|off>",
|
||||
"- /session max-age <duration|off>",
|
||||
"- /kill <id|#|all>",
|
||||
"- /steer <id|#> <message>",
|
||||
"- /tell <id|#> <message>",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,39 @@ function ensureDefaultGroupEntry(section: Record<string, unknown>): {
|
||||
return { groups, entry };
|
||||
}
|
||||
|
||||
function hasOwnKey(target: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(target, key);
|
||||
}
|
||||
|
||||
function migrateThreadBindingsTtlHoursForPath(params: {
|
||||
owner: Record<string, unknown>;
|
||||
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:
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { LegacyConfigRule } from "./legacy.shared.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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.<id>.threadBindings.ttlHours was renamed to channels.discord.accounts.<id>.threadBindings.idleHours (auto-migrated on load).",
|
||||
match: (value) => hasLegacyThreadBindingTtlInAccounts(value),
|
||||
},
|
||||
{
|
||||
path: ["routing", "allowFrom"],
|
||||
message:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1029,8 +1029,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -486,7 +486,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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",
|
||||
|
||||
146
src/config/thread-bindings-config-keys.test.ts
Normal file
146
src/config/thread-bindings-config-keys.test.ts
Normal file
@@ -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.<id>.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<string, unknown>
|
||||
| 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<string, unknown> | undefined)?.ttlHours,
|
||||
).toBeUndefined();
|
||||
|
||||
expect(discord?.accounts?.alpha?.threadBindings?.idleHours).toBe(6);
|
||||
expect(
|
||||
(discord?.accounts?.alpha?.threadBindings as Record<string, unknown> | undefined)?.ttlHours,
|
||||
).toBeUndefined();
|
||||
|
||||
expect(discord?.accounts?.beta?.threadBindings?.idleHours).toBe(4);
|
||||
expect(
|
||||
(discord?.accounts?.beta?.threadBindings as Record<string, unknown> | 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).",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => [],
|
||||
|
||||
@@ -18,8 +18,6 @@ const {
|
||||
resolveDiscordAllowlistConfigMock,
|
||||
resolveNativeCommandsEnabledMock,
|
||||
resolveNativeSkillsEnabledMock,
|
||||
resolveThreadBindingSessionTtlMsMock,
|
||||
resolveThreadBindingsEnabledMock,
|
||||
} = vi.hoisted(() => {
|
||||
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
|
||||
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 () => {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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) {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })] : [];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export {
|
||||
formatThreadBindingTtlLabel,
|
||||
formatThreadBindingDurationLabel,
|
||||
resolveThreadBindingFarewellText,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, { webhookId: string; webhookToken: string }>;
|
||||
persistByAccountId: Map<string, boolean>;
|
||||
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<string, boolean>(),
|
||||
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<ThreadBindingRecord, "boundAt" | "expiresAt">;
|
||||
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<ThreadBindingRecord, "idleTimeoutMs">;
|
||||
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<ThreadBindingRecord, "maxAgeMs">;
|
||||
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<ThreadBindingRecord, "lastActivityAt" | "idleTimeoutMs">;
|
||||
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<ThreadBindingRecord, "boundAt" | "maxAgeMs">;
|
||||
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<string, PersistedThreadBindingRecord> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user