@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.77",
3
+ "version": "0.7.79",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -20,6 +20,7 @@ import {
20
20
  ensureKxmSupervisor,
21
21
  kxmRuntimeRequest,
22
22
  kxmSupervisorStatus,
23
+ type KxmProjectSyncStatus,
23
24
  } from "../runtime-supervisor.ts";
24
25
  import { kxmRuntimePaths, runtimeError } from "../runtime-store.ts";
25
26
  import { assembleTenantStatus, formatTenantStatus } from "../tenant-status.ts";
@@ -748,12 +749,38 @@ export async function cmdKxmRuntime(runtime: Runtime, action: string): Promise<n
748
749
  return 0;
749
750
  }
750
751
  if (action === "status") {
752
+ // Liveness alone was not enough to answer "is my outbox draining?" — the
753
+ // supervisor ran clean while 23 rows sat refused by the hub. The sync block
754
+ // is that answer, read from the process that actually pushes.
751
755
  const status = kxmSupervisorStatus(paths);
752
- print(runtime.io, runtime.json, { ok: true, command: "runtime status", ...status }, status.running
753
- ? `runtime supervisor running: ${status.runtimeId} pid ${status.pid} on 127.0.0.1:${status.port}`
754
- : "runtime supervisor is not running");
756
+ const sync = status.running ? await readKxmSupervisorSync(runtime) : undefined;
757
+ print(runtime.io, runtime.json, { ok: true, command: "runtime status", ...status, ...(sync ? { sync } : {}) }, [
758
+ status.running
759
+ ? `runtime supervisor running: ${status.runtimeId} pid ${status.pid} on 127.0.0.1:${status.port}`
760
+ : "runtime supervisor is not running",
761
+ ...formatKxmSyncStatus(sync),
762
+ ].join("\n"));
755
763
  return status.running ? 0 : 1;
756
764
  }
765
+ if (action === "sync-retry") {
766
+ const supervisor = await attachKxmSupervisor({ env: runtime.env });
767
+ if (!supervisor) {
768
+ print(runtime.io, runtime.json, { ok: false, command: "runtime sync-retry", error: "runtime_not_running" }, "runtime supervisor is not running");
769
+ return 1;
770
+ }
771
+ const projectRoot = discoverKxmProjectRoot(runtime.cwd);
772
+ if (!projectRoot) {
773
+ print(runtime.io, runtime.json, { ok: false, command: "runtime sync-retry", error: "project_required" }, "kxm runtime sync-retry requires a KXM project (run kxm init first)");
774
+ return 1;
775
+ }
776
+ if (runtime.dryRun) {
777
+ print(runtime.io, runtime.json, { ok: true, command: "runtime sync-retry", projectRoot, dryRun: true }, "would re-queue rows the hub durably refused");
778
+ return 0;
779
+ }
780
+ const result = await kxmRuntimeRequest(supervisor, "POST", "/v1/sync/retry", { projectRoot });
781
+ print(runtime.io, runtime.json, { ok: true, command: "runtime sync-retry", ...result }, `re-queued ${String(result.retried ?? 0)} refused outbox rows for ${String(result.projectId ?? projectRoot)}`);
782
+ return 0;
783
+ }
757
784
  if (action === "stop") {
758
785
  const status = kxmSupervisorStatus(paths);
759
786
  if (!status.running || !status.port) {
@@ -781,6 +808,33 @@ export async function cmdKxmRuntime(runtime: Runtime, action: string): Promise<n
781
808
  }
782
809
  }
783
810
 
811
+ /** What the running supervisor last saw from the hub, or why it could not say. */
812
+ async function readKxmSupervisorSync(runtime: Runtime): Promise<KxmProjectSyncStatus[] | undefined> {
813
+ const supervisor = await attachKxmSupervisor({ env: runtime.env });
814
+ if (!supervisor) return undefined;
815
+ try {
816
+ const response = await kxmRuntimeRequest(supervisor, "GET", "/v1/sync/status");
817
+ return response.projects as unknown as KxmProjectSyncStatus[];
818
+ } catch {
819
+ return undefined;
820
+ }
821
+ }
822
+
823
+ function formatKxmSyncStatus(sync: KxmProjectSyncStatus[] | undefined): string[] {
824
+ if (sync === undefined) return ["sync: the supervisor did not answer /v1/sync/status"];
825
+ if (sync.length === 0) return ["sync: no project registered with this Runtime yet"];
826
+ return sync.map((project) => {
827
+ const codes = project.outbox.refusals.map((refusal) => `${refusal.code} x${refusal.count}`).join(", ");
828
+ const counts = `pending ${project.outbox.pending}, acked ${project.outbox.acked}, refused ${project.outbox.refused}`;
829
+ const tail = project.state === "refusing"
830
+ ? ` (${codes || "see log"}) — fix the hub, then: kxm runtime sync-retry`
831
+ : project.state === "blocked"
832
+ ? ` — last error: ${project.lastError ?? "unreachable"}${project.nextAttemptAt ? `; next attempt ${project.nextAttemptAt}` : ""}`
833
+ : "";
834
+ return `sync ${project.projectId}: ${project.state} (${counts})${tail}`;
835
+ });
836
+ }
837
+
784
838
  export async function cmdKxmRunReceipt(runtime: Runtime, runId: string, options: { all?: boolean } = {}): Promise<number> {
785
839
  try {
786
840
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
@@ -459,6 +459,10 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
459
459
  .action(async function runtimeStatusAction(this: Command) {
460
460
  result.code = await cmdKxmRuntime(runtimeFrom(ctx, this), "status");
461
461
  });
462
+ addGlobalOptions(runtimeCmd.command("sync-retry").description("Re-queue outbox rows the hub durably refused, after the hub-side state is corrected"))
463
+ .action(async function runtimeSyncRetryAction(this: Command) {
464
+ result.code = await cmdKxmRuntime(runtimeFrom(ctx, this), "sync-retry");
465
+ });
462
466
  addGlobalOptions(runtimeCmd.command("stop").description("Gracefully stop the Runtime supervisor"))
463
467
  .action(async function runtimeStopAction(this: Command) {
464
468
  result.code = await cmdKxmRuntime(runtimeFrom(ctx, this), "stop");
@@ -738,7 +738,7 @@ export function restoreBackup(
738
738
  // Must track KXM_EVENT_STORE_SCHEMA_VERSION in runtime-store.ts. The pin is
739
739
  // the e6 backup/restore round-trip test: bump one without the other and it
740
740
  // refuses its own fresh backup.
741
- maxSupported = 6;
741
+ maxSupported = 7;
742
742
  }
743
743
 
744
744
  let targetPath = store.sourcePath;
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
8
8
  import { deliverInboxNotification } from "./inbox.ts";
9
9
  import type { HubEvent, MessageRecord } from "./protocol.ts";
10
10
 
11
- const VERSION = "0.7.77";
11
+ const VERSION = "0.7.79";
12
12
  const inbox = new Map<string, MessageRecord>();
13
13
  const notifiedInbox = new Set<string>();
14
14
  let meshClient: HubClient | undefined;
@@ -561,7 +561,7 @@ export interface KxmCommandRecord {
561
561
  recordedAt: string;
562
562
  }
563
563
 
564
- export const KXM_EVENT_STORE_SCHEMA_VERSION = 6;
564
+ export const KXM_EVENT_STORE_SCHEMA_VERSION = 7;
565
565
  export const KXM_DRIVE_RECEIPT_SCHEMA = "kxm.drive-receipt.v1";
566
566
  export const DRIVE_RECEIPT_MAX_BYTES = 8 * 1024;
567
567
 
@@ -597,7 +597,9 @@ const EVENT_STORE_TABLES = {
597
597
  "schema", "record",
598
598
  ],
599
599
  project_controls: ["project_id", "paused", "reason", "updated_at", "actor", "schema", "record"],
600
- outbox: ["seq", "run_id", "sequence", "sync_event", "attempted_at", "acked_at"],
600
+ outbox: [
601
+ "seq", "run_id", "sequence", "sync_event", "attempted_at", "attempt_count", "acked_at", "refused_code", "refused_at",
602
+ ],
601
603
  } as const;
602
604
 
603
605
  /**
@@ -798,23 +800,52 @@ CREATE TABLE IF NOT EXISTS outbox (
798
800
  sequence INTEGER NOT NULL,
799
801
  sync_event TEXT NOT NULL,
800
802
  attempted_at TEXT,
803
+ attempt_count INTEGER NOT NULL DEFAULT 0,
801
804
  acked_at TEXT,
805
+ refused_code TEXT,
806
+ refused_at TEXT,
802
807
  UNIQUE (run_id, sequence)
803
808
  ) STRICT;
804
- CREATE INDEX outbox_pending ON outbox(seq) WHERE acked_at IS NULL;
809
+ CREATE INDEX outbox_pending ON outbox(seq) WHERE acked_at IS NULL AND refused_code IS NULL;
810
+ CREATE INDEX outbox_refused ON outbox(seq) WHERE refused_code IS NOT NULL;
805
811
  `;
806
812
 
807
813
  /**
808
814
  * One outbox row: the already-derived `kxm.sync-event.v1` bytes plus retry
809
815
  * transport metadata. The local source payload is never kept here.
816
+ *
817
+ * A row sits in exactly one of three states: **pending** (retryable — neither
818
+ * `ackedAt` nor `refusedCode` is set), **acked** (the hub holds it), or
819
+ * **refused** (the hub answered in a way that re-sending the same bytes cannot
820
+ * change: a used sequence, a project claimed by another label, a schema
821
+ * refusal). Refused rows leave the pending queue so they can neither block the
822
+ * rows behind them nor re-alert the hub on every tick, and stay inspectable
823
+ * until an operator clears the refusal with `retryRefusedOutbox`.
810
824
  */
811
825
  export interface KxmOutboxRow {
812
826
  seq: number;
813
827
  runId: string;
814
828
  sequence: number;
815
829
  syncEvent: string;
830
+ attemptCount: number;
816
831
  attemptedAt?: string;
817
832
  ackedAt?: string;
833
+ refusedCode?: string;
834
+ refusedAt?: string;
835
+ }
836
+
837
+ /** The outbox read model behind `kxm runtime status` and the supervisor's sync endpoint. */
838
+ export interface KxmOutboxStatus {
839
+ /** Rows the sync loop may still push. */
840
+ pending: number;
841
+ /** Rows the hub has acknowledged. */
842
+ acked: number;
843
+ /** Rows the hub durably refused, retried only by an operator. */
844
+ refused: number;
845
+ lastAttemptAt?: string;
846
+ oldestPendingSeq?: number;
847
+ /** Refused rows grouped by the hub's own code, busiest first. */
848
+ refusals: Array<{ code: string; count: number }>;
818
849
  }
819
850
 
820
851
  /** One persisted coordinator identity (`kxm.coordinator.v1`). */
@@ -1029,26 +1060,38 @@ export class KxmRunEventStore {
1029
1060
  .run(event.runId, event.sequence, kxmSyncEventBytes(syncEvent));
1030
1061
  }
1031
1062
 
1032
- /** Unacknowledged outbox rows after `afterSeq`, in outbox order, oldest first. */
1063
+ /**
1064
+ * Retryable outbox rows after `afterSeq`, in outbox order, oldest first.
1065
+ * Acked and durably refused rows are never returned: a refused row is not
1066
+ * waiting for a retry, it is waiting for an operator.
1067
+ */
1033
1068
  pendingOutbox(limit = 100, afterSeq = 0): KxmOutboxRow[] {
1034
1069
  const rows = this.database.prepare(`
1035
- SELECT seq, run_id, sequence, sync_event, attempted_at, acked_at
1036
- FROM outbox WHERE acked_at IS NULL AND seq > ? ORDER BY seq ASC LIMIT ?
1070
+ SELECT seq, run_id, sequence, sync_event, attempt_count, attempted_at, acked_at, refused_code, refused_at
1071
+ FROM outbox WHERE acked_at IS NULL AND refused_code IS NULL AND seq > ? ORDER BY seq ASC LIMIT ?
1037
1072
  `).all(afterSeq, limit) as OutboxSqlRow[];
1038
1073
  return rows.map(outboxFromRow);
1039
1074
  }
1040
1075
 
1041
- /** Every outbox row of one run, acknowledged or not, in sequence order. */
1076
+ /** Every outbox row of one run, in any state, in sequence order. */
1042
1077
  outboxForRun(runId: string): KxmOutboxRow[] {
1043
1078
  const rows = this.database.prepare(`
1044
- SELECT seq, run_id, sequence, sync_event, attempted_at, acked_at
1079
+ SELECT seq, run_id, sequence, sync_event, attempt_count, attempted_at, acked_at, refused_code, refused_at
1045
1080
  FROM outbox WHERE run_id = ? ORDER BY sequence ASC
1046
1081
  `).all(runId) as OutboxSqlRow[];
1047
1082
  return rows.map(outboxFromRow);
1048
1083
  }
1049
1084
 
1085
+ /**
1086
+ * Count one attempt against each row. Refused rows are never re-counted: they
1087
+ * are out of the queue, and a tick that reaches them again would mean the
1088
+ * refusal was cleared (which resets the count).
1089
+ */
1050
1090
  markOutboxAttempted(seqs: readonly number[], at: string): void {
1051
- const statement = this.database.prepare("UPDATE outbox SET attempted_at = ? WHERE seq = ? AND acked_at IS NULL");
1091
+ const statement = this.database.prepare(`
1092
+ UPDATE outbox SET attempted_at = ?, attempt_count = attempt_count + 1
1093
+ WHERE seq = ? AND acked_at IS NULL AND refused_code IS NULL
1094
+ `);
1052
1095
  this.transaction(() => {
1053
1096
  for (const seq of seqs) statement.run(at, seq);
1054
1097
  });
@@ -1056,7 +1099,7 @@ export class KxmRunEventStore {
1056
1099
 
1057
1100
  /** Advance the cursor: the hub holds these rows now. Returns rows newly acked. */
1058
1101
  ackOutbox(seqs: readonly number[], at: string): number {
1059
- const statement = this.database.prepare("UPDATE outbox SET acked_at = ? WHERE seq = ? AND acked_at IS NULL");
1102
+ const statement = this.database.prepare("UPDATE outbox SET acked_at = ?, refused_code = NULL, refused_at = NULL WHERE seq = ? AND acked_at IS NULL");
1060
1103
  return this.transaction(() => {
1061
1104
  let changed = 0;
1062
1105
  for (const seq of seqs) changed += Number(statement.run(at, seq).changes);
@@ -1064,6 +1107,68 @@ export class KxmRunEventStore {
1064
1107
  });
1065
1108
  }
1066
1109
 
1110
+ /**
1111
+ * Record a durable hub refusal: the answer will not change if the same bytes
1112
+ * are sent again. Returns rows newly moved out of the pending queue.
1113
+ */
1114
+ refuseOutbox(refusals: readonly { seq: number; code: string }[], at: string): number {
1115
+ const statement = this.database.prepare(`
1116
+ UPDATE outbox SET refused_code = ?, refused_at = ?
1117
+ WHERE seq = ? AND acked_at IS NULL AND refused_code IS NULL
1118
+ `);
1119
+ return this.transaction(() => {
1120
+ let changed = 0;
1121
+ for (const refusal of refusals) changed += Number(statement.run(refusal.code, at, refusal.seq).changes);
1122
+ return changed;
1123
+ });
1124
+ }
1125
+
1126
+ /**
1127
+ * Operator revive: put refused rows back in the pending queue with a fresh
1128
+ * attempt budget. Used after the hub-side state is corrected — the Runtime
1129
+ * must not decide on its own that a refusal has become retryable.
1130
+ */
1131
+ retryRefusedOutbox(): number {
1132
+ return Number(this.database.prepare(`
1133
+ UPDATE outbox SET refused_code = NULL, refused_at = NULL, attempted_at = NULL, attempt_count = 0
1134
+ WHERE acked_at IS NULL AND refused_code IS NOT NULL
1135
+ `).run().changes);
1136
+ }
1137
+
1138
+ /**
1139
+ * The outbox as one read model: what is still retryable, what the hub holds,
1140
+ * and what the hub refused with which codes. This is what makes a stalled
1141
+ * sync answerable without opening SQLite.
1142
+ */
1143
+ outboxStatus(): KxmOutboxStatus {
1144
+ const counts = this.database.prepare(`
1145
+ SELECT
1146
+ COALESCE(SUM(acked_at IS NULL AND refused_code IS NULL), 0) AS pending,
1147
+ COALESCE(SUM(acked_at IS NOT NULL), 0) AS acked,
1148
+ COALESCE(SUM(acked_at IS NULL AND refused_code IS NOT NULL), 0) AS refused,
1149
+ MAX(attempted_at) AS last_attempt_at
1150
+ FROM outbox
1151
+ `).get() as { pending: number; acked: number; refused: number; last_attempt_at: string | null };
1152
+ const refusals = (this.database.prepare(`
1153
+ SELECT refused_code AS code, COUNT(*) AS count
1154
+ FROM outbox WHERE acked_at IS NULL AND refused_code IS NOT NULL
1155
+ GROUP BY refused_code ORDER BY count DESC, code ASC
1156
+ `).all() as Array<{ code: string; count: number }>);
1157
+ const oldest = this.database.prepare(`
1158
+ SELECT seq FROM outbox WHERE acked_at IS NULL AND refused_code IS NULL ORDER BY seq ASC LIMIT 1
1159
+ `).get() as { seq: number } | undefined;
1160
+ return {
1161
+ pending: counts.pending,
1162
+ acked: counts.acked,
1163
+ refused: counts.refused,
1164
+ ...(counts.last_attempt_at !== null ? { lastAttemptAt: counts.last_attempt_at } : {}),
1165
+ ...(oldest ? { oldestPendingSeq: oldest.seq } : {}),
1166
+ // Mapped, not spread: node:sqlite rows are null-prototype objects, and this
1167
+ // read model is handed straight to the CLI and the supervisor's JSON answer.
1168
+ refusals: refusals.map((refusal) => ({ code: refusal.code, count: refusal.count })),
1169
+ };
1170
+ }
1171
+
1067
1172
  events(runId: string, afterSequence = 0, limit = 200): KxmRunEvent[] {
1068
1173
  const rows = this.database.prepare(`
1069
1174
  SELECT project_id, run_id, sequence, event_id, event_type, command_id, occurred_at, recorded_at, monotonic_ns, config_revision, memory_revision, executor_policy_revision, tool_policy_revision, payload, schema, home_runtime_id
@@ -1678,8 +1783,11 @@ type OutboxSqlRow = {
1678
1783
  run_id: string;
1679
1784
  sequence: number;
1680
1785
  sync_event: string;
1786
+ attempt_count: number;
1681
1787
  attempted_at: string | null;
1682
1788
  acked_at: string | null;
1789
+ refused_code: string | null;
1790
+ refused_at: string | null;
1683
1791
  };
1684
1792
 
1685
1793
  function outboxFromRow(row: OutboxSqlRow): KxmOutboxRow {
@@ -1688,8 +1796,11 @@ function outboxFromRow(row: OutboxSqlRow): KxmOutboxRow {
1688
1796
  runId: row.run_id,
1689
1797
  sequence: row.sequence,
1690
1798
  syncEvent: row.sync_event,
1799
+ attemptCount: row.attempt_count,
1691
1800
  ...(row.attempted_at !== null ? { attemptedAt: row.attempted_at } : {}),
1692
1801
  ...(row.acked_at !== null ? { ackedAt: row.acked_at } : {}),
1802
+ ...(row.refused_code !== null ? { refusedCode: row.refused_code } : {}),
1803
+ ...(row.refused_at !== null ? { refusedAt: row.refused_at } : {}),
1693
1804
  };
1694
1805
  }
1695
1806