diff --git a/docs/experiments/plans/acp-thread-bound-agents.md b/docs/experiments/plans/acp-thread-bound-agents.md index 3ca509c9492..a0637cedee5 100644 --- a/docs/experiments/plans/acp-thread-bound-agents.md +++ b/docs/experiments/plans/acp-thread-bound-agents.md @@ -638,7 +638,7 @@ Add independent ACP dispatch kill switch: - `/focus ` continues to support ACP targets - `/unfocus` keeps current semantics -- `/session ttl` remains the top level TTL override +- `/session idle` and `/session max-age` replace the old TTL override ## Phased rollout diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 3a03822f25b..cf91583c7f0 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -68,7 +68,7 @@ When thread bindings are enabled for a channel adapter, ACP sessions can be boun - OpenClaw binds a thread to a target ACP session. - Follow-up messages in that thread route to the bound ACP session. - ACP output is delivered back to the same thread. -- Unfocus/close/archive/TTL expiry removes the binding. +- Unfocus/close/archive/idle-timeout or max-age expiry removes the binding. Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message. diff --git a/src/discord/monitor/thread-bindings.lifecycle.test.ts b/src/discord/monitor/thread-bindings.lifecycle.test.ts index aa1dac458d2..02aba36b43e 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.test.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.test.ts @@ -746,104 +746,6 @@ describe("thread binding lifecycle", () => { expect(manager.getByThreadId("thread-acp-uncertain")).toBeDefined(); }); - it("migrates legacy expiresAt bindings to idle/max-age semantics", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-")); - process.env.OPENCLAW_STATE_DIR = stateDir; - try { - __testing.resetThreadBindingsForTests(); - const bindingsPath = __testing.resolveThreadBindingsPath(); - fs.mkdirSync(path.dirname(bindingsPath), { recursive: true }); - const boundAt = Date.now() - 10_000; - const expiresAt = boundAt + 60_000; - fs.writeFileSync( - bindingsPath, - JSON.stringify( - { - version: 1, - bindings: { - "thread-legacy-active": { - accountId: "default", - channelId: "parent-1", - threadId: "thread-legacy-active", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:legacy-active", - agentId: "main", - boundBy: "system", - boundAt, - expiresAt, - }, - "thread-legacy-disabled": { - accountId: "default", - channelId: "parent-1", - threadId: "thread-legacy-disabled", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:legacy-disabled", - agentId: "main", - boundBy: "system", - boundAt, - expiresAt: 0, - }, - }, - }, - null, - 2, - ), - "utf-8", - ); - - const manager = createThreadBindingManager({ - accountId: "default", - persist: false, - enableSweeper: false, - idleTimeoutMs: 24 * 60 * 60 * 1000, - maxAgeMs: 0, - }); - - const active = manager.getByThreadId("thread-legacy-active"); - expect(active).toBeDefined(); - expect(active?.idleTimeoutMs).toBe(0); - expect(active?.maxAgeMs).toBe(expiresAt - boundAt); - expect( - resolveThreadBindingMaxAgeExpiresAt({ - record: active!, - defaultMaxAgeMs: manager.getMaxAgeMs(), - }), - ).toBe(expiresAt); - expect( - resolveThreadBindingInactivityExpiresAt({ - record: active!, - defaultIdleTimeoutMs: manager.getIdleTimeoutMs(), - }), - ).toBeUndefined(); - - const disabled = manager.getByThreadId("thread-legacy-disabled"); - expect(disabled).toBeDefined(); - expect(disabled?.idleTimeoutMs).toBe(0); - expect(disabled?.maxAgeMs).toBe(0); - expect( - resolveThreadBindingMaxAgeExpiresAt({ - record: disabled!, - defaultMaxAgeMs: manager.getMaxAgeMs(), - }), - ).toBeUndefined(); - expect( - resolveThreadBindingInactivityExpiresAt({ - record: disabled!, - defaultIdleTimeoutMs: manager.getIdleTimeoutMs(), - }), - ).toBeUndefined(); - } finally { - __testing.resetThreadBindingsForTests(); - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - fs.rmSync(stateDir, { recursive: true, force: true }); - } - }); - it("persists unbinds even when no manager is active", () => { const previousStateDir = process.env.OPENCLAW_STATE_DIR; const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-")); diff --git a/src/discord/monitor/thread-bindings.state.ts b/src/discord/monitor/thread-bindings.state.ts index af0c251d762..10ae130b552 100644 --- a/src/discord/monitor/thread-bindings.state.ts +++ b/src/discord/monitor/thread-bindings.state.ts @@ -6,7 +6,7 @@ import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/ import { DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS, DEFAULT_THREAD_BINDING_MAX_AGE_MS, - RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS, + RECENT_UNBOUND_WEBHOOK_ECHO_WINDOW_MS, THREAD_BINDINGS_VERSION, type PersistedThreadBindingRecord, type PersistedThreadBindingsPayload, @@ -177,29 +177,6 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs) ? Math.max(0, Math.floor(value.maxAgeMs)) : undefined; - const legacyExpiresAt = - typeof (value as { expiresAt?: unknown }).expiresAt === "number" && - Number.isFinite((value as { expiresAt?: unknown }).expiresAt) - ? Math.max(0, Math.floor((value as { expiresAt?: number }).expiresAt ?? 0)) - : undefined; - - let migratedIdleTimeoutMs = idleTimeoutMs; - let migratedMaxAgeMs = maxAgeMs; - if ( - migratedIdleTimeoutMs === undefined && - migratedMaxAgeMs === undefined && - legacyExpiresAt != null - ) { - if (legacyExpiresAt <= 0) { - migratedIdleTimeoutMs = 0; - migratedMaxAgeMs = 0; - } else { - const baseBoundAt = boundAt > 0 ? boundAt : lastActivityAt; - // Legacy expiresAt represented an absolute timestamp; map it to max-age and disable idle timeout. - migratedIdleTimeoutMs = 0; - migratedMaxAgeMs = Math.max(1, legacyExpiresAt - Math.max(0, baseBoundAt)); - } - } return { accountId, @@ -214,8 +191,8 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin boundBy, boundAt, lastActivityAt, - idleTimeoutMs: migratedIdleTimeoutMs, - maxAgeMs: migratedMaxAgeMs, + idleTimeoutMs, + maxAgeMs, }; } @@ -344,7 +321,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, }); } diff --git a/src/discord/monitor/thread-bindings.types.ts b/src/discord/monitor/thread-bindings.types.ts index 4eedb18be5f..13923c44e68 100644 --- a/src/discord/monitor/thread-bindings.types.ts +++ b/src/discord/monitor/thread-bindings.types.ts @@ -77,4 +77,4 @@ export const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 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;