fix(auto-reply): bound session lifecycle expiries

This commit is contained in:
Peter Steinberger
2026-05-30 15:36:50 -04:00
parent 0adf3220b8
commit 8ef5b5ddba
2 changed files with 43 additions and 11 deletions

View File

@@ -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 () => {

View File

@@ -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) {