@staix/agent-hub 0.31.0 → 0.32.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.
@@ -1,5 +1,5 @@
1
1
  /** Host integration for X4. Remote identities carry chat, never local control authority. */
2
- import { ROOM_PROOF_UNAVAILABLE_REASON, type Bus } from "../hub/bus.ts";
2
+ import { ROOM_EPOCH_ROTATED_REASON, ROOM_PROOF_UNAVAILABLE_REASON, type Bus } from "../hub/bus.ts";
3
3
  import { USER, MAX_HOP, isInvocationKind, type Envelope, type PeerState } from "../hub/envelope.ts";
4
4
  import type { RoomInvocationAuthorization } from "./boundary.ts";
5
5
  import { roomInvocationHash } from "../hub/room-invocations.ts";
@@ -13,6 +13,7 @@ import { RoomPolicy } from "./policy.ts";
13
13
  import { RemotePeer } from "./remote-peer.ts";
14
14
  import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } from "./application.ts";
15
15
  import { RoomApplicationDeferredError, RoomNotSentError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
16
+ import { ROOM_PRESENCE_MISSES, ROOM_REFRESH_MS, roomRefreshPeriod } from "./liveness.ts";
16
17
 
17
18
  export interface RoomInvocationIdentity { room: string; member: string; epoch: string; current: () => boolean }
18
19
  export interface RoomInvocationIngress {
@@ -20,7 +21,7 @@ export interface RoomInvocationIngress {
20
21
  current: () => boolean; claim: () => boolean;
21
22
  }
22
23
  export interface RoomChatOptions {
23
- bus: Bus; cwd: string; now?: () => number;
24
+ bus: Bus; cwd: string; now?: () => number; refreshMs?: number;
24
25
  routing: () => Routing; piiPaths: () => readonly string[];
25
26
  senderPrivate: (peer: string) => boolean | undefined;
26
27
  taskPrivate: (id: number) => boolean | undefined;
@@ -34,7 +35,7 @@ export interface RoomChatOptions {
34
35
  notice?: (value: { code: "application_capacity"; room: string; count: number } | { code: "sequence_gap"; room: string; sender: string; count: string }) => void;
35
36
  }
36
37
  export interface RoomMetrics { since: number; outbound: number | null; inbound: number | null; withheld: number | null }
37
- interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; online: boolean; states: Map<string, PeerState> }
38
+ interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; rosterMembers: string; online: boolean; states: Map<string, PeerState>; observedAt: Map<string, number>; observedMembers: Set<string>; tracked?: boolean }
38
39
  interface Target { binding: Binding; member: string; peer: string }
39
40
  const ACTIVE = new Set(["dispatching", "accepted", "needs_review"]);
40
41
  function completedText(reason?: RoomReceiptReason): string {
@@ -69,6 +70,9 @@ export class RoomChat {
69
70
  private readonly outgoing = new Map<string, { binding: Binding; row: RoomOutgoing }>();
70
71
  private readonly resuming = new Set<string>();
71
72
  private readonly personHeldInvocations = new Map<string, Set<string>>();
73
+ private readonly refreshMs: number;
74
+ private presenceClockHighWater?: number;
75
+ private presenceClockInvalid = false;
72
76
  private evidenceCache?: Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>;
73
77
  private timer?: ReturnType<typeof setTimeout>;
74
78
  private stopped = false;
@@ -78,8 +82,9 @@ export class RoomChat {
78
82
  readonly application: RoomLinkApplication;
79
83
  constructor(private readonly options: RoomChatOptions) {
80
84
  this.policy = new RoomPolicy(options.cwd);
85
+ this.refreshMs = roomRefreshPeriod(options.refreshMs ?? ROOM_REFRESH_MS);
81
86
  let since: number | null = null;
82
- try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) since = value; } catch { /* Observation failure never disables transport construction. */ }
87
+ try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) { since = value; this.presenceClockHighWater = value; } } catch { /* Observation failure never disables transport construction. */ }
83
88
  this.metricsSince = since;
84
89
  this.application = {
85
90
  members: context => { if (context.current()) this.bind(context.store, context.name, context.link); },
@@ -107,6 +112,43 @@ export class RoomChat {
107
112
  };
108
113
  }
109
114
  private now(): number { return this.options.now?.() ?? Date.now(); }
115
+ /** Local receipt time only; remote timestamps never participate in freshness. */
116
+ private presenceNow(mutate = true): number | undefined {
117
+ let now: number;
118
+ try { now = this.now(); } catch { now = NaN; }
119
+ if (!Number.isSafeInteger(now) || now < 0 || this.presenceClockHighWater !== undefined && now < this.presenceClockHighWater) {
120
+ if (mutate && !this.presenceClockInvalid) {
121
+ for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) binding.observedAt.clear();
122
+ }
123
+ if (mutate) this.presenceClockInvalid = true;
124
+ return undefined;
125
+ }
126
+ if (mutate) {
127
+ if (this.presenceClockHighWater === undefined || now > this.presenceClockHighWater) this.presenceClockHighWater = now;
128
+ this.presenceClockInvalid = false;
129
+ }
130
+ return now;
131
+ }
132
+ private livenessTracked(binding: Binding): boolean | undefined {
133
+ return binding.tracked;
134
+ }
135
+ private memberFresh(binding: Binding, member: string, now: number | undefined): boolean {
136
+ if (this.livenessTracked(binding) === false) return true;
137
+ if (now === undefined || this.presenceClockInvalid) return false;
138
+ const seen = binding.observedAt.get(member);
139
+ return seen !== undefined && now >= seen && now - seen < this.refreshMs * ROOM_PRESENCE_MISSES;
140
+ }
141
+ private advisoryPresence(id: string, target: Target): PeerState | "unknown" {
142
+ if (this.livenessTracked(target.binding) !== false && !this.memberFresh(target.binding, target.member, this.presenceNow(false))) return "unknown";
143
+ return target.peer === USER ? "idle" : target.binding.states.get(id) ?? "offline";
144
+ }
145
+ private advisoryLiveness(target: Target): "untracked" | undefined {
146
+ return this.livenessTracked(target.binding) === false ? "untracked" : undefined;
147
+ }
148
+ private targetSendable(id: string): boolean {
149
+ const target = this.targets.get(id);
150
+ return !!target && target.binding.online && this.valid(target.binding);
151
+ }
110
152
  private wakeRemotes(binding: Binding): void {
111
153
  for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.wake();
112
154
  }
@@ -145,11 +187,18 @@ export class RoomChat {
145
187
  bind(store: RoomStore, name: string, link: RoomLink): void {
146
188
  if (this.stopped) return;
147
189
  const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
148
- const room = state.current.room, counted = this.counts.get(name);
190
+ const room = state.current.room, rosterMembers = roomMembers(state.current).map(member => member.fingerprint).sort().join(","), counted = this.counts.get(name);
149
191
  if (counted?.room !== room && (counted || this.counts.size < 64)) this.counts.set(name, { room, outbound: 0, inbound: 0, withheld: 0 });
150
192
  const saved = this.bindings.get(name);
151
- const binding = saved?.store === store && saved.link === link && saved.room === room ? saved : { store, name, room, link, digest, online: false, states: new Map<string, PeerState>() };
152
- if (binding.digest !== digest) { binding.states.clear(); binding.online = false; binding.digest = digest; }
193
+ const binding: Binding = saved?.store === store && saved.link === link && saved.room === room ? saved : {
194
+ store, name, room, link, digest, rosterMembers, online: false, states: new Map<string, PeerState>(), observedAt: new Map<string, number>(), observedMembers: new Set<string>(),
195
+ };
196
+ const tracked = link.livenessTracked;
197
+ if (binding.digest !== digest || binding.rosterMembers !== rosterMembers
198
+ || tracked !== undefined && binding.tracked !== undefined && tracked !== binding.tracked) {
199
+ binding.states.clear(); binding.observedAt.clear(); binding.observedMembers.clear(); binding.online = false; binding.digest = digest; binding.rosterMembers = rosterMembers;
200
+ }
201
+ if (tracked !== undefined) binding.tracked = tracked;
153
202
  this.bindings.set(name, binding);
154
203
  this.reconcileDispositions(binding);
155
204
  this.refreshInvocationHolds(binding);
@@ -165,6 +214,14 @@ export class RoomChat {
165
214
  } };
166
215
  }
167
216
  private refreshTargets(): void {
217
+ const now = this.presenceNow();
218
+ // Consume an expired observation once. Keeping its timestamp makes every
219
+ // later queue callback revisit this branch and can re-reserve settled rows.
220
+ if (now !== undefined) for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) {
221
+ for (const [member, seenAt] of binding.observedAt) {
222
+ if (now >= seenAt && now - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES) binding.observedAt.delete(member);
223
+ }
224
+ }
168
225
  const candidates = new Map<string, Target[]>();
169
226
  for (const binding of this.bindings.values()) {
170
227
  const state = binding.store.get(binding.name);
@@ -225,7 +282,7 @@ export class RoomChat {
225
282
  this.count(binding, "outbound", withheld === true);
226
283
  }
227
284
  finally { this.refreshOutgoing(binding); this.queueChanged(); }
228
- }, ready: () => this.options.bus.remoteSendAllowed(remote!), waiting: () => this.queueChanged(),
285
+ }, ready: () => this.options.bus.remoteSendAllowed(remote!) && this.targetSendable(id), waiting: () => this.queueChanged(),
229
286
  notSent: deliveryId => this.options.bus.requeueRemoteNotSent(remote!, deliveryId),
230
287
  queued: deliveryId => {
231
288
  const selected = this.targets.get(id);
@@ -292,7 +349,8 @@ export class RoomChat {
292
349
  }
293
350
  for (const [id, remote] of this.remotes) {
294
351
  const target = this.targets.get(id);
295
- remote.presence(target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline", target?.binding.online === true);
352
+ const state = target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline";
353
+ remote.presence(state, target?.binding.online === true);
296
354
  }
297
355
  this.options.roster?.([...this.targets.keys()]);
298
356
  }
@@ -302,14 +360,21 @@ export class RoomChat {
302
360
  && this.policy.get(binding.name).includes(peer)
303
361
  && roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
304
362
  }
305
- presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[] }): void {
363
+ presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }): void {
306
364
  const binding = this.bindings.get(name), state = store.get(name);
307
365
  if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
308
366
  const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
309
367
  if (!member) return;
368
+ if (value.restored && binding.observedMembers.has(value.member)) return;
310
369
  for (const peer of member.exposedPeers) binding.states.delete(`${peer}@${member.label}`);
311
370
  for (const peer of value.peers) if (member.exposedPeers.includes(peer.id)) binding.states.set(`${peer.id}@${member.label}`, peer.state);
371
+ const now = this.presenceNow();
372
+ if (value.restored !== true) {
373
+ binding.observedMembers.add(member.fingerprint);
374
+ if (now !== undefined) binding.observedAt.set(member.fingerprint, now);
375
+ }
312
376
  this.refreshTargets();
377
+ this.queueChanged();
313
378
  }
314
379
  linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
315
380
  // stop() fences callbacks as part of ordinary shutdown; it is not a room
@@ -339,6 +404,9 @@ export class RoomChat {
339
404
  return [...new Set([...this.outgoing.values()].filter(({ binding, row }) => binding.store.get(binding.name)?.current.room === room && ACTIVE.has(row.state) && !row.disposition).map(({ row }) => row.application))];
340
405
  }
341
406
  private outcome(binding: Binding, row: RoomOutgoing): void {
407
+ // Refresh this snapshot before settlement callbacks synchronously re-enter
408
+ // queueChanged through Bus.onQueues.
409
+ this.refreshOutgoing(binding);
342
410
  const remote = this.remotes.get(row.remotePeer);
343
411
  if (row.state === "needs_review" && !row.disposition) {
344
412
  const journalRow = this.options.bus.queueShow(row.deliveryId);
@@ -356,6 +424,7 @@ export class RoomChat {
356
424
  }
357
425
  private settleStoredOutcome(binding: Binding, remote: RemotePeer | undefined, row: RoomOutgoing): boolean {
358
426
  if ((row.state !== "completed" && row.state !== "failed_safe") || row.disposition) return false;
427
+ this.refreshOutgoing(binding);
359
428
  const outcome = row.lateReceipt?.outcome ?? row.state;
360
429
  const reason = row.lateReceipt?.reason ?? row.reason ?? (outcome === "completed" ? "admitted" : "boundary_refused");
361
430
  const journalRow = this.options.bus.queueShow(row.deliveryId);
@@ -433,6 +502,7 @@ export class RoomChat {
433
502
  if (!this.valid(binding)) return;
434
503
  let rows: RoomOutgoing[];
435
504
  try { rows = binding.store.applicationOutbox(binding.name); } catch { return; }
505
+ this.refreshOutgoing(binding);
436
506
  for (const row of rows) {
437
507
  if (row.disposition) continue;
438
508
  const remote = this.remotes.get(row.remotePeer);
@@ -443,7 +513,7 @@ export class RoomChat {
443
513
  const current = this.currentOutgoingRow(binding, row);
444
514
  if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) continue;
445
515
  const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current);
446
- if (quarantined) remote.settled(row.deliveryId);
516
+ if (quarantined) { this.refreshOutgoing(binding); remote.settled(row.deliveryId); }
447
517
  } else if (row.state === "needs_review") this.holdStoreNeedsReview(binding, row, remote);
448
518
  }
449
519
  this.refreshOutgoing(binding);
@@ -566,6 +636,16 @@ export class RoomChat {
566
636
  if (this.timer) clearTimeout(this.timer);
567
637
  this.timer = undefined; if (this.stopped || !this.bindings.size) return;
568
638
  let next = this.options.bus.nextRemoteExpiry();
639
+ const presenceNow = this.presenceNow();
640
+ if (presenceNow !== undefined) {
641
+ if ([...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
642
+ presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
643
+ for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) for (const seenAt of binding.observedAt.values()) {
644
+ const staleAt = seenAt + this.refreshMs * ROOM_PRESENCE_MISSES;
645
+ if (staleAt <= presenceNow) continue;
646
+ next = next === undefined ? staleAt : Math.min(next, staleAt);
647
+ }
648
+ }
569
649
  for (const { binding, row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
570
650
  const due = Math.min(row.expiresAt, binding.online ? row.ackDeadline ?? Infinity : Infinity); next = next === undefined ? due : Math.min(next, due);
571
651
  }
@@ -588,6 +668,9 @@ export class RoomChat {
588
668
  }
589
669
  tick(): void {
590
670
  if (this.stopped) return;
671
+ const presenceNow = this.presenceNow();
672
+ if (presenceNow === undefined || [...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
673
+ presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
591
674
  this.options.bus.expireRemoteQueues(this.now());
592
675
  for (const binding of this.bindings.values()) if (this.valid(binding)) {
593
676
  try {
@@ -713,16 +796,18 @@ export class RoomChat {
713
796
  }
714
797
  private reconcileDispositions(binding: Binding): boolean {
715
798
  let complete = true;
799
+ const settled: Array<{ peer: string; deliveryId: string }> = [];
716
800
  for (const row of binding.store.applicationOutbox(binding.name)) if (!row.disposition) {
717
801
  const action = this.options.bus.remoteDisposition(row.deliveryId);
718
802
  if (action) try {
719
803
  const disposed = binding.store.disposeApplication(binding.name, row.deliveryId, action, true, () => this.valid(binding)
720
804
  && binding.store.get(binding.name)?.current.digest === binding.digest && this.options.bus.remoteDisposition(row.deliveryId) === action);
721
- if (disposed?.disposition === action) this.remotes.get(row.remotePeer)?.settled(row.deliveryId);
805
+ if (disposed?.disposition === action) settled.push({ peer: row.remotePeer, deliveryId: row.deliveryId });
722
806
  }
723
807
  catch { complete = false; }
724
808
  }
725
809
  this.refreshOutgoing(binding);
810
+ for (const row of settled) this.remotes.get(row.peer)?.settled(row.deliveryId);
726
811
  return complete;
727
812
  }
728
813
  disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
@@ -734,7 +819,10 @@ export class RoomChat {
734
819
  const disposed = binding.store.disposeApplication(binding.name, deliveryId, action, true, () => this.valid(binding)
735
820
  && binding.store.get(binding.name)?.current.room === room && binding.store.get(binding.name)?.current.digest === digest
736
821
  && this.options.bus.remoteDisposition(deliveryId) === action);
737
- if (disposed?.disposition === action) this.remotes.get(peer)?.settled(deliveryId);
822
+ if (disposed?.disposition === action) {
823
+ this.refreshOutgoing(binding);
824
+ this.remotes.get(peer)?.settled(deliveryId);
825
+ }
738
826
  this.personHeldInvocations.get(binding.name)?.delete(deliveryId);
739
827
  this.refreshOutgoing(binding); break;
740
828
  }
@@ -756,20 +844,56 @@ export class RoomChat {
756
844
  const row = this.evidenceCache.get(deliveryId); if (!row || row.peer !== peer) return undefined;
757
845
  const { peer: _peer, ...view } = row; return view;
758
846
  }
847
+ /** Hold only the sealed intents captured by the person's committed epoch change. */
848
+ epochRotated(store: RoomStore, name: string, previousDigest: string, expected: readonly RoomOutgoing[]): boolean {
849
+ const state = store.get(name);
850
+ if (this.stopped || !state || state.left || state.current.digest === previousDigest) return false;
851
+ const binding = this.bindings.get(name);
852
+ if (binding?.store === store && binding.room === state.current.room) {
853
+ // Releasing an old FIFO head must not drain queued work using old presence.
854
+ binding.online = false; binding.states.clear(); binding.observedAt.clear();
855
+ for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.presence("offline", false);
856
+ }
857
+ let complete = true;
858
+ for (const before of expected) {
859
+ const current = () => {
860
+ const room = store.get(name), row = store.applicationOutbox(name).find(row => row.deliveryId === before.deliveryId);
861
+ return !this.stopped && !!room && !room.left && room.current.digest === state.current.digest
862
+ && !!row && row.application === before.application && row.remotePeer === before.remotePeer && row.recipient === before.recipient
863
+ && row.state === "needs_review" && row.reason === "boundary_refused" && !row.disposition;
864
+ };
865
+ if (!current()) { complete = false; continue; }
866
+ if (!this.options.bus.holdRemoteEpochRotated(before.remotePeer, before.deliveryId, state.current.room, before.recipient, current)) {
867
+ complete = false; continue;
868
+ }
869
+ const remote = this.remotes.get(before.remotePeer);
870
+ if (remote?.remoteRoom === state.current.room && remote.remoteMember === before.recipient) remote.settled(before.deliveryId);
871
+ }
872
+ this.evidenceCache = undefined;
873
+ return complete;
874
+ }
759
875
  rows(name: string): Record<string, unknown>[] {
760
876
  const binding = this.bindings.get(name); if (!binding) return [];
761
877
  const incoming = binding.store.applicationStatus(name);
762
- return [...binding.store.applicationOutbox(name).sort((a, b) => Number(!ACTIVE.has(a.state) || !!a.disposition) - Number(!ACTIVE.has(b.state) || !!b.disposition) || b.createdAt - a.createdAt).slice(0, 200).map(row => ({ direction: "outbound", id: row.deliveryId, application: row.application, peer: row.remotePeer, state: row.state === "completed" ? completedText(row.reason) : row.state, ...(row.disposition ? { disposition: row.disposition } : {}), ...(row.lateReceipt ? { lateReceipt: lateReceiptText(row.lateReceipt) } : {}), ...(refusalReason(row) ? { refusalReason: refusalReason(row) } : {}) })),
878
+ const outgoing = binding.store.applicationOutbox(name).sort((a, b) => Number(!ACTIVE.has(a.state) || !!a.disposition) - Number(!ACTIVE.has(b.state) || !!b.disposition) || b.createdAt - a.createdAt).slice(0, 200), epochHolds = new Set<string>();
879
+ for (const peer of new Set(outgoing.map(row => row.remotePeer))) {
880
+ for (const row of this.options.bus.queueList(peer, true)) if (row.state === "needs_review" && row.reason === `${ROOM_EPOCH_ROTATED_REASON} ${row.id}`) epochHolds.add(row.id);
881
+ }
882
+ return [...outgoing.map(row => ({ direction: "outbound", id: row.deliveryId, application: row.application, peer: row.remotePeer, state: row.state === "completed" ? completedText(row.reason) : row.state, ...(row.disposition ? { disposition: row.disposition } : {}), ...(row.lateReceipt ? { lateReceipt: lateReceiptText(row.lateReceipt) } : {}), ...(epochHolds.has(row.deliveryId) ? { holdReason: "epoch rotated" } : refusalReason(row) ? { refusalReason: refusalReason(row) } : {}) })),
763
883
  ...incoming.rows.map(row => ({ direction: "inbound", id: row.digest, application: row.application, state: row.state === "disposed" ? `disposed (${row.disposition}, by person)` : row.state, peer: row.peer, ...(row.disposedAt ? { disposedAt: row.disposedAt } : {}) })),
764
884
  { direction: "inbound", pending: incoming.pending, needsReview: incoming.needsReview, truncated: incoming.truncated }];
765
885
  }
766
- peers(name: string): { id: string; state: PeerState }[] {
767
- return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER).map(([id]) => ({ id, state: this.remotes.get(id)?.state ?? "offline" })).sort((a, b) => a.id.localeCompare(b.id));
886
+ peers(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] {
887
+ return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER)
888
+ .map(([id, target]) => ({ id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) })).sort((a, b) => a.id.localeCompare(b.id));
768
889
  }
769
890
  /** A bounded status list, separate from the explicit detail query above. */
770
- peersForStatus(name: string): { id: string; state: PeerState }[] | null {
891
+ peersForStatus(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] | null {
771
892
  const ids = this.statusTargets.get(name);
772
- return ids ? ids.map(id => ({ id, state: this.remotes.get(id)?.state ?? "offline" })) : null;
893
+ return ids ? ids.map(id => {
894
+ const target = this.targets.get(id)!;
895
+ return { id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) };
896
+ }) : null;
773
897
  }
774
898
  stop(): void {
775
899
  if (this.stopped) return; this.stopped = true;
@@ -18,6 +18,8 @@ const PEER_STATES = new Set<PeerState>(["idle", "busy", "paused", "offline"]);
18
18
  export interface RoomStatusPeer {
19
19
  id: string;
20
20
  state: PeerState;
21
+ presence?: PeerState | "unknown";
22
+ liveness?: "untracked";
21
23
  }
22
24
 
23
25
  export interface RoomRelayStats {
@@ -95,8 +97,13 @@ function remotePeers(value: unknown, remaining: number): RoomStatusPeer[] | null
95
97
  if (!plainRecord(item)) return null;
96
98
  const id = ownData(item, "id"), state = ownData(item, "state");
97
99
  if (typeof id !== "string" || !parseRemotePeerId(id) || typeof state !== "string" || !PEER_STATES.has(state as PeerState) || ids.has(id)) return null;
100
+ const presenceField = ownField(item, "presence"), livenessField = ownField(item, "liveness");
101
+ if (presenceField.present && !Object.hasOwn(presenceField, "value") || livenessField.present && !Object.hasOwn(livenessField, "value")) return null;
102
+ const presence = presenceField.value, liveness = livenessField.value;
103
+ if (presence !== undefined && presence !== "unknown" && (typeof presence !== "string" || !PEER_STATES.has(presence as PeerState))) return null;
104
+ if (liveness !== undefined && liveness !== "untracked") return null;
98
105
  ids.add(id);
99
- result.push({ id, state: state as PeerState });
106
+ result.push({ id, state: state as PeerState, ...(presence !== undefined ? { presence: presence as RoomStatusPeer["presence"] } : {}), ...(liveness === "untracked" ? { liveness } : {}) });
100
107
  }
101
108
  return result;
102
109
  }
@@ -673,6 +673,12 @@ export class RoomStore {
673
673
  ON CONFLICT(name) DO UPDATE SET epoch=excluded.epoch,digest=excluded.digest,wire=excluded.wire,previous_wire=excluded.previous_wire,previous_digest=excluded.previous_digest,previous_epoch=excluded.previous_epoch,previous_expires=excluded.previous_expires,left=0`)
674
674
  .run(name, current.room, broker, decodeRoster(wire).owner, current.epoch, current.digest, wire, prior ? priorWire! : null, prior?.epoch.digest ?? null, prior?.epoch.epoch ?? null, prior?.expiresAt ?? null);
675
675
  this.db.query("DELETE FROM pending_joins WHERE name=?").run(name);
676
+ if (previous && !same) {
677
+ // Epoch adoption and the old sealed intents' send fence commit together.
678
+ // The local Bus names the rotation; no new signed receipt reason is introduced.
679
+ this.db.query("UPDATE application_outbox SET state='needs_review',reason='boundary_refused' WHERE room=? AND state IN ('dispatching','accepted') AND disposition IS NULL").run(current.room);
680
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT t.digest FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id WHERE t.room=? AND a.state='needs_review' AND a.reason='boundary_refused')").run(current.room, current.room);
681
+ }
676
682
  return next;
677
683
  }
678
684
  async install(input: { name: string; broker: string; wire: Uint8Array; expectedDigest: string | null; current?: () => boolean; reactivate?: boolean }): Promise<RoomState> {
@@ -1102,6 +1108,10 @@ export class RoomStore {
1102
1108
  const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
1103
1109
  return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
1104
1110
  }
1111
+ /** Sealed, unresolved intents that a person must see before changing the epoch. */
1112
+ rotationPending(name: string): RoomOutgoing[] {
1113
+ return this.applicationOutbox(name, true).filter(row => row.state === "dispatching" || row.state === "accepted");
1114
+ }
1105
1115
  /** Compatibility no-op for retryable rows; only original TTL expiry creates needs_review. */
1106
1116
  quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
1107
1117
  const state = this.get(name); if (!state) return undefined;
package/src/ui/index.html CHANGED
@@ -1674,8 +1674,10 @@ function renderRooms(rooms) {
1674
1674
  const peers = el('div', undefined, 'peer-summary');
1675
1675
  for (const peer of room.remotePeers) {
1676
1676
  const id = roomMetadataText(peer.id, /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/, 65);
1677
- const state = ['idle', 'busy', 'paused', 'offline'].includes(peer.state) ? peer.state : 'unknown';
1678
- peers.append(el('span', id + ': ' + state, 'meta'));
1677
+ const observed = peer.presence ?? peer.state;
1678
+ const state = ['idle', 'busy', 'paused', 'offline', 'unknown'].includes(observed) ? observed : 'unknown';
1679
+ const note = peer.liveness === 'untracked' ? '; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness' : '';
1680
+ peers.append(el('span', id + ': ' + state + note, 'meta'));
1679
1681
  }
1680
1682
  row.append(peers);
1681
1683
  }