fix(outbound): bound current conversation expiry

This commit is contained in:
Peter Steinberger
2026-05-30 12:27:26 -04:00
parent 4f0e3cb621
commit 02ca283716
2 changed files with 88 additions and 6 deletions
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
@@ -76,6 +76,7 @@ describe("generic current-conversation bindings", () => {
});
afterEach(async () => {
vi.useRealTimers();
testing.resetCurrentConversationBindingsForTests({
deletePersistedFile: true,
});
@@ -324,6 +325,64 @@ describe("generic current-conversation bindings", () => {
).toBeNull();
});
it("drops persisted bindings with invalid expiration timestamps", async () => {
const filePath = testing.resolveBindingsFilePath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify({
version: 1,
bindings: [
{
bindingId: "generic:workspace\u241fdefault\u241f\u241fuser:U123",
targetSessionKey: "agent:codex:acp:workspace-dm",
targetKind: "session",
conversation: {
channel: "workspace",
accountId: "default",
conversationId: "user:U123",
},
status: "active",
boundAt: 1234,
expiresAt: 8_640_000_000_000_001,
},
],
}),
);
expect(
resolveGenericCurrentConversationBinding({
channel: "workspace",
accountId: "default",
conversationId: "user:U123",
}),
).toBeNull();
});
it("does not bind generic current conversations when ttl expiry overflows", async () => {
vi.setSystemTime(new Date(8_640_000_000_000_000));
await expect(
bindGenericCurrentConversation({
targetSessionKey: "agent:codex:acp:workspace-dm",
targetKind: "session",
conversation: {
channel: "workspace",
accountId: "default",
conversationId: "user:U123",
},
ttlMs: 1,
}),
).resolves.toBeNull();
expect(
resolveGenericCurrentConversationBinding({
channel: "workspace",
accountId: "default",
conversationId: "user:U123",
}),
).toBeNull();
});
it("persists touched activity across reloads", async () => {
const bound = await bindGenericCurrentConversation({
targetSessionKey: "agent:codex:acp:workspace-dm",
@@ -6,6 +6,10 @@ import { resolveStateDir } from "../../config/paths.js";
import { loadJsonFile } from "../../infra/json-file.js";
import { saveJsonFile } from "../../plugin-sdk/json-store.js";
import { getActivePluginChannelRegistryFromState } from "../../plugins/runtime-channel-state.js";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "../../shared/number-coercion.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import { normalizeConversationRef } from "./session-binding-normalization.js";
import type {
@@ -46,9 +50,15 @@ function resolveBindingsFilePath(env: NodeJS.ProcessEnv = process.env): string {
}
function isBindingExpired(record: SessionBindingRecord, now = Date.now()): boolean {
return typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)
? record.expiresAt <= now
: false;
if (record.expiresAt === undefined) {
return false;
}
const expiresAt = asDateTimestampMs(record.expiresAt);
if (expiresAt === undefined) {
return true;
}
const nowMs = asDateTimestampMs(now);
return nowMs !== undefined && expiresAt <= nowMs;
}
function toPersistedFile(): PersistedCurrentConversationBindingsFile {
@@ -159,11 +169,24 @@ export async function bindGenericCurrentConversation(
return null;
}
loadBindingsIntoMemory();
const now = Date.now();
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
if (now === undefined) {
return null;
}
const ttlMs =
typeof input.ttlMs === "number" && Number.isFinite(input.ttlMs)
? Math.max(0, Math.floor(input.ttlMs))
: undefined;
const expiresAt =
ttlMs === undefined
? undefined
: ttlMs === 0
? now
: resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNow });
if (ttlMs !== undefined && expiresAt === undefined) {
return null;
}
const key = buildConversationKey(conversation);
const existing = pruneExpiredBinding(key);
const record: SessionBindingRecord = {
@@ -173,7 +196,7 @@ export async function bindGenericCurrentConversation(
conversation,
status: "active",
boundAt: now,
...(ttlMs != null ? { expiresAt: now + ttlMs } : {}),
...(expiresAt !== undefined ? { expiresAt } : {}),
metadata: {
...existing?.metadata,
...input.metadata,