agent-embassy 1.4.1 → 1.6.0

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/CONTRIBUTING.md +69 -6
  3. package/README.md +8 -5
  4. package/README.zh-CN.md +6 -4
  5. package/SECURITY.md +152 -27
  6. package/dist/src/gateway/claude-peer.d.ts +7 -4
  7. package/dist/src/gateway/claude-peer.js +80 -36
  8. package/dist/src/gateway/claude-peer.js.map +1 -1
  9. package/dist/src/gateway/claude-runtime.d.ts +4 -0
  10. package/dist/src/gateway/claude-runtime.js +50 -39
  11. package/dist/src/gateway/claude-runtime.js.map +1 -1
  12. package/dist/src/gateway/cli-copy.d.ts +1 -1
  13. package/dist/src/gateway/cli-copy.en.d.ts +1 -1
  14. package/dist/src/gateway/cli-copy.en.js +1 -1
  15. package/dist/src/gateway/cli-copy.en.js.map +1 -1
  16. package/dist/src/gateway/cli-copy.js +1 -1
  17. package/dist/src/gateway/cli-copy.js.map +1 -1
  18. package/dist/src/gateway/cli-copy.zh-CN.d.ts +1 -1
  19. package/dist/src/gateway/cli-copy.zh-CN.js +1 -1
  20. package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
  21. package/dist/src/gateway/cli.d.ts +1 -1
  22. package/dist/src/gateway/cli.js +6 -7
  23. package/dist/src/gateway/cli.js.map +1 -1
  24. package/dist/src/gateway/codex-app-server.d.ts +44 -2
  25. package/dist/src/gateway/codex-app-server.js +536 -2
  26. package/dist/src/gateway/codex-app-server.js.map +1 -1
  27. package/dist/src/gateway/codex-local-transport.d.ts +10 -6
  28. package/dist/src/gateway/codex-local-transport.js +101 -56
  29. package/dist/src/gateway/codex-local-transport.js.map +1 -1
  30. package/dist/src/gateway/compatibility.d.ts +42 -3
  31. package/dist/src/gateway/compatibility.js +190 -37
  32. package/dist/src/gateway/compatibility.js.map +1 -1
  33. package/dist/src/gateway/control.js +28 -30
  34. package/dist/src/gateway/control.js.map +1 -1
  35. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  36. package/dist/src/gateway/dashboard-copy.en.d.ts +42 -17
  37. package/dist/src/gateway/dashboard-copy.en.js +56 -31
  38. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  39. package/dist/src/gateway/dashboard-copy.js +42 -17
  40. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  41. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +42 -17
  42. package/dist/src/gateway/dashboard-copy.zh-CN.js +56 -31
  43. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  44. package/dist/src/gateway/dashboard-model.d.ts +199 -11
  45. package/dist/src/gateway/dashboard-model.js +478 -75
  46. package/dist/src/gateway/dashboard-model.js.map +1 -1
  47. package/dist/src/gateway/dashboard.d.ts +3 -0
  48. package/dist/src/gateway/dashboard.js +62 -137
  49. package/dist/src/gateway/dashboard.js.map +1 -1
  50. package/dist/src/gateway/deepseek-detect.d.ts +13 -0
  51. package/dist/src/gateway/deepseek-detect.js +125 -0
  52. package/dist/src/gateway/deepseek-detect.js.map +1 -0
  53. package/dist/src/gateway/live-dashboard-app/app.js +158 -256
  54. package/dist/src/gateway/live-dashboard-assets.js +2 -1
  55. package/dist/src/gateway/live-dashboard-assets.js.map +1 -1
  56. package/dist/src/gateway/live-dashboard-stream.d.ts +2 -2
  57. package/dist/src/gateway/live-dashboard-stream.js +2 -2
  58. package/dist/src/gateway/live-dashboard-stream.js.map +1 -1
  59. package/dist/src/gateway/progress-watch-machine.d.ts +50 -64
  60. package/dist/src/gateway/progress-watch-machine.js +42 -133
  61. package/dist/src/gateway/progress-watch-machine.js.map +1 -1
  62. package/dist/src/gateway/provenance-envelope.d.ts +2 -0
  63. package/dist/src/gateway/provenance-envelope.js +13 -3
  64. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  65. package/dist/src/gateway/providers.d.ts +80 -16
  66. package/dist/src/gateway/providers.js +782 -115
  67. package/dist/src/gateway/providers.js.map +1 -1
  68. package/dist/src/gateway/server.d.ts +10 -5
  69. package/dist/src/gateway/server.js +100 -13
  70. package/dist/src/gateway/server.js.map +1 -1
  71. package/dist/src/gateway/service.d.ts +46 -3
  72. package/dist/src/gateway/service.js +363 -101
  73. package/dist/src/gateway/service.js.map +1 -1
  74. package/dist/src/gateway/store.d.ts +23 -28
  75. package/dist/src/gateway/store.js +479 -467
  76. package/dist/src/gateway/store.js.map +1 -1
  77. package/dist/src/gateway/types.d.ts +40 -12
  78. package/dist/src/gateway/types.js +145 -4
  79. package/dist/src/gateway/types.js.map +1 -1
  80. package/docs/CONFIGURATION.md +8 -8
  81. package/docs/CONFIGURATION.zh-CN.md +8 -8
  82. package/docs/DASHBOARD.md +35 -6
  83. package/docs/DASHBOARD.zh-CN.md +1 -1
  84. package/docs/GATEWAY-ARCHITECTURE.md +173 -61
  85. package/package.json +1 -1
  86. package/skills/embassy-peer/SKILL.md +2 -2
@@ -6,9 +6,9 @@ import path from "node:path";
6
6
  import { BridgeError } from "../errors.js";
7
7
  import { KeyedMutex } from "../mutex.js";
8
8
  import { isCodexRegistrationGeneration } from "./codex-registration-generation.js";
9
- import { compatibilityCacheKey, isCompatibilityAttestation, } from "./compatibility.js";
10
- import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY, PROGRESS_WATCH_MAX_IDLE_MS, PROGRESS_WATCH_MIN_IDLE_MS, createProgressWatchMachine, progressWatchCapabilities, progressWatchJournalKinds, progressWatchPhases, transitionProgressWatch, } from "./progress-watch-machine.js";
11
- import { CODEX_ENDPOINT_REFRESH_JOURNAL_CAPACITY, CODEX_ORPHAN_REMOVAL_JOURNAL_CAPACITY, compatibilityStates, connectorHealthStates, deliveryStates, gatewayProviders, messageDirections, projectGatewayPublicSnapshot, routeRegistrationModes, routeStates, } from "./types.js";
9
+ import { compatibilityCacheKey, isCompatibilityAttestation, isPersistedCompatibilityAttestation, } from "./compatibility.js";
10
+ import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY, PROGRESS_WATCH_MAX_IDLE_MS, PROGRESS_WATCH_MIN_IDLE_MS, commitProgressWatchNudge, createProgressWatch, deferProgressWatchNudge, inspectProgressWatchDue, progressWatchJournalKinds, recordProgressWatchActivity, } from "./progress-watch-machine.js";
11
+ import { CODEX_ENDPOINT_REFRESH_JOURNAL_CAPACITY, CODEX_ORPHAN_REMOVAL_JOURNAL_CAPACITY, compatibilityStates, connectorHealthStates, deliveryStates, gatewayPublicSnapshotLimits, gatewayProviders, messageDirections, projectGatewayPublicSnapshot, routeRegistrationModes, routeStates, } from "./types.js";
12
12
  const STATE_MARKER = ".agent-embassy-state";
13
13
  const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
14
14
  const STATE_FILE = "gateway-state.json";
@@ -44,8 +44,6 @@ const ROUTE_STATES = new Set(routeStates);
44
44
  const REGISTRATION_MODES = new Set(routeRegistrationModes);
45
45
  const DIRECTIONS = new Set(messageDirections);
46
46
  const DELIVERY_STATES = new Set(deliveryStates);
47
- const PROGRESS_WATCH_PHASES = new Set(progressWatchPhases);
48
- const PROGRESS_WATCH_CAPABILITIES = new Set(progressWatchCapabilities);
49
47
  const PROGRESS_WATCH_JOURNAL_KINDS = new Set(progressWatchJournalKinds);
50
48
  export const codexSuccessionJournalStages = [
51
49
  "prepared",
@@ -289,7 +287,30 @@ function progressWatchMatchesPair(watch, pair) {
289
287
  watch.workerAlias === pair.claudeAlias &&
290
288
  watch.workerLease === pair.claudeOwnerLease));
291
289
  }
292
- function isLegacyProgressWatchMachine(value) {
290
+ function progressWatchJournalEvent(watch, timestamp, transition) {
291
+ return {
292
+ timestamp,
293
+ conversationId: watch.conversationId,
294
+ ownerAlias: watch.ownerAlias,
295
+ workerAlias: watch.workerAlias,
296
+ ...transition,
297
+ };
298
+ }
299
+ const LEGACY_V14_PROGRESS_WATCH_JOURNAL_KINDS = new Set([
300
+ "opened",
301
+ "replaced",
302
+ "activity",
303
+ "nudge",
304
+ "worker_reported_complete",
305
+ "capability_degraded",
306
+ "conversation_rebound",
307
+ "done",
308
+ "unresponsive",
309
+ "pair_removed",
310
+ "endpoint_retired",
311
+ "disabled",
312
+ ]);
313
+ function isLegacyV14ProgressWatch(value) {
293
314
  if (!isObject(value) ||
294
315
  !hasOnlyKeys(value, [
295
316
  "conversationId",
@@ -306,7 +327,7 @@ function isLegacyProgressWatchMachine(value) {
306
327
  "nextActionAt",
307
328
  "capability",
308
329
  "degradedNoticeSent",
309
- ], ["workerReportedCompleteAt"])) {
330
+ ])) {
310
331
  return false;
311
332
  }
312
333
  return (typeof value.conversationId === "string" &&
@@ -325,8 +346,7 @@ function isLegacyProgressWatchMachine(value) {
325
346
  isPositiveInteger(value.idleMs) &&
326
347
  value.idleMs >= PROGRESS_WATCH_MIN_IDLE_MS &&
327
348
  value.idleMs <= PROGRESS_WATCH_MAX_IDLE_MS &&
328
- typeof value.phase === "string" &&
329
- PROGRESS_WATCH_PHASES.has(value.phase) &&
349
+ (value.phase === "quiet" || value.phase === "episode") &&
330
350
  (value.nudgeCount === 0 ||
331
351
  value.nudgeCount === 1 ||
332
352
  value.nudgeCount === 2) &&
@@ -334,17 +354,10 @@ function isLegacyProgressWatchMachine(value) {
334
354
  ? value.nudgeCount === 0
335
355
  : value.nudgeCount === 1 || value.nudgeCount === 2) &&
336
356
  isIsoTimestamp(value.nextActionAt) &&
337
- typeof value.capability === "string" &&
338
- PROGRESS_WATCH_CAPABILITIES.has(value.capability) &&
339
- typeof value.degradedNoticeSent === "boolean" &&
340
- (value.workerReportedCompleteAt === undefined ||
341
- isIsoTimestamp(value.workerReportedCompleteAt)));
357
+ (value.capability === "conversation" || value.capability === "route") &&
358
+ typeof value.degradedNoticeSent === "boolean");
342
359
  }
343
- function isProgressWatchMachine(value) {
344
- return (isLegacyProgressWatchMachine(value) &&
345
- !Object.hasOwn(value, "workerReportedCompleteAt"));
346
- }
347
- function isProgressWatchJournalEvent(value) {
360
+ function isLegacyV14ProgressWatchJournalEvent(value) {
348
361
  return (isObject(value) &&
349
362
  hasOnlyKeys(value, [
350
363
  "sequence",
@@ -364,14 +377,94 @@ function isProgressWatchJournalEvent(value) {
364
377
  ALIAS_PATTERN.test(value.workerAlias) &&
365
378
  value.ownerAlias !== value.workerAlias &&
366
379
  typeof value.kind === "string" &&
367
- PROGRESS_WATCH_JOURNAL_KINDS.has(value.kind) &&
368
- (value.nudgeNumber === undefined ||
369
- value.nudgeNumber === 1 ||
370
- value.nudgeNumber === 2) &&
380
+ LEGACY_V14_PROGRESS_WATCH_JOURNAL_KINDS.has(value.kind) &&
371
381
  (value.kind === "nudge"
372
382
  ? value.nudgeNumber === 1 || value.nudgeNumber === 2
373
383
  : value.nudgeNumber === undefined));
374
384
  }
385
+ function isProgressWatch(value) {
386
+ return (isObject(value) &&
387
+ hasOnlyKeys(value, [
388
+ "conversationId",
389
+ "ownerAlias",
390
+ "workerAlias",
391
+ "ownerLease",
392
+ "workerLease",
393
+ "lastActivityAt",
394
+ "idleMs",
395
+ "nudgeCount",
396
+ "nextActionAt",
397
+ ]) &&
398
+ typeof value.conversationId === "string" &&
399
+ CONVERSATION_ID_PATTERN.test(value.conversationId) &&
400
+ typeof value.ownerAlias === "string" &&
401
+ ALIAS_PATTERN.test(value.ownerAlias) &&
402
+ typeof value.workerAlias === "string" &&
403
+ ALIAS_PATTERN.test(value.workerAlias) &&
404
+ value.ownerAlias !== value.workerAlias &&
405
+ isPrivateToken(value.ownerLease) &&
406
+ isPrivateToken(value.workerLease) &&
407
+ value.ownerLease !== value.workerLease &&
408
+ isIsoTimestamp(value.lastActivityAt) &&
409
+ isPositiveInteger(value.idleMs) &&
410
+ value.idleMs >= PROGRESS_WATCH_MIN_IDLE_MS &&
411
+ value.idleMs <= PROGRESS_WATCH_MAX_IDLE_MS &&
412
+ (value.nudgeCount === 0 ||
413
+ value.nudgeCount === 1 ||
414
+ value.nudgeCount === 2) &&
415
+ isIsoTimestamp(value.nextActionAt));
416
+ }
417
+ function isProgressWatchJournalEvent(value) {
418
+ if (!isObject(value) ||
419
+ !hasOnlyKeys(value, [
420
+ "sequence",
421
+ "timestamp",
422
+ "conversationId",
423
+ "ownerAlias",
424
+ "workerAlias",
425
+ "kind",
426
+ "actor",
427
+ ], ["reason"]) ||
428
+ !isPositiveInteger(value.sequence) ||
429
+ !isIsoTimestamp(value.timestamp) ||
430
+ typeof value.conversationId !== "string" ||
431
+ !CONVERSATION_ID_PATTERN.test(value.conversationId) ||
432
+ typeof value.ownerAlias !== "string" ||
433
+ !ALIAS_PATTERN.test(value.ownerAlias) ||
434
+ typeof value.workerAlias !== "string" ||
435
+ !ALIAS_PATTERN.test(value.workerAlias) ||
436
+ value.ownerAlias === value.workerAlias ||
437
+ typeof value.kind !== "string" ||
438
+ !PROGRESS_WATCH_JOURNAL_KINDS.has(value.kind)) {
439
+ return false;
440
+ }
441
+ if (value.kind === "opened") {
442
+ return value.actor === "owner" && value.reason === undefined;
443
+ }
444
+ if (value.kind === "replaced") {
445
+ return ((value.actor === "owner" || value.actor === "unknown") &&
446
+ value.reason === undefined);
447
+ }
448
+ if (value.kind !== "settled" || typeof value.reason !== "string") {
449
+ return false;
450
+ }
451
+ if (value.reason === "done") {
452
+ return value.actor === "owner" || value.actor === "worker";
453
+ }
454
+ if (value.reason === "untracked")
455
+ return value.actor === "operator";
456
+ if (value.reason === "pair_removed")
457
+ return value.actor === "operator";
458
+ if (value.reason === "endpoint_retired") {
459
+ return value.actor === "gateway" || value.actor === "operator";
460
+ }
461
+ if (value.reason === "idle_timeout" ||
462
+ value.reason === "tracking_disabled" ||
463
+ value.reason === "legacy_upgrade") {
464
+ return value.actor === "gateway";
465
+ }
466
+ return value.reason === "legacy_done" && value.actor === "unknown";
467
+ }
375
468
  function isConnectorRecord(value) {
376
469
  if (!isObject(value) ||
377
470
  !hasOnlyKeys(value, [
@@ -784,8 +877,9 @@ function isLegacyCompatibilityCertification(value) {
784
877
  }
785
878
  /**
786
879
  * v1.1-v1.2 compatibility rows could contain manual live-certification
787
- * evidence. The ceremony is gone: accept only its exact old shape, discard
788
- * it, and retain the automatic bounded probe evidence.
880
+ * evidence. Discard only an exact old certification, retain the automatic
881
+ * bounded probes as schema-attested evidence, and leave malformed rows
882
+ * untouched for strict state validation to reject.
789
883
  */
790
884
  function migrateLegacyCompatibilityCertifications(value) {
791
885
  if (!isObject(value) ||
@@ -795,105 +889,180 @@ function migrateLegacyCompatibilityCertifications(value) {
795
889
  }
796
890
  let changed = false;
797
891
  const compatibilityAttestations = value.compatibilityAttestations.map((entry) => {
798
- if (!isObject(entry) || !Object.hasOwn(entry, "certification")) {
799
- return entry;
800
- }
801
- if (!isLegacyCompatibilityCertification(entry.certification)) {
892
+ let candidate = entry;
893
+ if (isObject(entry) &&
894
+ Object.hasOwn(entry, "certification") &&
895
+ isLegacyCompatibilityCertification(entry.certification)) {
896
+ const { certification: _legacyCertification, ...current } = entry;
897
+ candidate =
898
+ current.tier === "certified"
899
+ ? { ...current, tier: "schema_attested" }
900
+ : current;
901
+ }
902
+ if (!isPersistedCompatibilityAttestation(candidate))
802
903
  return entry;
803
- }
804
- const { certification: _legacyCertification, ...current } = entry;
805
- if (!isCompatibilityAttestation(current))
806
- return entry;
807
- changed = true;
808
- return current;
904
+ if (candidate !== entry)
905
+ changed = true;
906
+ return candidate;
809
907
  });
810
908
  return changed ? { ...value, compatibilityAttestations } : value;
811
909
  }
910
+ function legacyProgressWatchGraphIsValid(value, watches) {
911
+ if (!isObject(value) ||
912
+ !Array.isArray(value.routes) ||
913
+ !value.routes.every(isRouteRecord) ||
914
+ !Array.isArray(value.pairs) ||
915
+ value.pairs.length > 256 ||
916
+ !value.pairs.every(isPairRecord) ||
917
+ new Set(watches.map((watch) => watch.conversationId)).size !==
918
+ watches.length ||
919
+ new Set(watches.map(progressWatchPairKey)).size !== watches.length) {
920
+ return false;
921
+ }
922
+ const routes = value.routes;
923
+ const pairs = value.pairs;
924
+ for (const watch of watches) {
925
+ const owner = routes.find((route) => route.alias === watch.ownerAlias &&
926
+ route.binding.ownerLease === watch.ownerLease);
927
+ const worker = routes.find((route) => route.alias === watch.workerAlias &&
928
+ route.binding.ownerLease === watch.workerLease);
929
+ if (owner === undefined ||
930
+ worker === undefined ||
931
+ owner.binding.provider === worker.binding.provider ||
932
+ owner.binding.hostId !== worker.binding.hostId) {
933
+ return false;
934
+ }
935
+ const claude = owner.binding.provider === "claude" ? owner : worker;
936
+ const codex = owner.binding.provider === "codex" ? owner : worker;
937
+ if (!pairs.some((pair) => pair.claudeAlias === claude.alias &&
938
+ pair.codexAlias === codex.alias &&
939
+ pair.claudeOwnerLease === claude.binding.ownerLease &&
940
+ pair.codexOwnerLease === codex.binding.ownerLease)) {
941
+ return false;
942
+ }
943
+ }
944
+ return true;
945
+ }
946
+ function mapLegacyProgressWatchEvent(event) {
947
+ const base = {
948
+ timestamp: event.timestamp,
949
+ conversationId: event.conversationId,
950
+ ownerAlias: event.ownerAlias,
951
+ workerAlias: event.workerAlias,
952
+ };
953
+ switch (event.kind) {
954
+ case "opened":
955
+ return { ...base, kind: "opened", actor: "owner" };
956
+ case "replaced":
957
+ // v1.4 used this kind for both owner replacement and an automatic
958
+ // duplicate migration. Its actor cannot be reconstructed honestly.
959
+ return { ...base, kind: "replaced", actor: "unknown" };
960
+ case "worker_reported_complete":
961
+ return { ...base, kind: "settled", actor: "worker", reason: "done" };
962
+ case "done":
963
+ return {
964
+ ...base,
965
+ kind: "settled",
966
+ actor: "unknown",
967
+ reason: "legacy_done",
968
+ };
969
+ case "unresponsive":
970
+ return {
971
+ ...base,
972
+ kind: "settled",
973
+ actor: "gateway",
974
+ reason: "idle_timeout",
975
+ };
976
+ case "pair_removed":
977
+ return {
978
+ ...base,
979
+ kind: "settled",
980
+ actor: "operator",
981
+ reason: "pair_removed",
982
+ };
983
+ case "endpoint_retired":
984
+ return {
985
+ ...base,
986
+ kind: "settled",
987
+ actor: "gateway",
988
+ reason: "endpoint_retired",
989
+ };
990
+ case "disabled":
991
+ return {
992
+ ...base,
993
+ kind: "settled",
994
+ actor: "gateway",
995
+ reason: "tracking_disabled",
996
+ };
997
+ case "activity":
998
+ case "nudge":
999
+ case "capability_degraded":
1000
+ case "conversation_rebound":
1001
+ return undefined;
1002
+ }
1003
+ }
812
1004
  /**
813
- * Before v1.4, separately tracked conversations could leave multiple active
814
- * watches on one exact pair, and a worker completion could remain as a flag on
815
- * an active watch. Settle both legacy shapes into the bounded journal. Losers
816
- * are ordered by their stable original-array position, so a persisted result
817
- * is idempotent when loaded again.
1005
+ * v1.4.x watches carried capability and phase state that no longer has product
1006
+ * meaning. Strictly recognize the complete old graph, preserve representable
1007
+ * history, and settle every live row once at upgrade. Rebase the bounded
1008
+ * private sequence so a historical counter cannot make the new format fatal.
818
1009
  */
819
- function migrateLegacyProgressWatchState(value, eventCapacity) {
1010
+ function migrateV14ProgressWatchState(value, migratedAt) {
820
1011
  if (!isObject(value) ||
821
1012
  value.schemaVersion !== 1 ||
822
1013
  !isIsoTimestamp(value.updatedAt) ||
1014
+ !isIsoTimestamp(migratedAt) ||
823
1015
  !isNonNegativeInteger(value.watchSequence) ||
824
1016
  !Array.isArray(value.progressWatches) ||
825
- value.progressWatches.length > PROGRESS_WATCH_HARD_CAPACITY ||
826
- !value.progressWatches.every(isLegacyProgressWatchMachine) ||
827
- !Array.isArray(value.progressWatchEvents) ||
828
- !value.progressWatchEvents.every(isProgressWatchJournalEvent) ||
829
- value.progressWatchEvents.length > eventCapacity ||
830
- new Set(value.progressWatches.map((watch) => watch.conversationId)).size !== value.progressWatches.length) {
1017
+ !Array.isArray(value.progressWatchEvents)) {
831
1018
  return value;
832
1019
  }
833
- const progressWatches = value.progressWatches;
834
- const existingEvents = value.progressWatchEvents;
835
- const watchSequenceBefore = value.watchSequence;
836
- if (existingEvents.some((event, index) => event.sequence > watchSequenceBefore ||
837
- (index > 0 && event.sequence <= existingEvents[index - 1].sequence))) {
1020
+ const hasLegacyRows = value.progressWatches.some(isLegacyV14ProgressWatch) ||
1021
+ value.progressWatchEvents.some(isLegacyV14ProgressWatchJournalEvent);
1022
+ if (!hasLegacyRows)
838
1023
  return value;
839
- }
840
- const winnerByPair = new Map();
841
- for (const [index, watch] of progressWatches.entries()) {
842
- const key = progressWatchPairKey(watch);
843
- const winnerIndex = winnerByPair.get(key);
844
- const winner = winnerIndex === undefined
845
- ? undefined
846
- : progressWatches[winnerIndex];
847
- if (winner === undefined ||
848
- Date.parse(watch.createdAt) >= Date.parse(winner.createdAt)) {
849
- winnerByPair.set(key, index);
850
- }
851
- }
852
- const winnerIndexes = new Set(winnerByPair.values());
853
- const loserIndexes = progressWatches
854
- .map((_, index) => index)
855
- .filter((index) => !winnerIndexes.has(index));
856
- const completedWinnerIndexes = [...winnerByPair.values()].filter((index) => progressWatches[index]?.workerReportedCompleteAt !== undefined);
857
- const additions = loserIndexes.length + completedWinnerIndexes.length;
858
- if (additions === 0)
1024
+ if (value.progressWatches.length > PROGRESS_WATCH_HARD_CAPACITY ||
1025
+ !value.progressWatches.every(isLegacyV14ProgressWatch) ||
1026
+ value.progressWatchEvents.length > gatewayPublicSnapshotLimits.messages ||
1027
+ !value.progressWatchEvents.every(isLegacyV14ProgressWatchJournalEvent)) {
859
1028
  return value;
860
- if (watchSequenceBefore > Number.MAX_SAFE_INTEGER - additions) {
861
- throw new BridgeError("PROGRESS_WATCH_SEQUENCE_EXHAUSTED", "The bounded progress-watch journal sequence is exhausted.");
862
- }
863
- const retained = new Set(winnerIndexes);
864
- for (const index of completedWinnerIndexes)
865
- retained.delete(index);
866
- let watchSequence = watchSequenceBefore;
867
- const progressWatchEvents = [...existingEvents];
868
- const append = (watch, kind, timestamp) => {
869
- watchSequence += 1;
870
- progressWatchEvents.push({
871
- sequence: watchSequence,
872
- timestamp,
873
- conversationId: watch.conversationId,
874
- ownerAlias: watch.ownerAlias,
875
- workerAlias: watch.workerAlias,
876
- kind,
877
- });
878
- while (progressWatchEvents.length > eventCapacity) {
879
- progressWatchEvents.shift();
880
- }
881
- };
882
- for (const index of loserIndexes) {
883
- const watch = progressWatches[index];
884
- const winner = progressWatches[winnerByPair.get(progressWatchPairKey(watch))];
885
- append(watch, "replaced", winner.createdAt);
886
1029
  }
887
- for (const index of completedWinnerIndexes) {
888
- const watch = progressWatches[index];
889
- append(watch, "worker_reported_complete", watch.workerReportedCompleteAt);
1030
+ const watches = value.progressWatches;
1031
+ const legacyEvents = value.progressWatchEvents;
1032
+ const watchSequence = value.watchSequence;
1033
+ if (!legacyProgressWatchGraphIsValid(value, watches) ||
1034
+ legacyEvents.some((event, index) => event.sequence > watchSequence ||
1035
+ (index > 0 && event.sequence <= legacyEvents[index - 1].sequence))) {
1036
+ return value;
890
1037
  }
1038
+ const pending = [];
1039
+ for (const event of legacyEvents) {
1040
+ const mapped = mapLegacyProgressWatchEvent(event);
1041
+ if (mapped !== undefined)
1042
+ pending.push(mapped);
1043
+ }
1044
+ const settlements = watches.map((watch) => ({
1045
+ timestamp: migratedAt,
1046
+ conversationId: watch.conversationId,
1047
+ ownerAlias: watch.ownerAlias,
1048
+ workerAlias: watch.workerAlias,
1049
+ kind: "settled",
1050
+ actor: "gateway",
1051
+ reason: "legacy_upgrade",
1052
+ }));
1053
+ const retainedHistoryCount = gatewayPublicSnapshotLimits.messages - settlements.length;
1054
+ const retained = [
1055
+ ...pending.slice(Math.max(0, pending.length - retainedHistoryCount)),
1056
+ ...settlements,
1057
+ ];
1058
+ const progressWatchEvents = retained.map((event, index) => ({
1059
+ ...event,
1060
+ sequence: index + 1,
1061
+ }));
891
1062
  return {
892
1063
  ...value,
893
- watchSequence,
894
- progressWatches: progressWatches
895
- .filter((_, index) => retained.has(index))
896
- .map(({ workerReportedCompleteAt: _legacy, ...watch }) => watch),
1064
+ watchSequence: progressWatchEvents.length,
1065
+ progressWatches: [],
897
1066
  progressWatchEvents,
898
1067
  };
899
1068
  }
@@ -936,14 +1105,15 @@ function isPersistedState(value) {
936
1105
  value.pairs.length > 256 ||
937
1106
  !value.pairs.every(isPairRecord) ||
938
1107
  !Array.isArray(value.progressWatches) ||
939
- value.progressWatches.length > 256 ||
940
- !value.progressWatches.every(isProgressWatchMachine) ||
1108
+ value.progressWatches.length > PROGRESS_WATCH_HARD_CAPACITY ||
1109
+ !value.progressWatches.every(isProgressWatch) ||
941
1110
  !Array.isArray(value.progressWatchEvents) ||
1111
+ value.progressWatchEvents.length > gatewayPublicSnapshotLimits.messages ||
942
1112
  !value.progressWatchEvents.every(isProgressWatchJournalEvent) ||
943
1113
  !Array.isArray(value.compatibilityAttestations) ||
944
1114
  value.compatibilityAttestations.length >
945
1115
  COMPATIBILITY_ATTESTATION_CAPACITY ||
946
- !value.compatibilityAttestations.every(isCompatibilityAttestation) ||
1116
+ !value.compatibilityAttestations.every(isPersistedCompatibilityAttestation) ||
947
1117
  !Array.isArray(value.codexEndpointRefreshEvents) ||
948
1118
  value.codexEndpointRefreshEvents.length >
949
1119
  CODEX_ENDPOINT_REFRESH_JOURNAL_CAPACITY ||
@@ -959,6 +1129,7 @@ function isPersistedState(value) {
959
1129
  !Array.isArray(value.inFlight) ||
960
1130
  !value.inFlight.every(isInFlightMetadata) ||
961
1131
  !Array.isArray(value.events) ||
1132
+ value.events.length > gatewayPublicSnapshotLimits.messages ||
962
1133
  !value.events.every(isEvent) ||
963
1134
  !Array.isArray(value.dedupe) ||
964
1135
  !value.dedupe.every(isDedupeRecord) ||
@@ -1333,6 +1504,15 @@ function renamePairAlias(state, previousAlias, newAlias, now) {
1333
1504
  }
1334
1505
  }
1335
1506
  }
1507
+ function renameProgressWatchAlias(state, previousAlias, newAlias) {
1508
+ if (previousAlias === newAlias)
1509
+ return;
1510
+ state.progressWatches = state.progressWatches.map((watch) => watch.ownerAlias === previousAlias
1511
+ ? { ...watch, ownerAlias: newAlias }
1512
+ : watch.workerAlias === previousAlias
1513
+ ? { ...watch, workerAlias: newAlias }
1514
+ : watch);
1515
+ }
1336
1516
  function removePairsForAliases(state, aliases) {
1337
1517
  state.pairs = state.pairs.filter((pair) => !aliases.has(pair.claudeAlias) && !aliases.has(pair.codexAlias));
1338
1518
  }
@@ -1396,8 +1576,8 @@ export class GatewayStore {
1396
1576
  }
1397
1577
  await this.acquireLock();
1398
1578
  try {
1399
- const loaded = await this.loadStateFile();
1400
1579
  const now = this.now();
1580
+ const loaded = await this.loadStateFile(now);
1401
1581
  this.state = loaded ?? {
1402
1582
  schemaVersion: 1,
1403
1583
  createdAt: now.toISOString(),
@@ -1627,22 +1807,21 @@ export class GatewayStore {
1627
1807
  // Replacing an endpoint is scoped to the replacement alias. Other
1628
1808
  // selected Claude routes and their independent consent edges survive.
1629
1809
  const retired = selectedClaudeRoutes.filter((route) => route !== retained && route === aliasOwner);
1810
+ if (state.routes.some((route) => route !== retained &&
1811
+ !retired.includes(route) &&
1812
+ (sameRouteTarget(route.binding, replacement.binding) ||
1813
+ route.binding.ownerLease === replacement.binding.ownerLease))) {
1814
+ throw new BridgeError("ROUTE_BINDING_COLLISION", "The replacement Claude authority collides with a non-retired route.");
1815
+ }
1630
1816
  const retiredAliases = new Set(retired.map((route) => route.alias));
1631
- this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => retiredAliases.has(watch.ownerAlias) ||
1632
- retiredAliases.has(watch.workerAlias)).length);
1633
1817
  const settlementPlan = this.validateAffectedInFlightSettlements(state, retiredAliases, input.inFlightSettlements ?? []);
1634
- const settlements = this.terminateAffectedMessages(state, retiredAliases, now, "ROUTE_UNREGISTERED", settlementPlan);
1635
1818
  this.settleProgressWatchesForAliases(state, retiredAliases, now);
1819
+ const settlements = this.terminateAffectedMessages(state, retiredAliases, now, "ROUTE_UNREGISTERED", settlementPlan);
1636
1820
  state.routes = state.routes.filter((route) => !retired.includes(route));
1637
1821
  removePairsForAliases(state, retiredAliases);
1638
1822
  state.rateBuckets = state.rateBuckets.filter((bucket) => !retiredAliases.has(bucket.sourceAlias));
1639
1823
  state.dedupe = state.dedupe.filter((record) => !retiredAliases.has(record.sourceAlias) &&
1640
1824
  !retiredAliases.has(record.targetAlias));
1641
- if (state.routes.some((route) => route !== retained &&
1642
- (sameRouteTarget(route.binding, replacement.binding) ||
1643
- route.binding.ownerLease === replacement.binding.ownerLease))) {
1644
- throw new BridgeError("ROUTE_BINDING_COLLISION", "The replacement Claude authority collides with a non-retired route.");
1645
- }
1646
1825
  if (retained !== undefined) {
1647
1826
  const previousAlias = retained.alias;
1648
1827
  retained.alias = replacement.alias;
@@ -1672,6 +1851,7 @@ export class GatewayStore {
1672
1851
  }
1673
1852
  renameRateBucket(state, previousAlias, replacement.alias);
1674
1853
  renamePairAlias(state, previousAlias, replacement.alias, now);
1854
+ renameProgressWatchAlias(state, previousAlias, replacement.alias);
1675
1855
  }
1676
1856
  }
1677
1857
  else {
@@ -2006,7 +2186,6 @@ export class GatewayStore {
2006
2186
  return await this.mutate(async (state, now) => {
2007
2187
  const route = this.requireOwnedRoute(state, alias, ownerLease);
2008
2188
  const aliases = new Set([route.alias]);
2009
- this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => aliases.has(watch.ownerAlias) || aliases.has(watch.workerAlias)).length);
2010
2189
  const settlementPlan = this.validateAffectedInFlightSettlements(state, aliases, inFlightSettlements);
2011
2190
  route.enabled = false;
2012
2191
  route.state = "disabled";
@@ -2014,13 +2193,13 @@ export class GatewayStore {
2014
2193
  return this.terminateAffectedMessages(state, aliases, now, "ROUTE_DISABLED", settlementPlan);
2015
2194
  });
2016
2195
  }
2017
- async unregisterRoute(alias, ownerLease, inFlightSettlements = []) {
2196
+ async unregisterRoute(alias, ownerLease, inFlightSettlements = [], progressWatchSettlementActor = "gateway") {
2018
2197
  return await this.mutate(async (state, now) => {
2019
2198
  const route = this.requireOwnedRoute(state, alias, ownerLease);
2020
2199
  const aliases = new Set([route.alias]);
2021
2200
  const settlementPlan = this.validateAffectedInFlightSettlements(state, aliases, inFlightSettlements);
2201
+ this.settleProgressWatchesForAliases(state, aliases, now, progressWatchSettlementActor);
2022
2202
  const settlements = this.terminateAffectedMessages(state, aliases, now, "ROUTE_UNREGISTERED", settlementPlan);
2023
- this.settleProgressWatchesForAliases(state, aliases, now);
2024
2203
  state.routes = state.routes.filter((candidate) => candidate !== route);
2025
2204
  removePairsForAliases(state, aliases);
2026
2205
  const remainingCodexHosts = new Set(state.routes
@@ -2068,25 +2247,7 @@ export class GatewayStore {
2068
2247
  }
2069
2248
  renameRateBucket(state, alias, newAlias);
2070
2249
  renamePairAlias(state, alias, newAlias, now);
2071
- for (let index = 0; index < state.progressWatches.length; index += 1) {
2072
- const watch = state.progressWatches[index];
2073
- if (watch === undefined)
2074
- continue;
2075
- if (watch.ownerAlias === alias) {
2076
- state.progressWatches[index] = {
2077
- ...watch,
2078
- ownerAlias: newAlias,
2079
- updatedAt: now.toISOString(),
2080
- };
2081
- }
2082
- else if (watch.workerAlias === alias) {
2083
- state.progressWatches[index] = {
2084
- ...watch,
2085
- workerAlias: newAlias,
2086
- updatedAt: now.toISOString(),
2087
- };
2088
- }
2089
- }
2250
+ renameProgressWatchAlias(state, alias, newAlias);
2090
2251
  });
2091
2252
  }
2092
2253
  async resolveRoute(alias) {
@@ -2462,32 +2623,42 @@ export class GatewayStore {
2462
2623
  .sort((left, right) => pairKey(left.claudeAlias, left.codexAlias).localeCompare(pairKey(right.claudeAlias, right.codexAlias)));
2463
2624
  });
2464
2625
  }
2465
- async inspectProgressWatches() {
2466
- return this.mutex.run("gateway", async () => this.requireState().progressWatches.map((watch) => ({ ...watch })));
2467
- }
2468
- /** Bind one restarted route-capability watch to its first exact owner send. */
2469
- async bindProgressWatchConversation(input) {
2470
- return this.mutate(async (state, now) => {
2471
- if (!CONVERSATION_ID_PATTERN.test(input.conversationId) ||
2472
- !ALIAS_PATTERN.test(input.ownerAlias) ||
2473
- !ALIAS_PATTERN.test(input.workerAlias) ||
2474
- input.ownerAlias === input.workerAlias) {
2475
- throw new BridgeError("INVALID_PROGRESS_WATCH", "The progress-watch conversation binding is malformed.");
2476
- }
2477
- const routeWatch = state.progressWatches.find((watch) => watch.capability === "route" &&
2478
- watch.ownerAlias === input.ownerAlias &&
2479
- watch.workerAlias === input.workerAlias);
2480
- if (routeWatch === undefined)
2481
- return false;
2482
- const pair = state.pairs.find((candidate) => pairMatchesMessage(candidate, {
2483
- sourceAlias: input.ownerAlias,
2484
- targetAlias: input.workerAlias,
2485
- pair: true,
2486
- }) && progressWatchMatchesPair(routeWatch, candidate));
2487
- if (pair === undefined) {
2488
- throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "A restarted watch can bind only through its exact durable pair.");
2626
+ /** Resolve only an exact live watch; recovery may substitute its suffix. */
2627
+ async resolveProgressWatchDispatch(input) {
2628
+ return this.mutex.run("gateway", async () => {
2629
+ if (!ALIAS_PATTERN.test(input.sourceAlias) ||
2630
+ !ALIAS_PATTERN.test(input.targetAlias) ||
2631
+ input.sourceAlias === input.targetAlias ||
2632
+ (input.conversationId === undefined) ===
2633
+ (input.recoveredConversationIdSuffix === undefined) ||
2634
+ (input.conversationId !== undefined &&
2635
+ !CONVERSATION_ID_PATTERN.test(input.conversationId)) ||
2636
+ (input.recoveredConversationIdSuffix !== undefined &&
2637
+ !CONVERSATION_SUFFIX_PATTERN.test(input.recoveredConversationIdSuffix))) {
2638
+ throw new BridgeError("INVALID_PROGRESS_WATCH", "The progress-watch dispatch lookup is malformed.");
2489
2639
  }
2490
- return this.bindRouteProgressWatchConversation(state, now, input.conversationId, input.ownerAlias, input.workerAlias, pair);
2640
+ const state = this.requireState();
2641
+ const watch = state.progressWatches.find((watch) => (input.conversationId !== undefined
2642
+ ? watch.conversationId === input.conversationId
2643
+ : watch.conversationId.endsWith(input.recoveredConversationIdSuffix)) &&
2644
+ ((watch.ownerAlias === input.sourceAlias &&
2645
+ watch.workerAlias === input.targetAlias) ||
2646
+ (watch.ownerAlias === input.targetAlias &&
2647
+ watch.workerAlias === input.sourceAlias)));
2648
+ if (watch === undefined)
2649
+ return { markerActive: false };
2650
+ const hasExactPair = state.pairs.some((candidate) => pairMatchesMessage(candidate, {
2651
+ sourceAlias: input.sourceAlias,
2652
+ targetAlias: input.targetAlias,
2653
+ pair: true,
2654
+ }) && progressWatchMatchesPair(watch, candidate));
2655
+ if (!hasExactPair)
2656
+ return { markerActive: false };
2657
+ return {
2658
+ conversationId: watch.conversationId,
2659
+ markerActive: watch.ownerAlias === input.sourceAlias &&
2660
+ watch.workerAlias === input.targetAlias,
2661
+ };
2491
2662
  });
2492
2663
  }
2493
2664
  async inspectCompatibilityAttestation(surface, version) {
@@ -2533,97 +2704,67 @@ export class GatewayStore {
2533
2704
  (watch.ownerAlias !== alias && watch.workerAlias !== alias)) {
2534
2705
  continue;
2535
2706
  }
2536
- const transition = transitionProgressWatch(watch, {
2537
- type: "route_activity",
2538
- at: now.getTime(),
2539
- });
2540
- if (transition.state !== null) {
2541
- state.progressWatches[index] = transition.state;
2542
- changed += 1;
2543
- }
2707
+ state.progressWatches[index] = recordProgressWatchActivity(watch, now.getTime());
2708
+ changed += 1;
2544
2709
  }
2545
2710
  return changed;
2546
2711
  });
2547
2712
  }
2548
- async settleProgressWatch(input) {
2713
+ async endProgressWatch(conversationId) {
2549
2714
  return this.mutate(async (state, now) => {
2550
- const index = state.progressWatches.findIndex((watch) => watch.conversationId === input.conversationId);
2715
+ if (!CONVERSATION_ID_PATTERN.test(conversationId)) {
2716
+ throw new BridgeError("INVALID_PROGRESS_WATCH", "The progress-watch conversation token is malformed.");
2717
+ }
2718
+ const index = state.progressWatches.findIndex((watch) => watch.conversationId === conversationId);
2551
2719
  const watch = state.progressWatches[index];
2552
2720
  if (watch === undefined)
2553
2721
  return false;
2554
- if (input.ownerAlias !== undefined &&
2555
- input.ownerAlias !== watch.ownerAlias) {
2556
- throw new BridgeError("PROGRESS_WATCH_OWNER_REQUIRED", "Only the exact watch owner may end this watch.");
2557
- }
2558
- const event = input.outcome === "done"
2559
- ? { type: "owner_done", at: now.getTime() }
2560
- : input.outcome === "endpoint_retired"
2561
- ? { type: "endpoint_retired", at: now.getTime() }
2562
- : input.outcome === "disabled"
2563
- ? { type: "disabled", at: now.getTime() }
2564
- : undefined;
2565
- if (event === undefined) {
2566
- throw new BridgeError("INVALID_PROGRESS_WATCH_SETTLEMENT", "Unresponsive settlement is owned only by the watch deadline machine.");
2567
- }
2568
- const transition = transitionProgressWatch(watch, event);
2569
- this.requireProgressWatchJournalCapacity(state, 1);
2722
+ this.appendProgressWatchEvents(state, [
2723
+ progressWatchJournalEvent(watch, now.toISOString(), {
2724
+ kind: "settled",
2725
+ actor: "operator",
2726
+ reason: "untracked",
2727
+ }),
2728
+ ]);
2570
2729
  state.progressWatches.splice(index, 1);
2571
- this.appendProgressWatchEvent(state, watch, {
2572
- kind: input.outcome,
2573
- timestamp: now.toISOString(),
2574
- });
2575
- return transition.state === null;
2730
+ return true;
2576
2731
  });
2577
2732
  }
2578
2733
  /** Advance every due watch once under one durable store mutation. */
2579
2734
  async advanceDueProgressWatches() {
2580
2735
  return this.mutate(async (state, now) => {
2581
- const terminalCount = state.progressWatches.filter((watch) => {
2582
- if (Date.parse(watch.nextActionAt) > now.getTime())
2583
- return false;
2584
- return this.progressWatchDueTransition(state, watch, now).effects.some((effect) => effect.type === "settled");
2585
- }).length;
2586
- this.requireProgressWatchJournalCapacity(state, terminalCount);
2587
2736
  const actions = [];
2588
2737
  const retained = [];
2738
+ const events = [];
2589
2739
  for (const watch of state.progressWatches) {
2590
2740
  if (Date.parse(watch.nextActionAt) > now.getTime()) {
2591
2741
  retained.push(watch);
2592
2742
  continue;
2593
2743
  }
2594
- const transition = this.progressWatchDueTransition(state, watch, now);
2595
- const nudge = transition.effects.find((effect) => effect.type === "send_nudge");
2596
- // A nudge is only reserved here. Its state transition and journal row
2597
- // commit atomically with the ordinary queued body.
2598
- if (nudge !== undefined)
2744
+ const due = this.inspectProgressWatchDue(state, watch, now);
2745
+ if (due.kind === "not_due" || due.kind === "nudge") {
2599
2746
  retained.push(watch);
2600
- else if (transition.state !== null)
2601
- retained.push(transition.state);
2602
- for (const effect of transition.effects) {
2603
- if (effect.type === "send_nudge") {
2604
- actions.push({
2605
- type: "send_nudge",
2606
- conversationId: watch.conversationId,
2607
- ownerAlias: watch.ownerAlias,
2608
- workerAlias: watch.workerAlias,
2609
- nudgeNumber: effect.nudgeNumber,
2610
- });
2611
- }
2612
- else if (effect.type === "settled") {
2613
- this.appendProgressWatchEvent(state, watch, {
2614
- kind: effect.outcome,
2615
- timestamp: now.toISOString(),
2616
- });
2617
- actions.push({
2618
- type: "settled",
2619
- conversationId: watch.conversationId,
2620
- ownerAlias: watch.ownerAlias,
2621
- workerAlias: watch.workerAlias,
2622
- outcome: effect.outcome,
2623
- });
2624
- }
2747
+ }
2748
+ else if (due.kind === "rescheduled") {
2749
+ retained.push(due.watch);
2750
+ }
2751
+ if (due.kind === "nudge") {
2752
+ actions.push({
2753
+ conversationId: watch.conversationId,
2754
+ ownerAlias: watch.ownerAlias,
2755
+ workerAlias: watch.workerAlias,
2756
+ nudgeNumber: due.nudgeNumber,
2757
+ });
2758
+ }
2759
+ else if (due.kind === "settled") {
2760
+ events.push(progressWatchJournalEvent(watch, now.toISOString(), {
2761
+ kind: "settled",
2762
+ actor: "gateway",
2763
+ reason: "idle_timeout",
2764
+ }));
2625
2765
  }
2626
2766
  }
2767
+ this.appendProgressWatchEvents(state, events);
2627
2768
  state.progressWatches = retained;
2628
2769
  return actions;
2629
2770
  });
@@ -2633,21 +2774,17 @@ export class GatewayStore {
2633
2774
  ? watch.nextActionAt
2634
2775
  : earliest, undefined));
2635
2776
  }
2636
- async deferProgressWatchNudge(conversationId, retryDelayMs = 1_000) {
2777
+ async deferProgressWatchNudge(conversationId, nudgeNumber) {
2637
2778
  return this.mutate(async (state, now) => {
2638
2779
  const index = state.progressWatches.findIndex((watch) => watch.conversationId === conversationId);
2639
2780
  const watch = state.progressWatches[index];
2640
2781
  if (watch === undefined)
2641
2782
  return false;
2642
- const boundedDelay = Math.max(1, Math.min(retryDelayMs, watch.idleMs, 60_000));
2643
- const transition = transitionProgressWatch(watch, {
2644
- type: "nudge_deferred",
2645
- at: now.getTime(),
2646
- retryAt: now.getTime() + boundedDelay,
2647
- });
2648
- if (transition.state === null)
2783
+ const due = this.inspectProgressWatchDue(state, watch, now);
2784
+ if (due.kind !== "nudge" || due.nudgeNumber !== nudgeNumber) {
2649
2785
  return false;
2650
- state.progressWatches[index] = transition.state;
2786
+ }
2787
+ state.progressWatches[index] = deferProgressWatchNudge(watch, now.getTime());
2651
2788
  return true;
2652
2789
  });
2653
2790
  }
@@ -2686,10 +2823,9 @@ export class GatewayStore {
2686
2823
  return this.mutate(async (state, now) => {
2687
2824
  const { claude, codex } = this.requirePairRoutes(state, input, false);
2688
2825
  const pair = this.requireExactPair(state, claude, codex);
2689
- this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => progressWatchMatchesPair(watch, pair)).length);
2690
2826
  const plan = this.validateAffectedPairInFlightSettlements(state, pair, input.inFlightSettlements ?? []);
2691
- const settlements = this.terminateAffectedPairMessages(state, pair, now, "PAIR_REMOVED", plan);
2692
2827
  this.settleProgressWatchesForPair(state, pair, now);
2828
+ const settlements = this.terminateAffectedPairMessages(state, pair, now, "PAIR_REMOVED", plan);
2693
2829
  state.pairs = state.pairs.filter((candidate) => candidate !== pair);
2694
2830
  state.dedupe = state.dedupe.filter((record) => !pairMatchesMessage(pair, record));
2695
2831
  return {
@@ -2848,6 +2984,10 @@ export class GatewayStore {
2848
2984
  const metadata = state.queue[index];
2849
2985
  if (!metadata)
2850
2986
  return undefined;
2987
+ const queuedAhead = state.queue
2988
+ .slice(0, index)
2989
+ .filter((candidate) => candidate.targetAlias === metadata.targetAlias)
2990
+ .length;
2851
2991
  state.queue.splice(index, 1);
2852
2992
  state.accounting.queuedBytes -= metadata.bytes;
2853
2993
  const target = state.routes.find((route) => route.alias === metadata.targetAlias);
@@ -2875,7 +3015,11 @@ export class GatewayStore {
2875
3015
  ...(metadata.steer === true ? { steer: true } : {}),
2876
3016
  latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
2877
3017
  });
2878
- return { ...metadata, body };
3018
+ return {
3019
+ ...metadata,
3020
+ body,
3021
+ ...(queuedAhead === 0 ? {} : { queuedAhead }),
3022
+ };
2879
3023
  });
2880
3024
  }
2881
3025
  /** Controller-private shutdown inventory; full IDs never cross control output. */
@@ -3142,26 +3286,20 @@ export class GatewayStore {
3142
3286
  conversationIdSuffix: watch.conversationId.slice(-8),
3143
3287
  ownerAlias: watch.ownerAlias,
3144
3288
  workerAlias: watch.workerAlias,
3145
- phase: watch.phase,
3146
- capability: watch.capability,
3147
3289
  lastActivityAt: watch.lastActivityAt,
3148
3290
  nextActionAt: watch.nextActionAt,
3149
- idleMs: watch.idleMs,
3150
3291
  nudgeCount: watch.nudgeCount,
3151
3292
  }))
3152
3293
  .sort((left, right) => `${left.nextActionAt}\0${left.ownerAlias}\0${left.workerAlias}`.localeCompare(`${right.nextActionAt}\0${right.ownerAlias}\0${right.workerAlias}`));
3153
- const progressWatchEvents = state.progressWatchEvents
3154
- .filter((event) => event.kind !== "activity")
3155
- .map((event) => ({
3294
+ const progressWatchEvents = state.progressWatchEvents.map((event) => ({
3156
3295
  sequence: event.sequence,
3157
3296
  timestamp: event.timestamp,
3158
3297
  conversationIdSuffix: event.conversationId.slice(-8),
3159
3298
  ownerAlias: event.ownerAlias,
3160
3299
  workerAlias: event.workerAlias,
3161
3300
  kind: event.kind,
3162
- ...(event.nudgeNumber === undefined
3163
- ? {}
3164
- : { nudgeNumber: event.nudgeNumber }),
3301
+ actor: event.actor,
3302
+ ...(event.kind === "settled" ? { reason: event.reason } : {}),
3165
3303
  }));
3166
3304
  const pressureBuckets = [
3167
3305
  { bucket: "under_1m", settled: 0, expired: 0 },
@@ -3382,8 +3520,7 @@ export class GatewayStore {
3382
3520
  replaceCodexRouteForSuccession(state, route, identity, now, routeState, compatibility) {
3383
3521
  const oldAlias = route.alias;
3384
3522
  const affectedAliases = new Set([oldAlias, identity.alias]);
3385
- this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => affectedAliases.has(watch.ownerAlias) ||
3386
- affectedAliases.has(watch.workerAlias)).length);
3523
+ this.settleProgressWatchesForAliases(state, affectedAliases, now);
3387
3524
  route.alias = identity.alias;
3388
3525
  route.binding = { ...identity.binding };
3389
3526
  route.registrationMode = "explicit_opt_in";
@@ -3408,7 +3545,6 @@ export class GatewayStore {
3408
3545
  record.targetAlias !== identity.alias);
3409
3546
  state.rateBuckets = state.rateBuckets.filter((bucket) => bucket.sourceAlias !== oldAlias &&
3410
3547
  bucket.sourceAlias !== identity.alias);
3411
- this.settleProgressWatchesForAliases(state, affectedAliases, now);
3412
3548
  removePairsForAliases(state, affectedAliases);
3413
3549
  }
3414
3550
  assertAllowedIdentity(identity) {
@@ -3663,19 +3799,6 @@ export class GatewayStore {
3663
3799
  input.progressWatchNudge !== undefined) {
3664
3800
  throw new BridgeError("INVALID_PROGRESS_WATCH", "A queued message cannot be both endpoint activity and a controller nudge.");
3665
3801
  }
3666
- this.applyProgressWatchMessageActivity(state, now, input.progressWatch, sides);
3667
- this.commitProgressWatchNudge(state, now, input.progressWatchNudge, sides);
3668
- let supersededSettlement;
3669
- if (superseded !== undefined) {
3670
- state.queue.splice(oldestSteerIndex, 1);
3671
- this.transientBodies.delete(superseded.messageId);
3672
- state.accounting.queuedBytes -= superseded.bytes;
3673
- const oldestTarget = state.routes.find((route) => route.alias === superseded.targetAlias);
3674
- if (oldestTarget) {
3675
- oldestTarget.queueDepth = Math.max(0, oldestTarget.queueDepth - 1);
3676
- }
3677
- supersededSettlement = this.finishMetadata(state, superseded, "cancelled", now, "STEER_QUEUE_SUPERSEDED");
3678
- }
3679
3802
  const opaque = this.randomId();
3680
3803
  const messageId = `msg_${opaque}`;
3681
3804
  if (!MESSAGE_ID_PATTERN.test(messageId)) {
@@ -3701,6 +3824,19 @@ export class GatewayStore {
3701
3824
  : {}),
3702
3825
  ...(input.steer === true ? { steer: true } : {}),
3703
3826
  };
3827
+ this.applyProgressWatchMessageActivity(state, now, input.progressWatch, sides);
3828
+ this.commitProgressWatchNudge(state, now, input.progressWatchNudge, sides);
3829
+ let supersededSettlement;
3830
+ if (superseded !== undefined) {
3831
+ state.queue.splice(oldestSteerIndex, 1);
3832
+ this.transientBodies.delete(superseded.messageId);
3833
+ state.accounting.queuedBytes -= superseded.bytes;
3834
+ const oldestTarget = state.routes.find((route) => route.alias === superseded.targetAlias);
3835
+ if (oldestTarget) {
3836
+ oldestTarget.queueDepth = Math.max(0, oldestTarget.queueDepth - 1);
3837
+ }
3838
+ supersededSettlement = this.finishMetadata(state, superseded, "cancelled", now, "STEER_QUEUE_SUPERSEDED");
3839
+ }
3704
3840
  state.queue.push(metadata);
3705
3841
  this.transientBodies.set(messageId, input.body);
3706
3842
  state.accounting.accepted += 1;
@@ -3901,22 +4037,7 @@ export class GatewayStore {
3901
4037
  const pair = sides.pair === true
3902
4038
  ? state.pairs.find((candidate) => pairMatchesMessage(candidate, sides))
3903
4039
  : undefined;
3904
- let index = state.progressWatches.findIndex((watch) => watch.conversationId === activity.conversationId);
3905
- if (index < 0 &&
3906
- activity.openIdleMs === undefined &&
3907
- activity.completionSignal !== true &&
3908
- pair !== undefined) {
3909
- const routeMatches = state.progressWatches
3910
- .map((watch, watchIndex) => ({ watch, watchIndex }))
3911
- .filter(({ watch }) => watch.capability === "route" &&
3912
- watch.ownerAlias === sides.sourceAlias &&
3913
- watch.workerAlias === sides.targetAlias &&
3914
- progressWatchMatchesPair(watch, pair));
3915
- if (routeMatches.length === 1) {
3916
- this.bindRouteProgressWatchConversation(state, now, activity.conversationId, sides.sourceAlias, sides.targetAlias, pair);
3917
- index = routeMatches[0].watchIndex;
3918
- }
3919
- }
4040
+ const index = state.progressWatches.findIndex((watch) => watch.conversationId === activity.conversationId);
3920
4041
  const existing = state.progressWatches[index];
3921
4042
  if (existing !== undefined) {
3922
4043
  if (pair === undefined ||
@@ -3929,32 +4050,22 @@ export class GatewayStore {
3929
4050
  }
3930
4051
  if (activity.openIdleMs !== undefined &&
3931
4052
  activity.actorAlias !== existing.ownerAlias) {
3932
- throw new BridgeError("PROGRESS_WATCH_OWNER_REQUIRED", "Only the exact watch owner may reassert tracking options.");
4053
+ throw new BridgeError("PROGRESS_WATCH_OWNER_REQUIRED", "Tracking options persist from the original TRACK; a repeated TRACK from the exact owner refreshes activity without changing them.");
3933
4054
  }
3934
4055
  if (activity.completionSignal === true) {
3935
- const transition = transitionProgressWatch(existing, activity.actorAlias === existing.ownerAlias
3936
- ? { type: "owner_done", at: now.getTime() }
3937
- : { type: "worker_done", at: now.getTime() });
3938
- if (transition.state !== null) {
3939
- throw new BridgeError("INVALID_PROGRESS_WATCH_SETTLEMENT", "The completion signal did not settle its exact watch.");
3940
- }
3941
- this.requireProgressWatchJournalCapacity(state, 1);
4056
+ this.appendProgressWatchEvents(state, [
4057
+ progressWatchJournalEvent(existing, now.toISOString(), {
4058
+ kind: "settled",
4059
+ actor: activity.actorAlias === existing.ownerAlias
4060
+ ? "owner"
4061
+ : "worker",
4062
+ reason: "done",
4063
+ }),
4064
+ ]);
3942
4065
  state.progressWatches.splice(index, 1);
3943
- this.appendProgressWatchEvent(state, existing, {
3944
- kind: activity.actorAlias === existing.ownerAlias
3945
- ? "done"
3946
- : "worker_reported_complete",
3947
- timestamp: now.toISOString(),
3948
- });
3949
4066
  return;
3950
4067
  }
3951
- const transition = transitionProgressWatch(existing, {
3952
- type: "activity",
3953
- at: now.getTime(),
3954
- });
3955
- if (transition.state !== null) {
3956
- state.progressWatches[index] = transition.state;
3957
- }
4068
+ state.progressWatches[index] = recordProgressWatchActivity(existing, now.getTime());
3958
4069
  return;
3959
4070
  }
3960
4071
  if (activity.openIdleMs === undefined)
@@ -3965,49 +4076,29 @@ export class GatewayStore {
3965
4076
  if (pair === undefined) {
3966
4077
  throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "Progress tracking requires one exact paired consent edge.");
3967
4078
  }
3968
- const owner = state.routes.find((route) => route.alias === sides.sourceAlias);
3969
- const worker = state.routes.find((route) => route.alias === sides.targetAlias);
3970
- if (owner === undefined || worker === undefined) {
3971
- throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "Progress tracking requires both exact paired routes to be registered.");
3972
- }
3973
- const watch = createProgressWatchMachine({
4079
+ const watch = createProgressWatch({
3974
4080
  conversationId: activity.conversationId,
3975
- ownerAlias: owner.alias,
3976
- workerAlias: worker.alias,
3977
- ownerLease: owner.binding.ownerLease,
3978
- workerLease: worker.binding.ownerLease,
4081
+ ownerAlias: sides.sourceAlias,
4082
+ workerAlias: sides.targetAlias,
4083
+ ownerLease: sides.sourceAlias === pair.claudeAlias
4084
+ ? pair.claudeOwnerLease
4085
+ : pair.codexOwnerLease,
4086
+ workerLease: sides.targetAlias === pair.claudeAlias
4087
+ ? pair.claudeOwnerLease
4088
+ : pair.codexOwnerLease,
3979
4089
  idleMs: activity.openIdleMs,
3980
4090
  at: now.getTime(),
3981
4091
  });
3982
- if (!progressWatchMatchesPair(watch, pair)) {
3983
- throw new BridgeError("PROGRESS_WATCH_EDGE_MISMATCH", "The watch endpoints do not form one exact consent edge.");
3984
- }
3985
4092
  this.installProgressWatch(state, watch, pair, now);
3986
4093
  }
3987
- progressWatchDueTransition(state, watch, now) {
4094
+ inspectProgressWatchDue(state, watch, now) {
3988
4095
  const owner = state.routes.find((route) => route.alias === watch.ownerAlias &&
3989
4096
  route.binding.ownerLease === watch.ownerLease);
3990
4097
  const worker = state.routes.find((route) => route.alias === watch.workerAlias &&
3991
4098
  route.binding.ownerLease === watch.workerLease);
3992
- const pairPresent = owner !== undefined &&
3993
- worker !== undefined &&
3994
- state.pairs.some((pair) => ((pair.claudeAlias === owner.alias &&
3995
- pair.codexAlias === worker.alias) ||
3996
- (pair.claudeAlias === worker.alias &&
3997
- pair.codexAlias === owner.alias)) &&
3998
- pair.claudeOwnerLease ===
3999
- (owner.binding.provider === "claude"
4000
- ? owner.binding.ownerLease
4001
- : worker.binding.ownerLease) &&
4002
- pair.codexOwnerLease ===
4003
- (owner.binding.provider === "codex"
4004
- ? owner.binding.ownerLease
4005
- : worker.binding.ownerLease));
4006
- return transitionProgressWatch(watch, {
4007
- type: "due",
4099
+ return inspectProgressWatchDue(watch, {
4008
4100
  at: now.getTime(),
4009
4101
  bothIdle: owner?.state === "idle" && worker?.state === "idle",
4010
- endpointsPresent: pairPresent,
4011
4102
  });
4012
4103
  }
4013
4104
  commitProgressWatchNudge(state, now, nudge, sides) {
@@ -4020,17 +4111,12 @@ export class GatewayStore {
4020
4111
  sides.targetAlias !== watch.workerAlias) {
4021
4112
  throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The controller nudge no longer matches its exact watch edge.");
4022
4113
  }
4023
- const transition = this.progressWatchDueTransition(state, watch, now);
4024
- if (transition.state === null ||
4025
- !transition.effects.some((effect) => effect.type === "send_nudge" &&
4026
- effect.nudgeNumber === nudge.nudgeNumber)) {
4114
+ const due = this.inspectProgressWatchDue(state, watch, now);
4115
+ if (due.kind !== "nudge" || due.nudgeNumber !== nudge.nudgeNumber) {
4027
4116
  throw new BridgeError("PROGRESS_WATCH_NUDGE_NOT_DUE", "The requested progress-watch nudge is not currently due.", true);
4028
4117
  }
4029
- this.requireProgressWatchJournalCapacity(state, 1);
4030
- state.progressWatches[index] = transition.state;
4031
- this.appendProgressWatchEvent(state, transition.state, {
4032
- kind: "nudge",
4033
- timestamp: now.toISOString(),
4118
+ state.progressWatches[index] = commitProgressWatchNudge(watch, {
4119
+ at: now.getTime(),
4034
4120
  nudgeNumber: nudge.nudgeNumber,
4035
4121
  });
4036
4122
  }
@@ -4045,118 +4131,75 @@ export class GatewayStore {
4045
4131
  state.progressWatches[replacedIndex].ownerAlias !== watch.ownerAlias) {
4046
4132
  throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the incumbent watch owner may replace this pair watch; ask that owner to untrack it first.");
4047
4133
  }
4048
- this.requireProgressWatchJournalCapacity(state, replacedIndex < 0 ? 1 : 2);
4049
4134
  if (replacedIndex >= 0) {
4050
4135
  const replaced = state.progressWatches[replacedIndex];
4051
- state.progressWatches.splice(replacedIndex, 1);
4052
- this.appendProgressWatchEvent(state, replaced, {
4053
- kind: "replaced",
4054
- timestamp: now.toISOString(),
4055
- });
4136
+ this.appendProgressWatchEvents(state, [
4137
+ progressWatchJournalEvent(replaced, now.toISOString(), {
4138
+ kind: "replaced",
4139
+ actor: "owner",
4140
+ }),
4141
+ ]);
4142
+ state.progressWatches[replacedIndex] = watch;
4143
+ return;
4056
4144
  }
4145
+ this.appendProgressWatchEvents(state, [
4146
+ progressWatchJournalEvent(watch, now.toISOString(), {
4147
+ kind: "opened",
4148
+ actor: "owner",
4149
+ }),
4150
+ ]);
4057
4151
  state.progressWatches.push(watch);
4058
- this.appendProgressWatchEvent(state, watch, {
4059
- kind: "opened",
4060
- timestamp: now.toISOString(),
4061
- });
4062
4152
  }
4063
- requireProgressWatchJournalCapacity(state, additions) {
4064
- if (!Number.isSafeInteger(additions) ||
4065
- additions < 0 ||
4066
- state.watchSequence > Number.MAX_SAFE_INTEGER - additions) {
4153
+ appendProgressWatchEvents(state, events) {
4154
+ if (state.watchSequence > Number.MAX_SAFE_INTEGER - events.length) {
4067
4155
  throw new BridgeError("PROGRESS_WATCH_SEQUENCE_EXHAUSTED", "The bounded progress-watch journal sequence is exhausted.");
4068
4156
  }
4069
- }
4070
- appendProgressWatchEvent(state, watch, event) {
4071
- this.requireProgressWatchJournalCapacity(state, 1);
4072
- state.watchSequence += 1;
4073
- state.progressWatchEvents.push({
4074
- sequence: state.watchSequence,
4075
- timestamp: event.timestamp,
4076
- conversationId: watch.conversationId,
4077
- ownerAlias: watch.ownerAlias,
4078
- workerAlias: watch.workerAlias,
4079
- kind: event.kind,
4080
- ...(event.nudgeNumber === undefined
4081
- ? {}
4082
- : { nudgeNumber: event.nudgeNumber }),
4083
- });
4157
+ for (const event of events) {
4158
+ state.watchSequence += 1;
4159
+ state.progressWatchEvents.push({
4160
+ sequence: state.watchSequence,
4161
+ ...event,
4162
+ });
4163
+ }
4084
4164
  while (state.progressWatchEvents.length > this.config.limits.eventCapacity) {
4085
4165
  state.progressWatchEvents.shift();
4086
4166
  }
4087
4167
  }
4088
- settleProgressWatchesForAliases(state, aliases, now) {
4089
- this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => aliases.has(watch.ownerAlias) || aliases.has(watch.workerAlias)).length);
4168
+ settleProgressWatchesForAliases(state, aliases, now, actor = "gateway") {
4090
4169
  const retained = [];
4170
+ const events = [];
4091
4171
  for (const watch of state.progressWatches) {
4092
4172
  if (!aliases.has(watch.ownerAlias) &&
4093
4173
  !aliases.has(watch.workerAlias)) {
4094
4174
  retained.push(watch);
4095
4175
  continue;
4096
4176
  }
4097
- this.appendProgressWatchEvent(state, watch, {
4098
- kind: "endpoint_retired",
4099
- timestamp: now.toISOString(),
4100
- });
4177
+ events.push(progressWatchJournalEvent(watch, now.toISOString(), {
4178
+ kind: "settled",
4179
+ actor,
4180
+ reason: "endpoint_retired",
4181
+ }));
4101
4182
  }
4183
+ this.appendProgressWatchEvents(state, events);
4102
4184
  state.progressWatches = retained;
4103
4185
  }
4104
4186
  settleProgressWatchesForPair(state, pair, now) {
4105
- this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => progressWatchMatchesPair(watch, pair)).length);
4106
4187
  const retained = [];
4188
+ const events = [];
4107
4189
  for (const watch of state.progressWatches) {
4108
4190
  if (!progressWatchMatchesPair(watch, pair)) {
4109
4191
  retained.push(watch);
4110
4192
  continue;
4111
4193
  }
4112
- this.appendProgressWatchEvent(state, watch, {
4113
- kind: "pair_removed",
4114
- timestamp: now.toISOString(),
4115
- });
4194
+ events.push(progressWatchJournalEvent(watch, now.toISOString(), {
4195
+ kind: "settled",
4196
+ actor: "operator",
4197
+ reason: "pair_removed",
4198
+ }));
4116
4199
  }
4200
+ this.appendProgressWatchEvents(state, events);
4117
4201
  state.progressWatches = retained;
4118
4202
  }
4119
- bindRouteProgressWatchConversation(state, now, conversationId, ownerAlias, workerAlias, pair) {
4120
- const conversationOwner = state.progressWatches.find((watch) => watch.conversationId === conversationId);
4121
- if (conversationOwner !== undefined &&
4122
- (conversationOwner.capability !== "route" ||
4123
- conversationOwner.ownerAlias !== ownerAlias ||
4124
- conversationOwner.workerAlias !== workerAlias ||
4125
- !progressWatchMatchesPair(conversationOwner, pair))) {
4126
- throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The fresh conversation capability belongs to another exact watch.");
4127
- }
4128
- const matches = state.progressWatches
4129
- .map((watch, index) => ({ watch, index }))
4130
- .filter(({ watch }) => watch.capability === "route" &&
4131
- watch.ownerAlias === ownerAlias &&
4132
- watch.workerAlias === workerAlias &&
4133
- progressWatchMatchesPair(watch, pair));
4134
- if (matches.length === 0)
4135
- return false;
4136
- if (matches.length !== 1) {
4137
- throw new BridgeError("CORRUPT_GATEWAY_STATE", "More than one route-capability watch claims the exact pair.");
4138
- }
4139
- const match = matches[0];
4140
- this.requireProgressWatchJournalCapacity(state, 1);
4141
- const restored = transitionProgressWatch(match.watch, {
4142
- type: "restart",
4143
- at: now.getTime(),
4144
- conversationCapabilityRestored: true,
4145
- });
4146
- if (restored.state === null) {
4147
- throw new BridgeError("INVALID_PROGRESS_WATCH_REBIND", "The exact progress watch could not restore conversation authority.");
4148
- }
4149
- const rebound = {
4150
- ...restored.state,
4151
- conversationId,
4152
- };
4153
- state.progressWatches[match.index] = rebound;
4154
- this.appendProgressWatchEvent(state, rebound, {
4155
- kind: "conversation_rebound",
4156
- timestamp: now.toISOString(),
4157
- });
4158
- return true;
4159
- }
4160
4203
  finishMetadata(state, metadata, deliveryState, now, safeErrorCode) {
4161
4204
  const counterKey = deliveryState;
4162
4205
  state.accounting[counterKey] += 1;
@@ -4306,11 +4349,9 @@ export class GatewayStore {
4306
4349
  const state = this.requireState();
4307
4350
  const eventCutoff = now.getTime() - this.config.limits.eventTtlMs;
4308
4351
  state.events = state.events
4309
- .filter((event) => Date.parse(event.timestamp) > eventCutoff)
4310
- .slice(-this.config.limits.eventCapacity);
4352
+ .filter((event) => Date.parse(event.timestamp) > eventCutoff);
4311
4353
  state.progressWatchEvents = state.progressWatchEvents
4312
- .filter((event) => Date.parse(event.timestamp) > eventCutoff)
4313
- .slice(-this.config.limits.eventCapacity);
4354
+ .filter((event) => Date.parse(event.timestamp) > eventCutoff);
4314
4355
  state.dedupe = state.dedupe
4315
4356
  .filter((record) => Date.parse(record.expiresAt) > now.getTime())
4316
4357
  .slice(-this.config.limits.dedupeCapacity);
@@ -4322,37 +4363,13 @@ export class GatewayStore {
4322
4363
  const state = this.requireState();
4323
4364
  this.recoverCodexSuccessionAfterRestart(state, now);
4324
4365
  if (this.config.trackingEnabled === false) {
4325
- this.requireProgressWatchJournalCapacity(state, state.progressWatches.length);
4326
- for (const watch of state.progressWatches) {
4327
- this.appendProgressWatchEvent(state, watch, {
4328
- kind: "disabled",
4329
- timestamp: now.toISOString(),
4330
- });
4331
- }
4366
+ this.appendProgressWatchEvents(state, state.progressWatches.map((watch) => progressWatchJournalEvent(watch, now.toISOString(), {
4367
+ kind: "settled",
4368
+ actor: "gateway",
4369
+ reason: "tracking_disabled",
4370
+ })));
4332
4371
  state.progressWatches = [];
4333
4372
  }
4334
- else {
4335
- this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => !watch.degradedNoticeSent).length);
4336
- }
4337
- for (let index = 0; index < state.progressWatches.length; index += 1) {
4338
- const watch = state.progressWatches[index];
4339
- if (watch === undefined)
4340
- continue;
4341
- const transition = transitionProgressWatch(watch, {
4342
- type: "restart",
4343
- at: now.getTime(),
4344
- conversationCapabilityRestored: false,
4345
- });
4346
- if (transition.state !== null) {
4347
- state.progressWatches[index] = transition.state;
4348
- }
4349
- if (transition.effects.some((effect) => effect.type === "record_capability_degraded")) {
4350
- this.appendProgressWatchEvent(state, transition.state ?? watch, {
4351
- kind: "capability_degraded",
4352
- timestamp: now.toISOString(),
4353
- });
4354
- }
4355
- }
4356
4373
  for (const connector of state.connectors) {
4357
4374
  connector.health = "offline";
4358
4375
  connector.compatibility = "expired";
@@ -4417,9 +4434,6 @@ export class GatewayStore {
4417
4434
  aggregateHealth(connectors) {
4418
4435
  if (connectors.length === 0)
4419
4436
  return "offline";
4420
- if (connectors.some((connector) => connector.health === "incompatible")) {
4421
- return "incompatible";
4422
- }
4423
4437
  if (connectors.some((connector) => connector.health === "degraded")) {
4424
4438
  return "degraded";
4425
4439
  }
@@ -4636,7 +4650,7 @@ export class GatewayStore {
4636
4650
  // Never remove a lock that cannot be proven to belong to this instance.
4637
4651
  }
4638
4652
  }
4639
- async loadStateFile() {
4653
+ async loadStateFile(now) {
4640
4654
  let body;
4641
4655
  try {
4642
4656
  body = await this.readPrivateFile(this.stateFilePath, GATEWAY_MAX_STATE_FILE_BYTES);
@@ -4663,7 +4677,7 @@ export class GatewayStore {
4663
4677
  parsed = migratePreCodexOrphanRemovalJournal(parsed);
4664
4678
  parsed = migrateLegacyHopCounts(parsed);
4665
4679
  parsed = migrateLegacyCompatibilityCertifications(parsed);
4666
- parsed = migrateLegacyProgressWatchState(parsed, this.config.limits.eventCapacity);
4680
+ parsed = migrateV14ProgressWatchState(parsed, now.toISOString());
4667
4681
  if (!isPersistedState(parsed)) {
4668
4682
  throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict schema validation.");
4669
4683
  }
@@ -4674,8 +4688,6 @@ export class GatewayStore {
4674
4688
  parsed.rateBuckets.length > this.config.limits.maxRoutes ||
4675
4689
  parsed.progressWatches.length >
4676
4690
  (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY) ||
4677
- parsed.progressWatchEvents.length > this.config.limits.eventCapacity ||
4678
- parsed.events.length > this.config.limits.eventCapacity ||
4679
4691
  parsed.dedupe.length > this.config.limits.dedupeCapacity ||
4680
4692
  parsed.queue.length > this.config.limits.maxQueueMessages ||
4681
4693
  parsed.inFlight.length > this.config.limits.maxInFlightMessages ||
@@ -4742,7 +4754,7 @@ export class GatewayStore {
4742
4754
  throw error;
4743
4755
  let installed;
4744
4756
  try {
4745
- installed = await this.loadStateFile();
4757
+ installed = await this.loadStateFile(this.now());
4746
4758
  }
4747
4759
  catch {
4748
4760
  // Rename crossed the commit point. Keep the intended in-memory