Files
TencentDB-Agent-Memory/scripts/seed-v2/seed-v2.ts
T

656 lines
24 KiB
TypeScript
Raw Normal View History

2026-05-29 17:33:12 +08:00
#!/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);
});