231 lines
10 KiB
TypeScript
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);
|
|
}
|