fix(workbench): gc stale empty sessions

This commit is contained in:
lyon
2026-06-19 01:14:45 +08:00
parent 244844e608
commit c950709b25
7 changed files with 426 additions and 2 deletions
+5
View File
@@ -448,6 +448,11 @@ lanes:
HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: agentrun-v02
HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE: agentrun-v02
HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git
HWLAB_WORKBENCH_EMPTY_SESSION_GC_ENABLED: "1"
HWLAB_WORKBENCH_EMPTY_SESSION_TTL_MS: "600000"
HWLAB_WORKBENCH_EMPTY_SESSION_GC_INTERVAL_MS: "60000"
HWLAB_WORKBENCH_EMPTY_SESSION_GC_INITIAL_DELAY_MS: "5000"
HWLAB_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE: "100"
- serviceId: hwlab-user-billing
env:
HWLAB_USER_BILLING_DB_URL: secretRef:hwlab-cloud-api-v03-db/database-url
+5 -1
View File
@@ -68,6 +68,7 @@ import {
handleCodeAgentTraceHttp
} from "./server-code-agent-http.ts";
import { handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts";
import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
import { handleM3IoControlHttp } from "./server-m3-http.ts";
import { handleSkillsHttp } from "./server-skills-http.ts";
import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts";
@@ -115,7 +116,8 @@ export function createCloudApiServer(options = {}) {
if (typeof accessController.configureCodeAgentWorkspaceContext === "function") {
accessController.configureCodeAgentWorkspaceContext({ env, fetchImpl: options.fetchImpl, traceStore, codeAgentChatResults });
}
return createServer(async (request, response) => {
const workbenchEmptySessionGc = startWorkbenchEmptySessionGc({ env, accessController, logger: console });
const server = createServer(async (request, response) => {
const backendPerformance = backendPerformanceStore.beginHttpRequest(request, response);
await withBackendPerformanceContext(backendPerformance, async () => {
try {
@@ -133,6 +135,8 @@ export function createCloudApiServer(options = {}) {
}
});
});
server.on("close", () => workbenchEmptySessionGc.stop());
return server;
}
export function ensureCodeAgentRuntimeBase(env = process.env) {
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import {
classifyWorkbenchEmptySessionGcCandidate,
runWorkbenchEmptySessionGc
} from "./workbench-empty-session-gc.ts";
test("workbench empty session GC archives only stale empty idle sessions", async () => {
const sessions = new Map([
["ses_empty_old", session({ id: "ses_empty_old", status: "idle", updatedAt: "2026-06-18T00:00:00.000Z" })],
["ses_empty_fresh", session({ id: "ses_empty_fresh", status: "idle", updatedAt: "2026-06-18T00:15:30.000Z" })],
["ses_with_user", session({ id: "ses_with_user", status: "idle", updatedAt: "2026-06-18T00:00:00.000Z", messages: [{ role: "user", text: "keep" }] })],
["ses_running", session({ id: "ses_running", status: "running", updatedAt: "2026-06-18T00:00:00.000Z" })],
["ses_trace", session({ id: "ses_trace", status: "idle", updatedAt: "2026-06-18T00:00:00.000Z", lastTraceId: "trc_keep" })],
["ses_completed", session({ id: "ses_completed", status: "completed", updatedAt: "2026-06-18T00:00:00.000Z" })]
]);
const store = {
async listAgentSessionsForUser(input = {}) {
assert.equal(input.ownerScoped, false);
assert.equal(input.actorRole, "admin");
return [...sessions.values()].filter((item) => item.status !== "archived");
},
async archiveAgentConversation(input = {}) {
assert.equal(input.ownerScoped, false);
assert.equal(input.actorRole, "admin");
const current = sessions.get(input.sessionId);
if (!current || current.status === "archived") return { count: 0 };
sessions.set(input.sessionId, { ...current, status: "archived", endedAt: input.now, updatedAt: input.now });
return { count: 1 };
}
};
const result = await runWorkbenchEmptySessionGc({
store,
ttlMs: 10 * 60 * 1000,
now: "2026-06-18T00:20:00.000Z"
});
assert.equal(result.ok, true);
assert.deepEqual(result.archivedSessionIds, ["ses_empty_old"]);
assert.equal(sessions.get("ses_empty_old")?.status, "archived");
assert.equal(sessions.get("ses_empty_fresh")?.status, "idle");
assert.equal(sessions.get("ses_with_user")?.status, "idle");
assert.equal(sessions.get("ses_running")?.status, "running");
assert.equal(sessions.get("ses_trace")?.status, "idle");
assert.equal(sessions.get("ses_completed")?.status, "completed");
assert.equal(result.keptByReason.ttl_not_elapsed, 1);
assert.equal(result.keptByReason.has_messages, 1);
assert.equal(result.keptByReason.running_or_admitting, 1);
assert.equal(result.keptByReason.has_trace_or_terminal_evidence, 1);
assert.equal(result.keptByReason.terminal_result_status, 1);
});
test("workbench empty session GC classifier uses canonical activity timestamp", () => {
const candidate = session({
id: "ses_touched",
status: "idle",
updatedAt: "2026-06-18T00:00:00.000Z",
session: { createdAt: "2026-06-18T00:00:00.000Z", lastTouchedAt: "2026-06-18T00:19:00.000Z", messageCount: 0 }
});
assert.deepEqual(
classifyWorkbenchEmptySessionGcCandidate(candidate, { nowMs: Date.parse("2026-06-18T00:20:00.000Z"), ttlMs: 10 * 60 * 1000 }),
{ eligible: false, reason: "ttl_not_elapsed" }
);
});
function session(input: any) {
const messages = input.messages ?? [];
return {
id: input.id,
projectId: "prj_hwpod_workbench",
agentId: "hwlab-code-agent",
status: input.status,
startedAt: input.startedAt ?? input.updatedAt,
endedAt: null,
ownerUserId: "usr_gc",
conversationId: `cnv_${input.id.slice("ses_".length)}`,
threadId: null,
lastTraceId: input.lastTraceId ?? null,
updatedAt: input.updatedAt,
session: {
source: "test",
createdAt: input.startedAt ?? input.updatedAt,
updatedAt: input.updatedAt,
messageCount: messages.length,
messages,
...(input.session ?? {}),
valuesRedacted: true,
secretMaterialStored: false
}
};
}
@@ -0,0 +1,203 @@
/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-zero-repair; PJ2026-010403 API契约 draft-2026-06-18-r1.
* 职责: Workbench session GC session lifecycle mutation GET/read model/Web cleanup repair
*/
export const DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS = 10 * 60 * 1000;
export const DEFAULT_WORKBENCH_EMPTY_SESSION_GC_INTERVAL_MS = 60 * 1000;
export const DEFAULT_WORKBENCH_EMPTY_SESSION_GC_INITIAL_DELAY_MS = 5 * 1000;
export const DEFAULT_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE = 100;
const RUNNING_STATUSES = new Set(["active", "accepted", "busy", "creating", "dispatching", "pending", "queued", "running", "streaming"]);
const TERMINAL_RESULT_STATUSES = new Set(["blocked", "canceled", "cancelled", "completed", "failed", "timeout"]);
export function startWorkbenchEmptySessionGc(options: any = {}) {
const env = options.env ?? process.env;
const store = options.store ?? options.accessController?.store ?? null;
const enabled = envFlag(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_ENABLED, true);
const ttlMs = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_TTL_MS, DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS, { min: 60_000, max: 86_400_000 });
const intervalMs = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_INTERVAL_MS, DEFAULT_WORKBENCH_EMPTY_SESSION_GC_INTERVAL_MS, { min: 1_000, max: 3_600_000 });
const initialDelayMs = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_INITIAL_DELAY_MS, DEFAULT_WORKBENCH_EMPTY_SESSION_GC_INITIAL_DELAY_MS, { min: 0, max: 3_600_000 });
const batchSize = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE, DEFAULT_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE, { min: 1, max: 100 });
const canMutate = typeof store?.listAgentSessionsForUser === "function" && typeof store?.archiveAgentConversation === "function";
if (!enabled || !canMutate) {
return disabledGcHandle({ enabled, reason: enabled ? "session_store_missing_lifecycle_mutation" : "disabled", ttlMs, intervalMs, initialDelayMs, batchSize });
}
let stopped = false;
let running = false;
const tick = async (reason = "manual") => {
if (stopped) return { ok: false, status: "stopped", reason };
if (running) return { ok: true, status: "skipped", reason: "already_running" };
running = true;
try {
const result = await runWorkbenchEmptySessionGc({ store, ttlMs, batchSize, now: options.now, reason });
logGcResult(options.logger, result);
return result;
} catch (error) {
const result = { ok: false, status: "failed", reason, error: error instanceof Error ? error.message : String(error), valuesRedacted: true };
logGcResult(options.logger, result);
return result;
} finally {
running = false;
}
};
const startupTimer = setTimeout(() => { void tick("startup"); }, initialDelayMs);
startupTimer.unref?.();
const interval = setInterval(() => { void tick("interval"); }, intervalMs);
interval.unref?.();
return {
enabled: true,
status: "running",
ttlMs,
intervalMs,
initialDelayMs,
batchSize,
tick,
stop() {
stopped = true;
clearTimeout(startupTimer);
clearInterval(interval);
}
};
}
export async function runWorkbenchEmptySessionGc({ store, ttlMs = DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS, batchSize = DEFAULT_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE, now, reason = "manual" }: any = {}) {
if (typeof store?.listAgentSessionsForUser !== "function" || typeof store?.archiveAgentConversation !== "function") {
return { ok: false, status: "blocked", reason: "session_store_missing_lifecycle_mutation", ttlMs, batchSize, valuesRedacted: true };
}
const observedAt = timestampFor(now);
const nowMs = Date.parse(observedAt);
const sessions = await store.listAgentSessionsForUser({
ownerScoped: false,
actorRole: "admin",
includeArchived: false,
limit: batchSize
}) ?? [];
const keptByReason: Record<string, number> = {};
const archivedSessionIds: string[] = [];
for (const session of sessions) {
const decision = classifyWorkbenchEmptySessionGcCandidate(session, { nowMs, ttlMs });
if (!decision.eligible) {
keptByReason[decision.reason] = (keptByReason[decision.reason] ?? 0) + 1;
continue;
}
const archived = await store.archiveAgentConversation({
actorRole: "admin",
ownerScoped: false,
sessionId: session.id,
now: observedAt
}) ?? { count: 0 };
if (Number(archived.count ?? 0) > 0) archivedSessionIds.push(session.id);
}
return {
ok: true,
status: "completed",
reason,
observedAt,
cutoffAt: new Date(nowMs - ttlMs).toISOString(),
ttlMs,
batchSize,
scanned: sessions.length,
archived: archivedSessionIds.length,
archivedSessionIds,
keptByReason,
valuesRedacted: true
};
}
export function classifyWorkbenchEmptySessionGcCandidate(session: any, { nowMs = Date.now(), ttlMs = DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS } = {}) {
if (!session?.id || typeof session.id !== "string") return { eligible: false, reason: "invalid_session" };
const snapshot = objectValue(session.session);
const status = normalizeStatus(session.status ?? snapshot.sessionStatus ?? snapshot.status);
if (status === "archived") return { eligible: false, reason: "already_archived" };
if (RUNNING_STATUSES.has(status)) return { eligible: false, reason: "running_or_admitting" };
if (TERMINAL_RESULT_STATUSES.has(status)) return { eligible: false, reason: "terminal_result_status" };
if (sessionMessageCount(session, snapshot) > 0) return { eligible: false, reason: "has_messages" };
if (hasTraceOrTerminalEvidence(session, snapshot)) return { eligible: false, reason: "has_trace_or_terminal_evidence" };
const activityMs = lastActivityMs(session, snapshot);
if (!Number.isFinite(activityMs)) return { eligible: false, reason: "missing_activity_timestamp" };
if (nowMs - activityMs < ttlMs) return { eligible: false, reason: "ttl_not_elapsed" };
return { eligible: true, reason: "stale_empty_session", activityAt: new Date(activityMs).toISOString() };
}
function sessionMessageCount(session: any, snapshot: Record<string, any>) {
for (const messages of [snapshot.messages, snapshot.chatMessages, session.messages, session.chatMessages]) {
if (Array.isArray(messages) && messages.length > 0) return messages.length;
}
for (const value of [snapshot.messageCount, session.messageCount]) {
const count = Number(value);
if (Number.isFinite(count) && count > 0) return Math.trunc(count);
}
return 0;
}
function hasTraceOrTerminalEvidence(session: any, snapshot: Record<string, any>) {
if (text(session.lastTraceId) || text(snapshot.lastTraceId) || text(snapshot.currentTraceId) || text(snapshot.traceId)) return true;
if (objectHasKeys(snapshot.traceResults) || objectHasKeys(snapshot.traceResult)) return true;
return Boolean(snapshot.finalResponse || snapshot.traceSummary || snapshot.agentRun || snapshot.terminalEvidence);
}
function lastActivityMs(session: any, snapshot: Record<string, any>) {
const values = [
session.updatedAt,
snapshot.updatedAt,
snapshot.lastTouchedAt,
snapshot.lastActivityAt,
session.startedAt,
snapshot.startedAt,
snapshot.createdAt
].map((value) => Date.parse(String(value ?? ""))).filter(Number.isFinite);
return values.length > 0 ? Math.max(...values) : NaN;
}
function envFlag(value: unknown, fallback: boolean) {
const textValue = text(value).toLowerCase();
if (!textValue) return fallback;
if (["0", "false", "no", "off", "disabled"].includes(textValue)) return false;
if (["1", "true", "yes", "on", "enabled"].includes(textValue)) return true;
return fallback;
}
function boundedInteger(value: unknown, fallback: number, { min, max }: { min: number; max: number }) {
const parsed = Number.parseInt(String(value ?? ""), 10);
const candidate = Number.isInteger(parsed) ? parsed : fallback;
return Math.min(Math.max(candidate, min), max);
}
function timestampFor(now: unknown) {
const value = typeof now === "function" ? (now as () => unknown)() : now;
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString();
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value;
return new Date().toISOString();
}
function normalizeStatus(value: unknown) {
const status = text(value).toLowerCase().replace(/_/gu, "-");
return status === "cancelled" ? "canceled" : status;
}
function objectValue(value: unknown): Record<string, any> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
}
function objectHasKeys(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
}
function text(value: unknown) {
return String(value ?? "").trim();
}
function disabledGcHandle(meta: any) {
return { ...meta, status: "disabled", tick: async () => ({ ok: false, status: "disabled", ...meta }), stop() {} };
}
function logGcResult(logger: any, result: any) {
if (!logger || result?.archived <= 0 && result?.status !== "failed") return;
const message = JSON.stringify({ event: "workbench_empty_session_gc", ...result });
if (result?.status === "failed" && typeof logger.error === "function") logger.error(message);
else if (typeof logger.info === "function") logger.info(message);
}
+2 -1
View File
@@ -49,6 +49,7 @@ export const checkProfiles = Object.freeze({
{ id: "check-029-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/code-agent-chat.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-030-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/m3-io-control.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-030a-cloud-api-access-control", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/access-control.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-030b-cloud-api-workbench-empty-session-gc", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/workbench-empty-session-gc.ts","internal/cloud/workbench-empty-session-gc.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-031-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/server.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-032-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/server-test-helpers.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-033-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/code-agent-trace-store.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
@@ -143,7 +144,7 @@ export const checkProfiles = Object.freeze({
{ id: "check-143-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/code-agent-chat-smoke.mjs"] },
{ id: "check-144-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/cloud-api-runtime-smoke.mjs"] },
{ id: "check-145-repo-bootstrap-skills-sh", group: "repo", command: ["sh","-n","scripts/bootstrap-skills.sh","scripts/worker-entrypoint.sh"] },
{ id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/cloud/web-performance.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] }
{ id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/workbench-empty-session-gc.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/cloud/web-performance.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] }
])
});
@@ -6,6 +6,11 @@ import { readFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { extname, join, resolve, sep } from "node:path";
import {
DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS,
classifyWorkbenchEmptySessionGcCandidate
} from "../../../internal/cloud/workbench-empty-session-gc.ts";
type JsonRecord = Record<string, unknown>;
interface SessionRecord extends JsonRecord {
@@ -78,6 +83,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
state = createScenarioState(String(body.scenarioId ?? "baseline"));
return json(response, 200, stateSummary());
}
if (path === "/__e2e/gc-empty-sessions" && method === "POST") {
const body = await readJson(request);
return json(response, 200, gcEmptySessions(body));
}
if (path === "/auth/session" || path === "/auth/bootstrap") {
const payload = authPayload();
@@ -268,6 +277,7 @@ function createScenarioState(scenarioId: string): ScenarioState {
}
if (id === "deleted-session-deeplink") sessions.unshift(archivedDeletedSession());
if (id === "session-switch-empty-reload") sessions.push(emptySession());
if (id === "empty-session-gc") sessions.unshift(staleEmptyTtlSession(), freshEmptyTtlSession(), staleMessagedTtlSession());
const selectedSessionId = id === "deep-link" || id === "stale-nested-trace"
? "ses_failed"
: id === "legacy-cnv-deeplink-canonical"
@@ -394,6 +404,33 @@ function emptySession(): SessionRecord {
return { sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", messages: [] };
}
function staleEmptyTtlSession(): SessionRecord {
const at = "2026-06-18T16:00:00.000Z";
return { sessionId: "ses_empty_ttl", threadId: "thr_empty_ttl", status: "idle", startedAt: at, updatedAt: at, messageCount: 0, firstUserMessagePreview: null, messages: [] };
}
function freshEmptyTtlSession(): SessionRecord {
const at = "2026-06-18T16:15:30.000Z";
return { sessionId: "ses_empty_fresh", threadId: "thr_empty_fresh", status: "idle", startedAt: at, updatedAt: at, messageCount: 0, firstUserMessagePreview: null, messages: [] };
}
function staleMessagedTtlSession(): SessionRecord {
const at = "2026-06-18T15:50:00.000Z";
return {
sessionId: "ses_empty_ttl_has_message",
threadId: "thr_empty_ttl_has_message",
status: "completed",
startedAt: at,
updatedAt: at,
messageCount: 2,
firstUserMessagePreview: "超过 TTL 但已有消息,不能被 GC",
messages: [
{ id: "msg_empty_ttl_user", role: "user", title: "用户", text: "超过 TTL 但已有消息,不能被 GC", status: "sent", createdAt: at, sessionId: "ses_empty_ttl_has_message", threadId: "thr_empty_ttl_has_message" },
{ id: "msg_empty_ttl_agent", role: "agent", title: "Code Agent", text: "保留已有消息 session。", status: "completed", createdAt: at, sessionId: "ses_empty_ttl_has_message", threadId: "thr_empty_ttl_has_message" }
]
};
}
function archivedDeletedSession(): SessionRecord {
const now = new Date().toISOString();
return { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已删除 session", messages: [] };
@@ -687,6 +724,44 @@ function stateSummary(): JsonRecord {
return { scenarioId: state.scenarioId, selectedSessionId: state.selectedSessionId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, chatRequests: state.chatRequests, staleTraceId: state.staleTraceId, sessions: state.sessions.map((item) => ({ sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId, hidden: item.hidden === true })) };
}
function gcEmptySessions(body: JsonRecord): JsonRecord {
const ttlMs = positiveInteger(body.ttlMs, DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS);
const nowMs = Date.parse(typeof body.now === "string" ? body.now : "2026-06-18T16:20:30.000Z");
const archivedSessionIds: string[] = [];
const keptByReason: Record<string, number> = {};
for (const session of state.sessions) {
if (session.status === "archived") continue;
const decision = classifyWorkbenchEmptySessionGcCandidate(toBackendSessionRecord(session), { nowMs, ttlMs });
if (!decision.eligible) {
keptByReason[decision.reason] = (keptByReason[decision.reason] ?? 0) + 1;
continue;
}
session.status = "archived";
session.updatedAt = new Date(nowMs).toISOString();
archivedSessionIds.push(session.sessionId);
}
return { ok: true, status: "completed", ttlMs, archived: archivedSessionIds.length, archivedSessionIds, keptByReason, valuesRedacted: true };
}
function toBackendSessionRecord(session: SessionRecord): JsonRecord {
return {
id: session.sessionId,
status: session.status,
startedAt: session.startedAt,
updatedAt: session.updatedAt,
lastTraceId: session.lastTraceId,
session: {
createdAt: session.startedAt,
updatedAt: session.updatedAt,
messageCount: session.messageCount,
messages: session.messages ?? [],
lastTraceId: session.lastTraceId,
valuesRedacted: true,
secretMaterialStored: false
}
};
}
function recordLedger(method: string, url: URL): void {
if (!url.pathname.startsWith("/v1/")) return;
const entry = { method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()) };
@@ -763,3 +838,8 @@ function contentType(filePath: string): string {
function delay(ms: number): Promise<void> {
return ms > 0 ? new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) : Promise.resolve();
}
function positiveInteger(value: unknown, fallback: number): number {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
@@ -0,0 +1,37 @@
import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test.use({ scenarioId: "empty-session-gc" });
test("server-side empty session GC archives only stale empty sessions", async ({ page }, testInfo) => {
await gotoWorkbench(page);
await expect(page.locator(sessionTab("ses_empty_ttl"))).toHaveCount(1);
await expect(page.locator(sessionTab("ses_empty_fresh"))).toHaveCount(1);
await expect(page.locator(sessionTab("ses_empty_ttl_has_message"))).toHaveCount(1);
await saveScreenshot(page, testInfo, "empty-session-gc-before");
const gc = await page.request.post("/__e2e/gc-empty-sessions", {
data: { ttlMs: 600_000, now: "2026-06-18T16:20:30.000Z" }
});
expect(gc.ok()).toBeTruthy();
const gcBody = await gc.json() as { archivedSessionIds?: string[]; keptByReason?: Record<string, number> };
expect(gcBody.archivedSessionIds).toEqual(["ses_empty_ttl"]);
expect(gcBody.keptByReason?.ttl_not_elapsed).toBeGreaterThanOrEqual(1);
expect(gcBody.keptByReason?.terminal_result_status).toBeGreaterThanOrEqual(1);
await page.reload();
await expect(page.locator(sessionTab("ses_empty_ttl"))).toHaveCount(0);
await expect(page.locator(sessionTab("ses_empty_fresh"))).toHaveCount(1);
await expect(page.locator(sessionTab("ses_empty_ttl_has_message"))).toHaveCount(1);
await saveScreenshot(page, testInfo, "empty-session-gc-after-rail");
const state = await fakeServerState(page);
const archived = (state.sessions as Array<{ sessionId?: string; status?: string }>).find((item) => item.sessionId === "ses_empty_ttl");
expect(archived?.status).toBe("archived");
await gotoWorkbench(page, "/workbench/sessions/ses_empty_ttl");
await expect(page.locator(sessionTab("ses_empty_ttl"))).toHaveCount(0);
await expect(page.locator(".conversation-error-hint")).toContainText(/archived||session/u);
await expect(page.locator(selectors.commandSend)).toBeDisabled();
await saveScreenshot(page, testInfo, "empty-session-gc-deeplink");
});