From 8ef5b5ddbaefff0214bc1f36a4fa3cb4c672f36f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 15:36:50 -0400 Subject: [PATCH] fix(auto-reply): bound session lifecycle expiries --- .../reply/commands-session-lifecycle.test.ts | 23 ++++++++++++-- src/auto-reply/reply/commands-session.ts | 31 +++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 88825dfe9c8..a46522f50a3 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -570,7 +570,7 @@ describe("/session idle and /session max-age", () => { expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z"); }); - it("falls back when active idle timeout expiry is Date-invalid", async () => { + it("falls back to bind time when idle activity timestamp is out of range", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); @@ -586,7 +586,26 @@ describe("/session idle and /session max-age", () => { ); const result = await handleSessionCommand(createThreadCommandParams("/session idle"), true); - expect(result?.reply?.text).toBe("ℹ️ Idle timeout active (2h, next auto-unfocus at n/a)."); + expect(result?.reply?.text).toContain("Idle timeout active (2h"); + expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z"); + }); + + it("treats overflowed idle timeout metadata as disabled", async () => { + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createThreadBinding({ + metadata: { + boundBy: "user-1", + lastActivityAt: Date.now(), + idleTimeoutMs: Number.MAX_SAFE_INTEGER, + maxAgeMs: 0, + }, + }), + ); + + const result = await handleSessionCommand(createThreadCommandParams("/session idle"), true); + expect(result?.reply?.text).toBe( + "ℹ️ Idle timeout is currently disabled for this focused session.", + ); }); it("sets max age for the focused thread-chat session", async () => { diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 302de434e01..c9334063560 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -27,6 +27,10 @@ import { } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js"; import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js"; +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "../../shared/number-coercion.js"; import { formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { parseActivationCommand } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; @@ -104,13 +108,19 @@ function resolveSessionBindingDurationMs( } function resolveSessionBindingLastActivityAt(binding: SessionBindingRecord): number { - const raw = binding.metadata?.lastActivityAt; - if (typeof raw !== "number" || !Number.isFinite(raw)) { + const raw = asDateTimestampMs(binding.metadata?.lastActivityAt); + if (raw === undefined) { return binding.boundAt; } return Math.max(Math.floor(raw), binding.boundAt); } +function resolveSessionBindingExpiryAt(baseMs: number, durationMs: number): number | undefined { + return durationMs > 0 + ? resolveExpiresAtMsFromDurationMs(durationMs, { nowMs: baseMs }) + : undefined; +} + function resolveSessionBindingBoundBy(binding: SessionBindingRecord): string { const raw = binding.metadata?.boundBy; return normalizeOptionalString(raw) ?? ""; @@ -177,7 +187,10 @@ function resolveUpdatedBindingExpiry(params: { if (idleTimeoutMs <= 0) { return undefined; } - return Math.max(binding.lastActivityAt, binding.boundAt) + idleTimeoutMs; + return resolveSessionBindingExpiryAt( + Math.max(binding.lastActivityAt, binding.boundAt), + idleTimeoutMs, + ); } const maxAgeMs = @@ -187,7 +200,7 @@ function resolveUpdatedBindingExpiry(params: { if (maxAgeMs <= 0) { return undefined; } - return binding.boundAt + maxAgeMs; + return resolveSessionBindingExpiryAt(binding.boundAt, maxAgeMs); }) .filter((expiresAt): expiresAt is number => typeof expiresAt === "number"); @@ -537,12 +550,12 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm "idleTimeoutMs", 24 * 60 * 60 * 1000, ); - const idleExpiresAt = - idleTimeoutMs > 0 - ? resolveSessionBindingLastActivityAt(activeBinding) + idleTimeoutMs - : undefined; + const idleExpiresAt = resolveSessionBindingExpiryAt( + resolveSessionBindingLastActivityAt(activeBinding), + idleTimeoutMs, + ); const maxAgeMs = resolveSessionBindingDurationMs(activeBinding, "maxAgeMs", 0); - const maxAgeExpiresAt = maxAgeMs > 0 ? activeBinding.boundAt + maxAgeMs : undefined; + const maxAgeExpiresAt = resolveSessionBindingExpiryAt(activeBinding.boundAt, maxAgeMs); const durationArgRaw = tokens.slice(1).join(""); if (!durationArgRaw) {