@kontextmind/kxm 0.7.77 → 0.7.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,10 +27,11 @@ import { createKxmOneShotProducer } from "./oneshot-producer.ts";
27
27
  import { isRouteAdmitted } from "./routes.ts";
28
28
  import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts";
29
29
  import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts";
30
- import { RuntimeHubClient } from "./client.ts";
30
+ import { RuntimeHubClient, HubHttpError, type SyncPushResponse } from "./client.ts";
31
31
  import { readHubBinding } from "./hub-binding.ts";
32
32
  import { resolveClientHubAuthToken } from "./hub-env.ts";
33
- import { defaultProjectName } from "./project-name.ts";
33
+ import { createLogger } from "./logger.ts";
34
+ import type { KxmOutboxRow, KxmOutboxStatus } from "./runtime-store.ts";
34
35
 
35
36
  /* ------------------------------------------------------------------ *
36
37
  * Token management
@@ -364,6 +365,10 @@ export const DEFAULT_RUNTIME_SYNC_INTERVAL_MS = 10_000;
364
365
  const MIN_RUNTIME_SYNC_INTERVAL_MS = 250;
365
366
  const MAX_RUNTIME_SYNC_INTERVAL_MS = 60_000;
366
367
  const OUTBOX_PUSH_BATCH = 32;
368
+ /** Hub body ceiling is 256 KiB; leave headroom for the JSON envelope. */
369
+ const OUTBOX_PUSH_BATCH_BYTES = 200_000;
370
+ /** How long the sync tick stops knocking on a hub that keeps failing. */
371
+ const RUNTIME_SYNC_MAX_BACKOFF_MS = 5 * 60_000;
367
372
 
368
373
  /** How often the supervisor heartbeats and pushes its outbox. Keep it well
369
374
  * under the hub's presence lease (30 s by default). */
@@ -375,19 +380,54 @@ export function runtimeSyncIntervalMs(env: NodeJS.ProcessEnv = process.env): num
375
380
  return Math.min(MAX_RUNTIME_SYNC_INTERVAL_MS, Math.max(MIN_RUNTIME_SYNC_INTERVAL_MS, parsed));
376
381
  }
377
382
 
383
+ export interface KxmOutboxRefusal {
384
+ runId: string;
385
+ sequence: number;
386
+ code: string;
387
+ }
388
+
378
389
  export interface KxmOutboxSyncResult {
390
+ /** Rows carried to the hub in this pass, including rows later refused. */
379
391
  pushed: number;
392
+ /** Rows the hub accepted or already held — the cursor advanced. */
380
393
  acked: number;
381
- conflicts: number;
382
- rejected: number;
394
+ /** Rows the hub refused in a way re-sending cannot change. */
395
+ refused: number;
396
+ /** Per-row refusals, oldest first, carrying the hub's own code. */
397
+ refusals: KxmOutboxRefusal[];
398
+ /** Rows the hub gave no answer for. Left pending, never acked. */
399
+ unconfirmed: number;
400
+ /** A transport failure ended the pass; the rest of the queue stays pending. */
401
+ blocked: boolean;
402
+ /** Why the pass was blocked, already reduced to an operator-safe line. */
403
+ blockedReason?: string;
404
+ }
405
+
406
+ /** A hub answer that will not change if the same bytes are sent again. */
407
+ function isDurableRefusal(outcome: string): boolean {
408
+ return outcome === "conflict" || outcome === "rejected";
409
+ }
410
+
411
+ /** An HTTP refusal that says "this batch is too big", not "try again later". */
412
+ function isOversizeRefusal(error: unknown): boolean {
413
+ return error instanceof HubHttpError
414
+ && (error.statusCode === 413 || error.code === "sync_batch_too_large" || error.code === "payload_too_large");
383
415
  }
384
416
 
385
417
  /**
386
- * Push every pending outbox row to the bound hub in outbox order and advance
387
- * the cursor on each acknowledgement. Accepted and duplicate rows are acked;
388
- * a conflict or rejection stays pending (the hub has raised the alert) and is
389
- * skipped for the rest of this pass. A transport failure leaves every row
390
- * pending for a safe retry.
418
+ * Push every pending outbox row to the bound hub in outbox order.
419
+ *
420
+ * Accepted and duplicate rows are acked. A row the hub **durably** refuses — a
421
+ * sequence already used with other bytes, a project id another hub project
422
+ * claimed, an event that fails the sync schema — is refused locally too: it
423
+ * leaves the pending queue with the hub's code recorded, because re-sending the
424
+ * same bytes can only re-raise the same alert. That is the difference between a
425
+ * retry and a stampede, and it is what keeps one unreachable row from parking
426
+ * every row behind it.
427
+ *
428
+ * A transport failure marks the pass `blocked` and returns: the rows stay
429
+ * pending for a real retry, with the failure carried back to the caller so it
430
+ * can be logged and backed off instead of swallowed.
391
431
  */
392
432
  export async function syncKxmOutbox(
393
433
  eventStore: KxmRunEventStore,
@@ -395,8 +435,74 @@ export async function syncKxmOutbox(
395
435
  options: { now?: () => string; batchSize?: number } = {},
396
436
  ): Promise<KxmOutboxSyncResult> {
397
437
  const now = options.now ?? (() => new Date().toISOString());
398
- const batchSize = options.batchSize ?? OUTBOX_PUSH_BATCH;
399
- const result: KxmOutboxSyncResult = { pushed: 0, acked: 0, conflicts: 0, rejected: 0 };
438
+ const batchSize = Math.max(1, options.batchSize ?? OUTBOX_PUSH_BATCH);
439
+ const result: KxmOutboxSyncResult = { pushed: 0, acked: 0, refused: 0, refusals: [], unconfirmed: 0, blocked: false };
440
+
441
+ const refuse = (row: KxmOutboxRow, code: string): void => {
442
+ if (eventStore.refuseOutbox([{ seq: row.seq, code }], now()) === 0) return;
443
+ result.refused += 1;
444
+ result.refusals.push({ runId: row.runId, sequence: row.sequence, code });
445
+ };
446
+
447
+ /**
448
+ * Deliver one batch. Returns false only for a transient failure, where every
449
+ * row of the batch stays pending. One result per event, in order, is the
450
+ * hub's contract, so the answer for a row is found at its index and then
451
+ * verified against the row it claims to describe.
452
+ */
453
+ const deliver = async (batch: readonly KxmOutboxRow[]): Promise<boolean> => {
454
+ const payload: unknown[] = [];
455
+ const sendable: KxmOutboxRow[] = [];
456
+ for (const row of batch) {
457
+ try {
458
+ payload.push(JSON.parse(row.syncEvent) as unknown);
459
+ sendable.push(row);
460
+ } catch {
461
+ // Written by the transform, so unparseable bytes are local corruption —
462
+ // durable, visible, and never a reason to stop the queue behind it.
463
+ refuse(row, "sync_row_unreadable");
464
+ }
465
+ }
466
+ if (sendable.length === 0) return true;
467
+ eventStore.markOutboxAttempted(sendable.map((row) => row.seq), now());
468
+ let response: SyncPushResponse;
469
+ try {
470
+ response = await client.pushSyncEvents(payload);
471
+ } catch (error) {
472
+ if (isOversizeRefusal(error)) {
473
+ if (sendable.length > 1) {
474
+ // Isolate the offender one row at a time so the batch never becomes
475
+ // a permanent head-of-line block.
476
+ for (const row of sendable) {
477
+ if (!(await deliver([row]))) return false;
478
+ }
479
+ return true;
480
+ }
481
+ refuse(sendable[0]!, "sync_row_too_large");
482
+ return true;
483
+ }
484
+ result.blockedReason = syncFailureText(error);
485
+ return false;
486
+ }
487
+ result.pushed += sendable.length;
488
+ const acked: number[] = [];
489
+ sendable.forEach((row, index) => {
490
+ const entry = response.results[index];
491
+ const describesRow = entry !== undefined
492
+ && (entry.runId === undefined || entry.runId === row.runId)
493
+ && (entry.sequence === undefined || entry.sequence === row.sequence);
494
+ if (!entry || !describesRow) {
495
+ result.unconfirmed += 1;
496
+ return;
497
+ }
498
+ if (entry.outcome === "accepted" || entry.outcome === "duplicate") acked.push(row.seq);
499
+ else if (isDurableRefusal(entry.outcome)) refuse(row, entry.code ?? `sync_${entry.outcome}`);
500
+ else result.unconfirmed += 1;
501
+ });
502
+ result.acked += eventStore.ackOutbox(acked, now());
503
+ return true;
504
+ };
505
+
400
506
  let afterSeq = 0;
401
507
  for (;;) {
402
508
  const rows = eventStore.pendingOutbox(batchSize, afterSeq);
@@ -405,8 +511,7 @@ export async function syncKxmOutbox(
405
511
  // over its body ceiling (HTTP 413), and retrying the same oversized batch
406
512
  // would permanently block the queue. Trim to the byte budget and leave the
407
513
  // rest for the next iteration.
408
- const MAX_BATCH_BYTES = 200_000; // hub ceiling is 256 KiB; leave headroom
409
- let byteBudget = MAX_BATCH_BYTES;
514
+ let byteBudget = OUTBOX_PUSH_BATCH_BYTES;
410
515
  let sendCount = 0;
411
516
  for (const row of rows) {
412
517
  const rowBytes = Buffer.byteLength(row.syncEvent, "utf8") + 64; // JSON overhead
@@ -414,21 +519,12 @@ export async function syncKxmOutbox(
414
519
  byteBudget -= rowBytes;
415
520
  sendCount += 1;
416
521
  }
417
- const batch = rows.slice(0, sendCount);
418
- if (batch.length === 0) batch.push(rows[0]!); // one oversized row: send alone, hub will 413
522
+ const batch = rows.slice(0, Math.max(1, sendCount));
419
523
  afterSeq = batch[batch.length - 1]!.seq;
420
- eventStore.markOutboxAttempted(batch.map((row) => row.seq), now());
421
- const response = await client.pushSyncEvents(batch.map((row) => JSON.parse(row.syncEvent) as unknown));
422
- result.pushed += batch.length;
423
- const outcomes = new Map(response.results.map((entry) => [`${entry.runId}\u0000${entry.sequence}`, entry.outcome]));
424
- const acked: number[] = [];
425
- for (const row of batch) {
426
- const outcome = outcomes.get(`${row.runId}\u0000${row.sequence}`);
427
- if (outcome === "accepted" || outcome === "duplicate") acked.push(row.seq);
428
- else if (outcome === "conflict") result.conflicts += 1;
429
- else result.rejected += 1;
524
+ if (!(await deliver(batch))) {
525
+ result.blocked = true;
526
+ return result;
430
527
  }
431
- result.acked += eventStore.ackOutbox(acked, now());
432
528
  }
433
529
  }
434
530
 
@@ -437,9 +533,10 @@ function runtimeHubClientFor(context: KxmRuntimeContext, env: NodeJS.ProcessEnv)
437
533
  const serverUrl = env.KXM_SERVER_URL?.trim() || readHubBinding(env)?.url;
438
534
  if (!serverUrl) return undefined;
439
535
  // Use the context's actual projectId — the same identity sync events carry — so the
440
- // ops snapshot can join runs to their home Runtime. defaultProjectName() resolves to
441
- // the npm package name (e.g. "@kontextmind/kxm"), which never matches project.yaml's
442
- // prj_* id, leaving presence orphaned from the runs it owns.
536
+ // ops snapshot can join runs to their home Runtime. The npm package name (what an
537
+ // earlier build sent) never matches project.yaml's prj_* id: presence lands under one
538
+ // label and runs under the other, and the hub pins the project id to the first label
539
+ // it ever saw, which refuses every later push. See the supervisor sync gate.
443
540
  const project = context.projectId;
444
541
  const authToken = resolveClientHubAuthToken(env, project);
445
542
  return new RuntimeHubClient({
@@ -450,6 +547,54 @@ function runtimeHubClientFor(context: KxmRuntimeContext, env: NodeJS.ProcessEnv)
450
547
  });
451
548
  }
452
549
 
550
+ /* ------------------------------------------------------------------ *
551
+ * Sync status: what the tick saw, readable without opening SQLite
552
+ * ------------------------------------------------------------------ */
553
+
554
+ /**
555
+ * One project's outbound sync state as the supervisor last observed it.
556
+ *
557
+ * `state` is the answer to "is this Runtime talking to the hub?":
558
+ * `ok` (rows are acking), `no_hub` (nothing bound — keeping rows locally is the
559
+ * design), `blocked` (a transport or credential failure; retryable, backing
560
+ * off), or `refusing` (the hub answered durably; an operator has to change
561
+ * something). `blocked` and `refusing` are the two states that mean run facts
562
+ * are not reaching the hub.
563
+ */
564
+ export interface KxmProjectSyncStatus {
565
+ projectId: string;
566
+ projectRoot: string;
567
+ homeRuntimeId: string;
568
+ state: "ok" | "no_hub" | "blocked" | "refusing";
569
+ outbox: KxmOutboxStatus;
570
+ consecutiveFailures: number;
571
+ hubUrl?: string;
572
+ lastCompletedAt?: string;
573
+ lastPushed?: number;
574
+ lastAcked?: number;
575
+ lastRefused?: number;
576
+ lastUnconfirmed?: number;
577
+ lastError?: string;
578
+ nextAttemptAt?: string;
579
+ }
580
+
581
+ /** Exponential backoff on consecutive stalls, capped so a hub outage heals by itself. */
582
+ function syncBackoffMs(consecutiveStalls: number, intervalMs: number): number {
583
+ if (consecutiveStalls <= 0) return 0;
584
+ return Math.min(RUNTIME_SYNC_MAX_BACKOFF_MS, intervalMs * 2 ** Math.min(5, consecutiveStalls - 1));
585
+ }
586
+
587
+ /** A failure worth showing an operator: bounded, and never the bearer token. */
588
+ function syncFailureText(error: unknown): string {
589
+ const code = error instanceof HubHttpError ? `${error.statusCode} ${error.code ?? "error"}` : undefined;
590
+ const message = error instanceof Error ? error.message : String(error);
591
+ return `${code ? `${code}: ` : ""}${message}`.replace(/(kxm_[A-Za-z0-9_]+|[A-Za-z0-9._-]{40,})/g, "[redacted]").slice(0, 300);
592
+ }
593
+
594
+ function runtimeSupervisorLogFile(paths: KxmRuntimePaths): string {
595
+ return join(paths.runtimeDir, "logs", "kxm-runtime.jsonl");
596
+ }
597
+
453
598
  export interface KxmRuntimeSupervisor {
454
599
  server: Server;
455
600
  port: number;
@@ -458,12 +603,13 @@ export interface KxmRuntimeSupervisor {
458
603
  }
459
604
 
460
605
  export async function startKxmRuntimeSupervisor(
461
- options: { stateRoot?: string; port?: number; now?: () => string } = {},
606
+ options: { stateRoot?: string; port?: number; now?: () => string; env?: NodeJS.ProcessEnv } = {},
462
607
  ): Promise<KxmRuntimeSupervisor> {
463
608
  const now = options.now ?? (() => new Date().toISOString());
609
+ const env = options.env ?? process.env;
464
610
  const paths = kxmRuntimePaths(options.stateRoot !== undefined ? { stateRoot: options.stateRoot } : {});
465
611
  try {
466
- return await startKxmRuntimeSupervisorInner(paths, options.port, now);
612
+ return await startKxmRuntimeSupervisorInner(paths, options.port, now, env);
467
613
  } catch (error) {
468
614
  // Startup failures are recorded so auto-start clients see the real cause
469
615
  // instead of a bare timeout. Conflict is not an error: the winner is
@@ -479,9 +625,19 @@ async function startKxmRuntimeSupervisorInner(
479
625
  paths: KxmRuntimePaths,
480
626
  requestedPortOption: number | undefined,
481
627
  now: () => string,
628
+ env: NodeJS.ProcessEnv,
482
629
  ): Promise<KxmRuntimeSupervisor> {
483
630
  mkdirSync(paths.runtimeDir, { recursive: true, mode: 0o700 });
484
631
  mkdirSync(paths.projectsDir, { recursive: true, mode: 0o700 });
632
+ // The supervisor is spawned detached with stdio ignored, so this file is its
633
+ // only durable voice. Without it, a sync path that refuses every row is
634
+ // indistinguishable from a sync path that never ran.
635
+ const logger = createLogger({
636
+ component: "runtime",
637
+ path: runtimeSupervisorLogFile(paths),
638
+ stdout: false,
639
+ maxBytes: 2 * 1024 * 1024,
640
+ });
485
641
  // The token is generated in memory and written only after the supervisor
486
642
  // singleton is owned: a losing supervisor can never overwrite a winner's
487
643
  // token and lock every client out.
@@ -494,16 +650,21 @@ async function startKxmRuntimeSupervisorInner(
494
650
  let activeRuntimeId = runtimeId;
495
651
 
496
652
  const contexts = new Map<string, KxmRuntimeContext>();
653
+ // Declared with the contexts rather than with the timer that fills it: the
654
+ // listener is up before the tick is armed, and `GET /v1/sync/status` must be
655
+ // answerable in that window rather than reach for a binding that is not there
656
+ // yet.
657
+ const syncStatuses = new Map<string, KxmProjectSyncStatus>();
497
658
  const registerSyncCredentials = (context: KxmRuntimeContext): void => {
498
659
  // Register credentials on the store's redactor at context creation —
499
660
  // BEFORE any event can be appended — so the very first outbox row is
500
661
  // already scrubbed. Registering on the sync tick leaves a window where
501
662
  // appended events retain credentials.
502
- const hubToken = resolveClientHubAuthToken(process.env, context.projectId);
663
+ const hubToken = resolveClientHubAuthToken(env, context.projectId);
503
664
  if (hubToken) context.eventStore.syncRedactor.register(hubToken);
504
- for (const key of Object.keys(process.env)) {
665
+ for (const key of Object.keys(env)) {
505
666
  if ((key.startsWith("KXM_") && (key.endsWith("_TOKEN") || key.endsWith("_KEY"))) || key.endsWith("_API_KEY") || key.endsWith("_SECRET")) {
506
- const value = process.env[key]?.trim();
667
+ const value = env[key]?.trim();
507
668
  if (value) context.eventStore.syncRedactor.register(value);
508
669
  }
509
670
  }
@@ -556,6 +717,42 @@ async function startKxmRuntimeSupervisorInner(
556
717
  return;
557
718
  }
558
719
 
720
+ // What the sync loop last saw, per project this Runtime owns. This is the
721
+ // surface that makes "the supervisor is running clean" mean something:
722
+ // pending, acked and refused rows, the hub's refusal codes, and when the
723
+ // next attempt is due.
724
+ if (request.method === "GET" && url.pathname === "/v1/sync/status") {
725
+ const projectRoot = url.searchParams.get("projectRoot");
726
+ const statuses = [...syncStatuses.values()]
727
+ .filter((status) => projectRoot === null || status.projectRoot === projectRoot)
728
+ .sort((left, right) => left.projectId.localeCompare(right.projectId));
729
+ sendJson(response, 200, { ok: true, runtimeId: activeRuntimeId, projects: statuses });
730
+ return;
731
+ }
732
+
733
+ // Operator-only revive: a durably refused row stays refused until someone
734
+ // says the hub-side state has been corrected. The Runtime never decides on
735
+ // its own that a refusal has become retryable, because the refusal is
736
+ // usually the hub holding a claim this Runtime cannot see.
737
+ if (request.method === "POST" && url.pathname === "/v1/sync/retry") {
738
+ const body = await readJsonBody(request);
739
+ const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
740
+ const context = contextFor(projectRoot);
741
+ const key = projectRuntimeKey(context.projectRoot);
742
+ const retried = context.eventStore.retryRefusedOutbox();
743
+ const status = syncStatuses.get(key);
744
+ // Clear the backoff gate so the rows go out on the next tick, and let
745
+ // that tick re-derive the state from what the hub actually says.
746
+ if (status) {
747
+ const cleared: KxmProjectSyncStatus = { ...status, outbox: context.eventStore.outboxStatus(), consecutiveFailures: 0 };
748
+ delete (cleared as Partial<KxmProjectSyncStatus>).nextAttemptAt;
749
+ syncStatuses.set(key, cleared);
750
+ }
751
+ logger({ event: "runtime_sync_retry", projectId: context.projectId, retried });
752
+ sendJson(response, 200, { ok: true, projectId: context.projectId, retried, outbox: context.eventStore.outboxStatus() });
753
+ return;
754
+ }
755
+
559
756
  if (request.method === "POST" && url.pathname === "/v1/runs") {
560
757
  const body = await readJsonBody(request);
561
758
  const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
@@ -891,7 +1088,10 @@ async function startKxmRuntimeSupervisorInner(
891
1088
 
892
1089
  // Outbound only: the supervisor pulls nothing and exposes nothing to the hub.
893
1090
  // A tick that finds no bound hub, no credential or an unreachable hub does
894
- // nothing; outbox rows stay pending and local execution never waits on it.
1091
+ // not fail a run; outbox rows stay pending and local execution never waits on
1092
+ // it. "Never waits" is not "never says anything": every tick records what it
1093
+ // saw into the sync status this process serves at `GET /v1/sync/status`, and
1094
+ // a transport failure backs off instead of knocking every interval.
895
1095
  //
896
1096
  // Restart recovery: contexts are only populated on demand (a project request
897
1097
  // opens one), so a restarted supervisor would see an empty map and silently
@@ -904,27 +1104,107 @@ async function startKxmRuntimeSupervisorInner(
904
1104
  // A project whose checkout has moved or been deleted stays skipped; its
905
1105
  // outbox rows remain pending and its presence expires, which is visible
906
1106
  // in the ops snapshot as orphaned.
1107
+ logger.warn({
1108
+ event: "runtime_sync_context_unavailable",
1109
+ projectId: reg.projectId,
1110
+ message: `cannot reopen ${reg.projectRoot}: its outbox rows stay pending`,
1111
+ });
907
1112
  }
908
1113
  }
909
1114
 
1115
+ const recordSyncStatus = (context: KxmRuntimeContext, next: KxmProjectSyncStatus): void => {
1116
+ const key = projectRuntimeKey(context.projectRoot);
1117
+ const previous = syncStatuses.get(key);
1118
+ syncStatuses.set(key, next);
1119
+ const changed = previous === undefined || previous.state !== next.state
1120
+ || (next.lastError !== undefined && previous?.lastError !== next.lastError)
1121
+ || JSON.stringify(next.outbox.refusals) !== JSON.stringify(previous?.outbox.refusals ?? []);
1122
+ if (!changed) return;
1123
+ // One line per state change — never one per tick, which is how a stalled
1124
+ // sync turns its own log into noise. `refusing` and `blocked` are the two
1125
+ // states that mean "run facts are not reaching the hub", so they are the
1126
+ // states an operator must be able to see without opening SQLite.
1127
+ const stalledState = next.state === "blocked" || next.state === "refusing";
1128
+ const entry = {
1129
+ event: stalledState ? "runtime_sync_stalled" : "runtime_sync_state",
1130
+ projectId: next.projectId,
1131
+ runtimeId: next.homeRuntimeId,
1132
+ state: next.state,
1133
+ pending: next.outbox.pending,
1134
+ acked: next.outbox.acked,
1135
+ refused: next.outbox.refused,
1136
+ ...(next.outbox.refusals.length > 0 ? { refusals: next.outbox.refusals } : {}),
1137
+ ...(next.hubUrl ? { hubUrl: next.hubUrl } : {}),
1138
+ ...(next.lastError ? { reason: next.lastError } : {}),
1139
+ ...(next.nextAttemptAt ? { nextAttemptAt: next.nextAttemptAt } : {}),
1140
+ };
1141
+ if (stalledState) logger.warn(entry);
1142
+ else logger.info(entry);
1143
+ };
1144
+
910
1145
  let syncing = false;
911
1146
  const syncTimer = setInterval(() => {
912
1147
  if (syncing || stopping) return;
913
1148
  syncing = true;
914
1149
  void (async () => {
915
1150
  for (const context of [...contexts.values()]) {
916
-
1151
+ const key = projectRuntimeKey(context.projectRoot);
1152
+ const prior = syncStatuses.get(key);
1153
+ if (prior?.nextAttemptAt && Date.parse(prior.nextAttemptAt) > Date.parse(now())) continue;
1154
+ const base = {
1155
+ projectId: context.projectId,
1156
+ projectRoot: context.projectRoot,
1157
+ homeRuntimeId: context.homeRuntimeId,
1158
+ outbox: context.eventStore.outboxStatus(),
1159
+ consecutiveFailures: 0,
1160
+ };
917
1161
  try {
918
- const client = runtimeHubClientFor(context, process.env);
919
- if (!client) continue;
1162
+ const client = runtimeHubClientFor(context, env);
1163
+ if (!client) {
1164
+ // Not a failure: a box with no bound hub simply keeps its rows.
1165
+ recordSyncStatus(context, { ...base, state: "no_hub" });
1166
+ continue;
1167
+ }
920
1168
  await client.heartbeat();
921
- await syncKxmOutbox(context.eventStore, client, { now });
922
- } catch {
923
- // Retry on the next tick.
1169
+ const pass = await syncKxmOutbox(context.eventStore, client, { now });
1170
+ const outbox = context.eventStore.outboxStatus();
1171
+ // A refused row is out of the pending queue, so there is nothing left to
1172
+ // stampede over: back off only for failures a later pass could actually
1173
+ // fix. The `refusing` state persists for as long as the store holds
1174
+ // refusals, which is the part an operator has to see.
1175
+ const failures = pass.blocked || pass.unconfirmed > 0 ? (prior?.consecutiveFailures ?? 0) + 1 : 0;
1176
+ const delayMs = syncBackoffMs(failures, runtimeSyncIntervalMs(env));
1177
+ recordSyncStatus(context, {
1178
+ ...base,
1179
+ hubUrl: client.options.serverUrl,
1180
+ outbox,
1181
+ state: pass.blocked ? "blocked" : (outbox.refused > 0 || pass.unconfirmed > 0) ? "refusing" : "ok",
1182
+ lastCompletedAt: now(),
1183
+ lastPushed: pass.pushed,
1184
+ lastAcked: pass.acked,
1185
+ lastRefused: pass.refused,
1186
+ lastUnconfirmed: pass.unconfirmed,
1187
+ consecutiveFailures: failures,
1188
+ ...(pass.blockedReason !== undefined ? { lastError: pass.blockedReason } : {}),
1189
+ ...(delayMs > 0 ? { nextAttemptAt: new Date(Date.parse(now()) + delayMs).toISOString() } : {}),
1190
+ });
1191
+ } catch (error) {
1192
+ // The hub is unreachable, unauthenticated or answering garbage. The
1193
+ // rows are still pending, so this is retryable — but it is never
1194
+ // silently retried: the reason is recorded and the tick backs off.
1195
+ const failures = (prior?.consecutiveFailures ?? 0) + 1;
1196
+ const delayMs = syncBackoffMs(failures, runtimeSyncIntervalMs(env));
1197
+ recordSyncStatus(context, {
1198
+ ...base,
1199
+ state: "blocked",
1200
+ consecutiveFailures: failures,
1201
+ lastError: syncFailureText(error),
1202
+ nextAttemptAt: new Date(Date.parse(now()) + delayMs).toISOString(),
1203
+ });
924
1204
  }
925
1205
  }
926
1206
  })().finally(() => { syncing = false; });
927
- }, runtimeSyncIntervalMs());
1207
+ }, runtimeSyncIntervalMs(env));
928
1208
  syncTimer.unref();
929
1209
 
930
1210
  let stopping = false;
@@ -961,6 +1241,7 @@ async function startKxmRuntimeSupervisorInner(
961
1241
  }
962
1242
  for (const context of contexts.values()) closeKxmRuntimeContext(context);
963
1243
  contexts.clear();
1244
+ logger.close();
964
1245
  const closed = new Promise<void>((resolveStop) => server.close(() => resolveStop()));
965
1246
  server.closeIdleConnections?.();
966
1247
  await closed;