/* * 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; export const DEFAULT_WORKBENCH_EMPTY_SESSION_GC_JITTER_MS = 15 * 1000; export const DEFAULT_WORKBENCH_EMPTY_SESSION_GC_FAILURE_BACKOFF_MS = 2 * 60 * 1000; 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 jitterMs = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_JITTER_MS, DEFAULT_WORKBENCH_EMPTY_SESSION_GC_JITTER_MS, { min: 0, max: intervalMs }); const failureBackoffMs = boundedInteger(env.HWLAB_WORKBENCH_EMPTY_SESSION_GC_FAILURE_BACKOFF_MS, DEFAULT_WORKBENCH_EMPTY_SESSION_GC_FAILURE_BACKOFF_MS, { min: intervalMs, max: 3_600_000 }); 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, jitterMs, failureBackoffMs }); } let stopped = false; let running = false; let timer: ReturnType | null = null; let nextOffset = 0; 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; const offset = nextOffset; try { const result = await runWorkbenchEmptySessionGc({ store, ttlMs, batchSize, offset, now: options.now, reason }); if (result?.ok) nextOffset = result.scanned >= batchSize ? offset + batchSize : 0; logGcResult(options.logger, result); return result; } catch (error) { const result = { ok: false, status: "failed", reason, offset, error: error instanceof Error ? error.message : String(error), valuesRedacted: true }; logGcResult(options.logger, result); return result; } finally { running = false; } }; const schedule = (delayMs: number, reason: string) => { if (stopped) return; const actualDelayMs = Math.max(0, delayMs) + randomJitterMs(jitterMs); timer = setTimeout(() => { timer = null; void tick(reason).then((result: any) => { if (stopped) return; const failed = result?.ok === false || result?.status === "failed"; schedule(failed ? failureBackoffMs : intervalMs, failed ? "failure_backoff" : "interval"); }); }, actualDelayMs); timer.unref?.(); }; schedule(initialDelayMs, "startup"); return { enabled: true, status: "running", ttlMs, intervalMs, initialDelayMs, batchSize, jitterMs, failureBackoffMs, tick, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } }; } export async function runWorkbenchEmptySessionGc({ store, ttlMs = DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS, batchSize = DEFAULT_WORKBENCH_EMPTY_SESSION_GC_BATCH_SIZE, offset = 0, 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, offset }) ?? []; const keptByReason: Record = {}; 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, offset, 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) { 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) { 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) { 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 randomJitterMs(maxJitterMs: number) { return maxJitterMs > 0 ? Math.floor(Math.random() * (maxJitterMs + 1)) : 0; } 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 { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } 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); }