Discord: thread bindings idle + max-age lifecycle (#27845) (thanks @osolmaz)

* refactor discord thread bindings to idle and max-age lifecycle

* fix: migrate legacy thread binding expiry and reduce hot-path disk writes

* refactor: remove remaining thread-binding ttl legacy paths

* fix: harden thread-binding lifecycle persistence

* Discord: fix thread binding types in message/reply paths

* Infra: handle win32 unknown inode in file identity checks

* Infra: relax win32 guarded-open identity checks

* Config: migrate threadBindings ttlHours to idleHours

* Revert "Infra: relax win32 guarded-open identity checks"

This reverts commit de94126771.

* Revert "Infra: handle win32 unknown inode in file identity checks"

This reverts commit 96fc5ddfb3.

* 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:
Onur Solmaz
2026-02-27 10:02:39 +01:00
committed by GitHub
parent 0fb7add7d6
commit a7929abad8
45 changed files with 1656 additions and 402 deletions

View File

@@ -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;