mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:51:37 +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:
@@ -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>",
|
||||
|
||||
Reference in New Issue
Block a user