Files
TencentDB-Agent-Memory/scripts/seed-v2/seed-v2.ts
T
2026-05-29 17:51:28 +08:00

656 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env npx tsx
/**
* seed-v2 — 通过 v2 API 把历史对话灌入 memory-tencentdb gateway。
*
* 与老 `src/cli/commands/seed.ts`v1import 核心 runtime 直跑)相比:
* - 纯 HTTP 客户端,不 import 任何 plugin 内部模块
* - 通过 `/v2/conversation/add` 写 L0 + 让 gateway 自动调度 L1/L2/L3
* - 通过轮询 `/v2/pipeline/status` 的 `busy` 标量做节流
* - 既适用于 standalone,也适用于 service 模式(同一份代码)
*
* 阻塞节奏(对齐老 `seed-runtime.executeSeed`:
* - per-round 写入 conversation/add 后立即继续
* - 每 N 轮(--every-n / SEED_EVERY_N)等一次 busy=falsestable polls
* - 每个 session 末尾再等一次
* - 全部跑完后做 final drain
*
* @example
* # 用 npm script 入口
* npm run seed-v2 -- --input ./fixtures/minimal.json
*
* # 或直接 tsx
* npx tsx scripts/seed-v2/seed-v2.ts --input fixture.json --endpoint http://127.0.0.1:18420
*
* # 关键参数
* --input <file> 必选,fixture JSON 路径
* --endpoint <url> gateway 地址(默认 http://127.0.0.1:18420
* --service-id <id> x-tdai-service-id(默认 default
* --api-key <key> Bearer key(默认 standalone-e2e
* --every-n <n> 每 N 轮 wait 一次 busy=false(默认 5
* --max-wait-ms <ms> 单次 wait 最长时间(默认 600000=10 分钟)
* --no-final-wait 写完最后一批不等 cascade,立即退出
* --dry-run 只打印计划不实际请求
* --quiet 静默模式
*/
import { readFileSync, existsSync } from "node:fs";
import { resolve as resolvePath } from "node:path";
import { parseArgs } from "node:util";
import dayjs from "dayjs";
import { validateAndNormalizeRaw, fillTimestamps, SeedValidationError } from "./input-validate.js";
import type { NormalizedInput } from "./input-types.js";
// ============================
// Logger (timestamped console wrappers)
// ============================
/**
* Full ISO 8601 local-timezone timestamp, e.g. "2026-05-21T15:03:00.123+08:00".
* Aligned with the standalone gateway's console logger so seed-v2 lines and
* gateway lines can be merged/sorted by timestamp during incident review.
*/
function ts(): string {
return dayjs().format("YYYY-MM-DDTHH:mm:ss.SSSZ");
}
const log = (msg: string): void => { console.log(`${ts()} ${msg}`); };
const warn = (msg: string): void => { console.warn(`${ts()} ${msg}`); };
const err = (msg: string): void => { console.error(`${ts()} ${msg}`); };
// Truncate long content to a single-line preview safe for logs.
function preview(s: string, max = 80): string {
const oneLine = s.replace(/\s+/g, " ").trim();
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max)}...`;
}
// ============================
// CLI args + env fallback
// ============================
interface CliOptions {
input: string;
endpoint: string;
apiKey: string;
serviceId: string;
everyN: number;
pollMs: number;
stableRounds: number;
maxWaitMs: number; // per every-N / per-session-tail wait timeout (L1 only)
finalMaxWaitMs: number; // final all-layer drain wait timeout (L1+L2+L3)
finalWait: boolean;
dryRun: boolean;
quiet: boolean;
// Input validation / normalization options (parity with old seed CLI)
sessionKey?: string; // fallback for sessions missing sessionKey
strictRoundRole: boolean; // each round must have user + assistant
autoFillTimestamps: boolean; // when all-missing, fill globally-monotonic ts
}
function parseCli(argv: string[]): CliOptions {
const { values } = parseArgs({
args: argv,
options: {
input: { type: "string", short: "i" },
endpoint: { type: "string", short: "e" },
"api-key": { type: "string" },
"service-id": { type: "string", short: "s" },
"every-n": { type: "string", short: "n" },
"poll-ms": { type: "string" },
"stable-rounds": { type: "string" },
"max-wait-ms": { type: "string" },
"final-max-wait-ms": { type: "string" },
"no-final-wait": { type: "boolean" },
"dry-run": { type: "boolean" },
"session-key": { type: "string" },
"strict-round-role": { type: "boolean" },
"no-auto-fill-timestamps": { type: "boolean" },
quiet: { type: "boolean", short: "q" },
help: { type: "boolean", short: "h" },
},
allowPositionals: false,
strict: false,
});
if (values.help) {
printHelp();
process.exit(0);
}
const input = (values.input as string | undefined) ?? process.argv[2];
if (!input) {
console.error("error: --input <fixture.json> is required");
printHelp();
process.exit(2);
}
const intOpt = (key: string, env: string, def: number): number => {
const raw = (values[key] as string | undefined) ?? process.env[env];
if (raw == null || raw === "") return def;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : def;
};
return {
input,
endpoint: (values.endpoint as string) ?? process.env.SEED_ENDPOINT ?? "http://127.0.0.1:18420",
apiKey: (values["api-key"] as string) ?? process.env.SEED_API_KEY ?? "standalone-e2e",
serviceId: (values["service-id"] as string) ?? process.env.SEED_SERVICE_ID ?? "default",
everyN: intOpt("every-n", "SEED_EVERY_N", 5),
pollMs: intOpt("poll-ms", "SEED_POLL_MS", 500),
stableRounds: intOpt("stable-rounds", "SEED_STABLE_ROUNDS", 2),
maxWaitMs: intOpt("max-wait-ms", "SEED_MAX_WAIT_MS", 180_000),
finalMaxWaitMs: intOpt("final-max-wait-ms", "SEED_FINAL_MAX_WAIT_MS", 600_000),
finalWait: !values["no-final-wait"],
dryRun: Boolean(values["dry-run"]),
quiet: Boolean(values.quiet) || process.env.SEED_VERBOSE === "0",
sessionKey: (values["session-key"] as string | undefined) ?? process.env.SEED_FALLBACK_SESSION_KEY,
strictRoundRole: Boolean(values["strict-round-role"]) || process.env.SEED_STRICT_ROUND_ROLE === "1",
autoFillTimestamps: !values["no-auto-fill-timestamps"] && process.env.SEED_AUTO_FILL_TIMESTAMPS !== "0",
};
}
function printHelp(): void {
console.log(`
seed-v2 — feed historical conversations into memory-tencentdb via v2 API
Usage:
seed-v2 --input <fixture.json> [options]
Required:
-i, --input <file> Fixture JSON file (Format A: { sessions: [...] })
Options:
-e, --endpoint <url> Gateway URL (default: http://127.0.0.1:18420)
--api-key <key> Bearer key (default: standalone-e2e)
-s, --service-id <id> x-tdai-service-id (default: default)
-n, --every-n <n> Wait for busy=false every N rounds per session (default: 5)
--poll-ms <ms> Status poll interval (default: 500)
--stable-rounds <n> Consecutive idle polls before considered stable (default: 2)
--max-wait-ms <ms> Per-batch wait timeout, L1 only (default: 180000 = 3 min)
--final-max-wait-ms <ms> Final drain wait timeout, ALL layers L1+L2+L3 (default: 600000 = 10 min)
--no-final-wait Skip final cascade drain wait
--dry-run Print plan, no requests
--session-key <key> Fallback sessionKey for sessions missing one
--strict-round-role Each round must contain user + assistant
--no-auto-fill-timestamps Don't auto-fill missing timestamps (will error if all missing)
-q, --quiet Quiet mode (env SEED_VERBOSE=0)
-h, --help Show this help
Fixture JSON (Format A):
{
"sessions": [
{
"sessionKey": "user-001",
"sessionId": "user-001",
"conversations": [
[
{ "role": "user", "content": "..." },
{ "role": "assistant", "content": "..." }
]
]
}
]
}
Exit codes:
0 success
1 seed or wait failed
2 fixture / config error
`.trim());
}
// ============================
// HTTP helper
// ============================
interface ApiResponse<T = unknown> {
code: number;
message: string;
request_id: string;
data?: T;
}
interface ApiCallResult<T = unknown> {
body: ApiResponse<T>;
httpStatus: number;
durationMs: number;
}
class SeedClient {
private callSeq = 0;
constructor(private opts: CliOptions) {}
async post<T = unknown>(path: string, body: unknown): Promise<ApiCallResult<T>> {
this.callSeq++;
const url = `${this.opts.endpoint}/v2/${path}`;
const startedAt = Date.now();
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.opts.apiKey}`,
"x-tdai-service-id": this.opts.serviceId,
},
body: JSON.stringify(body),
});
const durationMs = Date.now() - startedAt;
const text = await resp.text();
let parsed: ApiResponse<T>;
try {
parsed = JSON.parse(text) as ApiResponse<T>;
} catch {
throw new Error(`non-JSON response from /v2/${path}: ${resp.status} ${text.slice(0, 200)}`);
}
return { body: parsed, httpStatus: resp.status, durationMs };
}
}
// ============================
// Fixture loading + validation (delegates to input-validate.ts)
// ============================
/**
* Load fixture from disk → parse JSON → validate → normalize. Returns the
* NormalizedInput shape used downstream (sessions[].rounds[].messages[]),
* with timestamps either preserved, mixed (rejected), or auto-filled to
* globally-monotonic epoch ms (parity with v1 fillTimestamps).
*
* On validation failure throws SeedValidationError; caller should print and exit 2.
*/
function loadAndValidate(opts: CliOptions): NormalizedInput {
const abs = resolvePath(opts.input);
if (!existsSync(abs)) {
throw new SeedValidationError([{ stage: "file", message: `Fixture not found: ${abs}` }]);
}
const text = readFileSync(abs, "utf8").trim();
if (!text) {
throw new SeedValidationError([{ stage: "file", message: `Fixture is empty: ${abs}` }]);
}
let raw: unknown;
try {
raw = JSON.parse(text);
} catch (err) {
throw new SeedValidationError([{
stage: "file",
message: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`,
}]);
}
return validateAndNormalizeRaw(raw, {
sessionKey: opts.sessionKey,
strictRoundRole: opts.strictRoundRole,
autoFillTimestamps: opts.autoFillTimestamps,
});
}
// ============================
// Status polling
// ============================
interface LayerStatus {
queued: number;
running: number;
queued_sessions: string[];
running_sessions: string[];
idle: boolean;
}
interface StatusData {
l1: LayerStatus;
l2: LayerStatus;
l3: LayerStatus;
}
async function pollStatus(client: SeedClient): Promise<StatusData> {
const r = await client.post<StatusData>("pipeline/status", {});
if (r.body.code !== 0 || !r.body.data) {
throw new Error(`/v2/pipeline/status failed: HTTP ${r.httpStatus} code=${r.body.code} msg=${r.body.message}`);
}
return r.body.data;
}
/**
* Wait until the requested layers are idle for `stableRounds` consecutive polls.
*
* @param waitMode "l1" → only wait for L1 to drain (mid-batch / per-session);
* L2/L3 cascade may still run in background.
* "all" → wait for L1+L2+L3 all idle (final drain after all
* rounds dispatched).
*
* On timeout: log a `[wait] done reason=timeout` line and return (do not
* throw). Data is already on disk and the gateway keeps cooking; callers
* decide whether the next batch can proceed.
*
* Logging:
* - `[wait] enter` once at the start (label, mode, budget).
* - `[poll]` per status snapshot, throttled: first poll, OR queue/running
* signature changed, OR 5s heartbeat. Long stable waits stay quiet,
* stalls keep showing progress.
* - `[wait] done` once at exit with reason=stable|timeout, elapsed, polls.
*/
async function waitForBusyFalse(
client: SeedClient,
opts: CliOptions,
label: string,
waitMode: "l1" | "all",
maxWaitMs: number,
): Promise<void> {
const startTime = Date.now();
let consecutiveIdle = 0;
let lastSnapshot: StatusData | null = null;
let lastLogAtMs = 0;
let pollCount = 0;
const HEARTBEAT_INTERVAL_MS = 5000;
log(`[seed-v2] [wait] enter label="${label}" mode=${waitMode} maxWaitMs=${maxWaitMs} stableRounds=${opts.stableRounds} pollMs=${opts.pollMs}`);
// Format a compact one-line snapshot for logging.
const fmt = (s: StatusData): string => {
const layer = (name: string, l: LayerStatus) => {
const extras: string[] = [];
if (l.running_sessions.length > 0) extras.push(`running=${JSON.stringify(l.running_sessions)}`);
if (l.queued_sessions.length > 0) extras.push(`queued=${JSON.stringify(l.queued_sessions)}`);
const tail = extras.length > 0 ? ` ${extras.join(" ")}` : "";
return `${name}(q=${l.queued} r=${l.running} idle=${l.idle}${tail})`;
};
return [layer("L1", s.l1), layer("L2", s.l2), layer("L3", s.l3)].join(" ");
};
// Stable signature for change-detection (only fields that matter for idle).
const sig = (s: StatusData): string =>
`${s.l1.queued}/${s.l1.running}|${s.l2.queued}/${s.l2.running}|${s.l3.queued}/${s.l3.running}`;
let lastSig = "";
while (true) {
const elapsed = Date.now() - startTime;
if (elapsed > maxWaitMs) {
const last = lastSnapshot ? fmt(lastSnapshot) : "no-snapshot";
warn(
`[seed-v2] [wait] done label="${label}" mode=${waitMode} elapsed=${(elapsed / 1000).toFixed(1)}s ` +
`polls=${pollCount} reason=timeout last=${last}`,
);
return;
}
const s = await pollStatus(client);
pollCount++;
lastSnapshot = s;
const isIdle = waitMode === "l1"
? s.l1.idle
: (s.l1.idle && s.l2.idle && s.l3.idle);
if (isIdle) consecutiveIdle++;
else consecutiveIdle = 0;
// Decide whether to log this poll.
const now = Date.now();
const currentSig = sig(s);
const changed = currentSig !== lastSig;
const heartbeat = now - lastLogAtMs >= HEARTBEAT_INTERVAL_MS;
const isFirstPoll = lastLogAtMs === 0;
const willBeStable = isIdle && consecutiveIdle >= opts.stableRounds;
if (!opts.quiet && (isFirstPoll || changed || heartbeat || willBeStable)) {
const mark = isIdle ? "idle" : "busy";
const stable = willBeStable ? " ✓ STABLE" : "";
log(
`[seed-v2] [poll] ${label} t+${(elapsed / 1000).toFixed(1)}s ${mark} ${fmt(s)} ` +
`consecutiveIdle=${consecutiveIdle}${stable}`,
);
lastLogAtMs = now;
lastSig = currentSig;
}
if (willBeStable) {
log(
`[seed-v2] [wait] done label="${label}" mode=${waitMode} elapsed=${(elapsed / 1000).toFixed(1)}s ` +
`polls=${pollCount} reason=stable`,
);
return;
}
await new Promise((r) => setTimeout(r, opts.pollMs));
}
}
// ============================
// Conversation/add per round
// ============================
interface AddRoundContext {
sessionIndex: number; // 1-based among non-empty sessions
totalSessions: number;
sessionKey: string;
sessionId: string;
roundIndex: number; // 1-based within this session
totalRoundsInSession: number;
cumRounds: number; // 1-based across whole fixture
totalRounds: number;
cumMessagesAfter: number; // includes this round (post-success)
totalMessages: number;
}
async function addRound(
client: SeedClient,
ctx: AddRoundContext,
messages: { role: string; content: string; timestamp: number }[],
quiet: boolean,
): Promise<number> {
// After validateAndNormalizeRaw + fillTimestamps, every message has a positive
// epoch-ms `timestamp` (or 0 if user explicitly opted out via
// --no-auto-fill-timestamps). v2 conversation/add schema requires ISO 8601 string.
const payload = messages.map((m) => ({
role: m.role,
content: m.content,
timestamp: new Date(m.timestamp || Date.now()).toISOString(),
}));
const roles = payload.map((p) => p.role).join(",");
const firstTs = payload[0]?.timestamp ?? "n/a";
const lastTs = payload[payload.length - 1]?.timestamp ?? "n/a";
if (!quiet) {
log(
`[seed-v2] [post] session ${ctx.sessionIndex}/${ctx.totalSessions} round ${ctx.roundIndex}/${ctx.totalRoundsInSession} ` +
`(${ctx.sessionKey}) → POST /v2/conversation/add session_id="${ctx.sessionId}" ` +
`msgs=${payload.length} roles=[${roles}] firstTs=${firstTs} lastTs=${lastTs}`,
);
for (let i = 0; i < payload.length; i++) {
const m = payload[i]!;
log(`[seed-v2] [post] ${m.role}[${i}]: "${preview(m.content)}"`);
}
}
const r = await client.post<{ accepted_ids: string[]; total_count: number }>("conversation/add", {
session_id: ctx.sessionId,
messages: payload,
});
const ok = r.body.code === 0 && r.body.data;
if (!ok) {
err(
`[seed-v2] [post] session ${ctx.sessionIndex}/${ctx.totalSessions} round ${ctx.roundIndex}/${ctx.totalRoundsInSession} ` +
`(${ctx.sessionKey}) ✗ HTTP ${r.httpStatus} code=${r.body.code} msg="${r.body.message}" in ${r.durationMs}ms`,
);
throw new Error(`/v2/conversation/add failed for ${ctx.sessionId}: HTTP ${r.httpStatus} code=${r.body.code} msg=${r.body.message}`);
}
const accepted = r.body.data!.accepted_ids.length;
if (!quiet) {
log(
`[seed-v2] [post] session ${ctx.sessionIndex}/${ctx.totalSessions} round ${ctx.roundIndex}/${ctx.totalRoundsInSession} ` +
`(${ctx.sessionKey}) ✓ HTTP ${r.httpStatus} code=0 accepted=${accepted} in ${r.durationMs}ms ` +
`(cum: rounds=${ctx.cumRounds}/${ctx.totalRounds} msgs=${ctx.cumMessagesAfter}/${ctx.totalMessages})`,
);
}
return accepted;
}
// ============================
// Main
// ============================
async function main(): Promise<void> {
const opts = parseCli(process.argv.slice(2));
const client = new SeedClient(opts);
log(
`[seed-v2] endpoint=${opts.endpoint} serviceId=${opts.serviceId} apiKey=${opts.apiKey ? "***" : "(none)"}`,
);
log(
`[seed-v2] cadence: everyN=${opts.everyN} stableRounds=${opts.stableRounds} pollMs=${opts.pollMs} ` +
`maxWaitMs=${opts.maxWaitMs}(L1-only) finalMaxWaitMs=${opts.finalMaxWaitMs}(all-layers) finalWait=${opts.finalWait}`,
);
// Load + validate + normalize fixture (Layer 2-6 of v1 seed: format detect /
// session+round+message validation / timestamp consistency check / auto-fill).
let normalized: NormalizedInput;
try {
normalized = loadAndValidate(opts);
} catch (e) {
if (e instanceof SeedValidationError) {
err(`[seed-v2] ${e.message}`);
} else {
err(`[seed-v2] fixture load failed: ${e instanceof Error ? e.message : String(e)}`);
}
process.exit(2);
}
const sessions = normalized.sessions;
const { totalRounds, totalMessages, hasTimestamps } = normalized;
log(
`[seed-v2] fixture=${opts.input} sessions=${sessions.length} rounds=${totalRounds} ` +
`messages=${totalMessages} hasTimestamps=${hasTimestamps}` +
(!hasTimestamps && opts.autoFillTimestamps ? " (auto-filled)" : "") +
` autoFill=${opts.autoFillTimestamps} strictRoundRole=${opts.strictRoundRole}`,
);
// Session manifest preview (first 3 + count).
const previewSessions = sessions.slice(0, 3)
.map((s) => `${s.sessionKey}(${s.rounds.length}r)`).join(" ");
const moreSessions = sessions.length > 3 ? ` ... (+${sessions.length - 3} more)` : "";
log(`[seed-v2] sessions: ${previewSessions}${moreSessions}`);
if (opts.dryRun) {
log("[seed-v2] DRY RUN — exiting without making any requests");
for (const s of sessions) {
log(`[seed-v2] would add: sessionKey=${s.sessionKey} sessionId=${s.sessionId} rounds=${s.rounds.length}`);
}
process.exit(0);
}
// Pre-flight: status接口 must respond
let preflight: StatusData;
try {
preflight = await pollStatus(client);
} catch (e) {
err(`[seed-v2] pre-flight /v2/pipeline/status failed: ${(e as Error).message}`);
err(`[seed-v2] gateway up at ${opts.endpoint}? deployMode=standalone? stateBackend configured?`);
process.exit(2);
}
log(
`[seed-v2] pre-flight ok l1.idle=${preflight.l1.idle} ` +
`(l1 q=${preflight.l1.queued} r=${preflight.l1.running}, ` +
`l2 q=${preflight.l2.queued} r=${preflight.l2.running}, ` +
`l3 q=${preflight.l3.queued} r=${preflight.l3.running})`,
);
// Per-session, per-round seeding (timestamps now come from normalized input,
// not synthesized per-call — global monotonic ordering already guaranteed
// by validateAndNormalizeRaw + fillTimestamps).
const startMs = Date.now();
let roundsDone = 0;
let messagesDone = 0;
for (let si = 0; si < sessions.length; si++) {
const session = sessions[si]!;
const sessionKey = session.sessionKey;
const sessionId = session.sessionId; // always present after normalization
if (session.rounds.length === 0) {
warn(`[seed-v2] session ${si + 1}/${sessions.length} key="${sessionKey}" has no rounds, skipping`);
continue;
}
const sessionStartMs = Date.now();
let sessionMsgs = 0;
log(
`[seed-v2] session ${si + 1}/${sessions.length} START key="${sessionKey}" id="${sessionId}" ` +
`rounds=${session.rounds.length}`,
);
for (let ri = 0; ri < session.rounds.length; ri++) {
const round = session.rounds[ri]!;
if (round.messages.length === 0) continue;
const accepted = await addRound(
client,
{
sessionIndex: si + 1,
totalSessions: sessions.length,
sessionKey,
sessionId,
roundIndex: ri + 1,
totalRoundsInSession: session.rounds.length,
cumRounds: roundsDone + 1,
totalRounds,
cumMessagesAfter: messagesDone + round.messages.length,
totalMessages,
},
round.messages,
opts.quiet,
);
roundsDone++;
messagesDone += accepted;
sessionMsgs += accepted;
// Old seed-runtime parity: every N rounds in this session, wait for L1 idle.
const roundInSession = ri + 1;
if (roundInSession % opts.everyN === 0) {
await waitForBusyFalse(client, opts, `every-${opts.everyN}@${sessionKey}#${roundInSession}`, "l1", opts.maxWaitMs);
}
}
// Per-session tail wait (matches old seed-runtime per-session waitForL1Idle).
await waitForBusyFalse(client, opts, `session-tail@${sessionKey}`, "l1", opts.maxWaitMs);
const sessionDur = (Date.now() - sessionStartMs) / 1000;
log(
`[seed-v2] session ${si + 1}/${sessions.length} END key="${sessionKey}" ` +
`rounds=${session.rounds.length} msgs=${sessionMsgs} duration=${sessionDur.toFixed(1)}s`,
);
}
// Final wait: drain any cascade L2/L3 work (best-effort).
if (opts.finalWait) {
log(
`[seed-v2] all rounds dispatched, final drain — waiting for L1+L2+L3 all idle ` +
`(max ${(opts.finalMaxWaitMs / 1000).toFixed(0)}s)...`,
);
await waitForBusyFalse(client, opts, "final", "all", opts.finalMaxWaitMs);
} else {
log(`[seed-v2] all rounds dispatched, --no-final-wait set, exiting immediately`);
}
const durationMs = Date.now() - startMs;
log(
`[seed-v2] done sessions=${sessions.length} rounds=${roundsDone}/${totalRounds} ` +
`msgs=${messagesDone}/${totalMessages} duration=${(durationMs / 1000).toFixed(1)}s`,
);
// Final status snapshot for downstream verification.
const final = await pollStatus(client);
const allIdle = final.l1.idle && final.l2.idle && final.l3.idle;
log(
`[seed-v2] final status: l1.idle=${final.l1.idle} l2.idle=${final.l2.idle} l3.idle=${final.l3.idle} ` +
`(l1 q=${final.l1.queued} r=${final.l1.running}, ` +
`l2 q=${final.l2.queued} r=${final.l2.running}, ` +
`l3 q=${final.l3.queued} r=${final.l3.running})` +
(allIdle ? " ✓ all clean" : " ⚠ residual work — gateway will keep cooking after exit"),
);
}
main().catch((e) => {
err(`[seed-v2] FATAL: ${e instanceof Error ? e.stack : String(e)}`);
process.exit(1);
});