Files
pikasTech-HWLAB/internal/cloud/workbench-empty-session-gc.ts
T
2026-06-19 16:54:43 +08:00

231 lines
10 KiB
TypeScript

/*
* 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<typeof setTimeout> | 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<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,
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<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 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<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);
}