pi-straw-boss 0.59.0 → 0.59.1

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.
@@ -8036,8 +8036,8 @@ function createGuiHost(o) {
8036
8036
  dialogs: () => ({
8037
8037
  hub: theHub(),
8038
8038
  read: async () => void await (forwardNet ??= netSource({ ...o.tower ? { services: (workspace) => o.tower.view(workspace) } : {}, run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read(),
8039
- /** The pane of a unit by the key the hub gives its dialogs (known after a `read`). */
8040
- paneOf: (key2) => forwardNet?.lookup(key2)?.pane
8039
+ /** The unit the hub files dialogs under `key` (known after a `read`): its pane for the dispatcher, its name and role for the label a controller is told. */
8040
+ lookup: (key2) => forwardNet?.lookup(key2)
8041
8041
  }),
8042
8042
  /** At the server's start: the GUI on the recorded ports unless it was stopped on purpose (which only the boss's own command undoes). */
8043
8043
  async start() {
@@ -8446,6 +8446,9 @@ var MAX_SEEN = 1024;
8446
8446
  var UNANSWERED_MS = 30 * 6e4;
8447
8447
  var REFRESH_MS = 10 * 6e4;
8448
8448
  var UNKNOWN_RECHECK_MS = 15e3;
8449
+ var PASS_STUCK_MS = 6e4;
8450
+ var CLOSE_TRIES = 5;
8451
+ var MAX_CLOSES = 256;
8449
8452
  var DialogForwarder = class {
8450
8453
  d;
8451
8454
  open = /* @__PURE__ */ new Map();
@@ -8453,12 +8456,29 @@ var DialogForwarder = class {
8453
8456
  seen = /* @__PURE__ */ new Set();
8454
8457
  off;
8455
8458
  polling;
8459
+ /** The closes not posted yet, per controller and dialog, drained by every pass until each is posted. */
8460
+ closes = /* @__PURE__ */ new Map();
8461
+ /** The passes let go as stuck that have not returned; at most one is outstanding, so a call that never returns costs one pending pass and not one a minute. */
8462
+ released = /* @__PURE__ */ new Set();
8463
+ /** The line that a second stuck pass is not let go was written for this stretch. */
8464
+ hung = false;
8456
8465
  again = false;
8466
+ /** The pass that counts: a pass let go as stuck has an older number and stops at its next turn. */
8467
+ gen = 0;
8468
+ /** When the pass in flight began, and the call it waits at (`dialog-pass-stuck` names it). */
8469
+ passAt = 0;
8470
+ step = "idle";
8471
+ /** What the last `dialog-controllers` line said, so a line is written when it changes and not at every pass. */
8472
+ counted;
8473
+ /** The room for open questions was used up, and the line that says so was written. */
8474
+ full = false;
8457
8475
  constructor(d) {
8458
8476
  this.d = d;
8459
8477
  }
8460
8478
  start() {
8461
- this.off ??= this.d.hub.onChange(() => void this.poll());
8479
+ if (this.off) return;
8480
+ this.off = this.d.hub.onChange(() => void this.poll());
8481
+ this.d.log({ event: "dialog-forwarder", state: "started" });
8462
8482
  }
8463
8483
  stop() {
8464
8484
  this.off?.();
@@ -8468,19 +8488,37 @@ var DialogForwarder = class {
8468
8488
  tick() {
8469
8489
  return this.poll();
8470
8490
  }
8471
- /** Looks at the hub now; calls that overlap run once more after the one in flight. */
8491
+ /** Looks at the hub now; calls that overlap run once more after the one in flight, and a pass in flight for `PASS_STUCK_MS` is let go (its next look is a new pass). */
8472
8492
  poll() {
8473
- if (this.polling) return this.again = true, this.polling;
8493
+ if (this.polling) {
8494
+ if (this.now() - this.passAt < PASS_STUCK_MS) return this.again = true, this.polling;
8495
+ if (this.released.size) {
8496
+ if (!this.hung) this.d.log({ event: "dialog-pass-hung", step: this.step, ms: this.now() - this.passAt });
8497
+ this.hung = true;
8498
+ return this.again = true, this.polling;
8499
+ }
8500
+ this.d.log({ event: "dialog-pass-stuck", step: this.step, ms: this.now() - this.passAt });
8501
+ this.gen++;
8502
+ this.released.add(this.polling);
8503
+ this.polling = void 0;
8504
+ for (const d of this.open.values()) d.sent.clear();
8505
+ }
8506
+ const gen = this.gen;
8474
8507
  const run3 = (async () => {
8475
8508
  do {
8476
8509
  this.again = false;
8510
+ this.passAt = this.now();
8477
8511
  try {
8478
- await this.pass();
8512
+ await this.pass(gen);
8479
8513
  } catch (error) {
8480
- this.d.log({ event: "dialog-forward-failed", error: String(error.message).slice(0, 200) });
8514
+ this.d.log({ event: "dialog-forward-failed", step: this.step, error: String(error.message).slice(0, 200) });
8481
8515
  }
8482
- } while (this.again);
8483
- })().finally(() => this.polling = void 0);
8516
+ } while (this.again && gen === this.gen);
8517
+ })().finally(() => {
8518
+ if (this.polling === run3) this.polling = void 0;
8519
+ this.released.delete(run3);
8520
+ if (!this.released.size) this.hung = false;
8521
+ });
8484
8522
  return this.polling = run3;
8485
8523
  }
8486
8524
  /** Whose unit asked: looked up until it is known, then kept. A lookup that fails leaves it unknown, which is the admins' alone. */
@@ -8492,10 +8530,27 @@ var DialogForwarder = class {
8492
8530
  scopeOf(d) {
8493
8531
  return this.d.dispatcher ? d.who ? { device: d.who.device, ...d.who.team !== void 0 ? { team: d.who.team } : {} } : {} : EVERYONE;
8494
8532
  }
8495
- async pass() {
8533
+ /** Records where the pass in flight waits (`dialog-pass-stuck` names it); a pass let go as stuck no longer writes it. */
8534
+ at(gen, step) {
8535
+ if (gen === this.gen) this.step = step;
8536
+ }
8537
+ /** One line when the controllers the forwarder sees change in number or in whether the list is complete, so a box with nobody to tell says so once (and the first pass says how it starts). */
8538
+ count(controllers, known2) {
8539
+ const line2 = `${controllers}/${known2}`;
8540
+ if (line2 === this.counted) return;
8541
+ this.counted = line2;
8542
+ this.d.log({ event: "dialog-controllers", count: controllers, known: known2, open: this.open.size });
8543
+ }
8544
+ async pass(gen) {
8496
8545
  const to = this.d.controllers();
8546
+ this.count(to.length, this.d.controllersKnown?.() ?? true);
8547
+ if (this.closes.size && !await this.drain(gen)) return;
8497
8548
  if (!to.length && !this.open.size) return;
8498
- if (to.length) await this.d.read();
8549
+ if (to.length) {
8550
+ this.at(gen, "read");
8551
+ await this.d.read();
8552
+ if (gen !== this.gen) return;
8553
+ }
8499
8554
  for (const o of to.length ? this.d.hub.answerable() : []) {
8500
8555
  const id = o.target.answer.dialog;
8501
8556
  if (this.open.has(id) || this.skipped.has(id)) continue;
@@ -8504,19 +8559,32 @@ var DialogForwarder = class {
8504
8559
  this.skipped.add(id);
8505
8560
  this.d.log({ event: "dialog-oversize", dialog: id, bytes: Buffer.byteLength(text5) });
8506
8561
  const unit = { key: o.key };
8562
+ this.at(gen, "dispatcher");
8507
8563
  await this.whose(unit);
8508
- await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${o.owner} was not forwarded: it is ${Buffer.byteLength(text5)} bytes, over the ${LIMITS2.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
8564
+ this.at(gen, "notice");
8565
+ await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${this.labelOf(o.key, o.owner)} was not forwarded: it is ${Buffer.byteLength(text5)} bytes, over the ${LIMITS2.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
8566
+ if (gen !== this.gen) return;
8567
+ continue;
8568
+ }
8569
+ if (this.open.size >= MAX_OPEN2) {
8570
+ if (!this.full) this.d.log({ event: "dialog-full", open: this.open.size });
8571
+ this.full = true;
8509
8572
  continue;
8510
8573
  }
8511
- if (this.open.size >= MAX_OPEN2) continue;
8512
- this.open.set(id, { key: o.key, target: o.target, owner: o.owner, text: text5, sent: /* @__PURE__ */ new Map(), away: /* @__PURE__ */ new Set(), claimed: false, since: this.now(), told: false });
8574
+ this.open.set(id, { key: o.key, target: o.target, owner: o.owner, label: this.labelOf(o.key, o.owner), text: text5, sent: /* @__PURE__ */ new Map(), away: /* @__PURE__ */ new Set(), claimed: false, since: this.now(), told: false });
8575
+ this.d.log({ event: "dialog-seen", dialog: id, unit: o.key, owner: o.owner });
8513
8576
  }
8514
8577
  for (const [id, d] of [...this.open]) {
8578
+ this.at(gen, "dispatcher");
8515
8579
  await this.whose(d);
8580
+ if (gen !== this.gen) return;
8516
8581
  const state = this.d.hub.outcome(id);
8517
8582
  if (!state.open) {
8518
8583
  this.open.delete(id);
8519
- for (const k of d.sent.keys()) await this.send(k, "dialog-close", { dialogId: id, by: state.by });
8584
+ this.full = false;
8585
+ this.d.log({ event: "dialog-ended", dialog: id, by: state.by, sent: d.sent.size });
8586
+ for (const k of d.sent.keys()) this.hold(k, id, state.by);
8587
+ if (!await this.drain(gen)) return;
8520
8588
  continue;
8521
8589
  }
8522
8590
  if (d.claimed && d.unknownAt !== void 0 && this.now() - d.unknownAt >= UNKNOWN_RECHECK_MS) {
@@ -8529,10 +8597,13 @@ var DialogForwarder = class {
8529
8597
  if (mine.some((c) => `${c.team}
8530
8598
  ${c.device}` === k)) continue;
8531
8599
  d.sent.delete(k);
8532
- await this.send(k, "dialog-close", { dialogId: id, by: "withdrawn" });
8600
+ this.hold(k, id, "withdrawn");
8601
+ if (!await this.drain(gen)) return;
8533
8602
  }
8534
8603
  if (!d.told && !d.claimed && to.length && this.now() - d.since >= UNANSWERED_MS) {
8535
- const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.owner} has waited ${Math.round((this.now() - d.since) / 6e4)} minutes for an answer.` }, this.scopeOf(d));
8604
+ this.at(gen, "notice");
8605
+ const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.label} has waited ${Math.round((this.now() - d.since) / 6e4)} minutes for an answer.` }, this.scopeOf(d));
8606
+ if (gen !== this.gen) return;
8536
8607
  d.told = sent !== false;
8537
8608
  }
8538
8609
  if (!mine.length && !d.unrouted) {
@@ -8548,16 +8619,53 @@ ${c.device}`;
8548
8619
  if (at !== void 0) continue;
8549
8620
  } else if (at !== void 0 && !d.away.delete(k) && this.now() - at < REFRESH_MS) continue;
8550
8621
  d.sent.set(k, this.now());
8551
- if (!await this.send(k, "dialog-open", { dialogId: id, digest: d.target.answer.digest, owner: d.owner, dialog: d.text })) d.sent.delete(k);
8622
+ this.at(gen, "post");
8623
+ const posted = await this.send(k, "dialog-open", { dialogId: id, digest: d.target.answer.digest, owner: d.label, dialog: d.text });
8624
+ if (gen !== this.gen) return;
8625
+ if (!posted) d.sent.delete(k);
8552
8626
  }
8553
8627
  }
8554
8628
  for (const id of [...this.skipped]) if (!this.d.hub.outcome(id).open) this.skipped.delete(id);
8555
8629
  }
8630
+ /** Keeps a close to post: the oldest is given up when the room is used up. */
8631
+ hold(k, dialogId, by) {
8632
+ this.closes.set(`${k}
8633
+ ${dialogId}`, { k, dialogId, by, at: this.now(), tries: 0 });
8634
+ for (const [key2, c] of this.closes) {
8635
+ if (this.closes.size <= MAX_CLOSES) break;
8636
+ this.closes.delete(key2);
8637
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "full" });
8638
+ }
8639
+ }
8640
+ /** Posts the held closes, each removed once posted. One that failed `CLOSE_TRIES` times, or whose card has expired at the controller (`OPEN_STALE_MS`), is given up with a line. False when this pass was let go meanwhile. */
8641
+ async drain(gen) {
8642
+ for (const [key2, c] of [...this.closes]) {
8643
+ if (this.now() - c.at > OPEN_STALE_MS) {
8644
+ this.closes.delete(key2);
8645
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "stale" });
8646
+ continue;
8647
+ }
8648
+ this.at(gen, "post");
8649
+ const posted = await this.send(c.k, "dialog-close", { dialogId: c.dialogId, by: c.by });
8650
+ if (posted) this.closes.delete(key2);
8651
+ else if (++c.tries >= CLOSE_TRIES) {
8652
+ this.closes.delete(key2);
8653
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "failed" });
8654
+ }
8655
+ if (gen !== this.gen) return false;
8656
+ }
8657
+ return true;
8658
+ }
8556
8659
  now = () => this.d.now?.() ?? Date.now();
8660
+ /** What the controllers are told asks: the unit's name when it is known, else the owner of the dialog. */
8661
+ labelOf(key2, owner) {
8662
+ return this.d.unit?.(key2) || owner;
8663
+ }
8557
8664
  async send(k, kind, payload) {
8558
8665
  const [team, device] = k.split("\n");
8559
8666
  try {
8560
8667
  await this.d.post(team, device, kind, payload);
8668
+ this.d.log({ event: "dialog-sent", kind, dialog: payload.dialogId, team, to: device });
8561
8669
  return true;
8562
8670
  } catch (error) {
8563
8671
  this.d.log({ event: "dialog-post-failed", kind, dialog: payload.dialogId, error: String(error.message).slice(0, 200) });
@@ -8664,6 +8772,13 @@ function dispatcherLookup(d) {
8664
8772
  return panes3 ? dispatcherOf(pane, panes3, d.allyPanes()) : void 0;
8665
8773
  };
8666
8774
  }
8775
+ var KIND = { main: "main", member: "member", subagent: "subagent", ally: "ally run", run: "run" };
8776
+ function unitLabel(found) {
8777
+ const name2 = allyLabel(found?.name);
8778
+ if (!found || !/[\p{L}\p{N}]/u.test(name2)) return void 0;
8779
+ const kind = KIND[found.role];
8780
+ return kind ? `${kind} - ${name2}` : name2;
8781
+ }
8667
8782
 
8668
8783
  // src/server/opentower.ts
8669
8784
  import { realpathSync as realpathSync7 } from "node:fs";
@@ -11353,6 +11468,7 @@ ${Buffer5.from(header2.id).toString("hex")}`).digest("hex").slice(0, 16);
11353
11468
  const sender = roster.members.get(header2.from);
11354
11469
  if (!(this.o.attended?.() ?? true) || !sender || sender.attended) return void this.o.store.log({ event: "drop", team: run3.team, reason: "dialog-open-unexpected", from: header2.from, kind: "dialog-open" });
11355
11470
  const fresh = this.dialogViews.put({ team: run3.team, device: header2.from, dialogId: p.dialogId, digest: p.digest, owner: p.owner, dialog: p.dialog, at: this.now() });
11471
+ this.o.store.log({ event: "dialog-in", team: run3.team, from: header2.from, dialog: p.dialogId, fresh });
11356
11472
  if (fresh) this.o.notify(`Straw Link: ${sender.label} asks a question (${restrictLabel(p.owner)})`, "warning");
11357
11473
  }
11358
11474
  takeDialogClose(header2, p) {
@@ -18847,11 +18963,12 @@ async function runServer(o) {
18847
18963
  const dialogsOut = () => forwarder ??= new DialogForwarder({
18848
18964
  hub: guiHost.dialogs().hub,
18849
18965
  read: guiHost.dialogs().read,
18850
- dispatcher: dispatcherLookup({ paneOf: (key2) => guiHost.dialogs().paneOf(key2), list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }),
18966
+ dispatcher: dispatcherLookup({ paneOf: (key2) => guiHost.dialogs().lookup(key2)?.pane, list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }),
18851
18967
  admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false,
18852
18968
  controllers: () => link?.holder?.dialogControllers() ?? [],
18853
18969
  online: (team, device) => link?.holder?.deviceOnline(team, device) ?? false,
18854
18970
  controllersKnown: () => link?.holder?.controllersKnown() ?? false,
18971
+ unit: (key2) => unitLabel(guiHost.dialogs().lookup(key2)),
18855
18972
  post: async (team, device, kind, payload) => {
18856
18973
  if (!link?.holder) throw new Error("The link holder is not running");
18857
18974
  await link.holder.sendDialog(team, device, kind, payload);
@@ -18971,7 +19088,7 @@ ${unit}`), note = ws ? readNotes(slot, ws).at(-1) : void 0, sent = ws ? link?.ho
18971
19088
  if (slot.primary && !leaving && phase === "starting") {
18972
19089
  dialogsOut().start();
18973
19090
  timers2.push(setInterval(() => void Promise.all([dialogsOut().tick(), unitsOut().tick()]), o.unitsMs ?? 15e3));
18974
- }
19091
+ } else log.line("dialog-forwarder", { state: "skipped", primary: slot.primary, leaving: Boolean(leaving), phase });
18975
19092
  if (phase === "starting") {
18976
19093
  phase = "ready";
18977
19094
  const stale = own.lastError !== void 0 && own.lastError === prior?.lastError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.59.0",
3
+ "version": "0.59.1",
4
4
  "description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,7 +19,8 @@ export interface RemoteDialog { select?: unknown; questions?: unknown; cancel?:
19
19
  export type RemoteAnswer = { choice: number } | { answers: unknown[] } | { cancel: true };
20
20
 
21
21
  /** The most dialogs one device keeps open for forwarding, and the answer ids it remembers. */
22
- const MAX_OPEN = 32, MAX_SEEN = 1024;
22
+ export const MAX_OPEN = 32;
23
+ const MAX_SEEN = 1024;
23
24
 
24
25
  export interface DialogsOutDeps {
25
26
  hub: Pick<Hub, "onChange" | "answerable" | "outcome" | "send">;
@@ -44,6 +45,11 @@ export interface DialogsOutDeps {
44
45
  dispatcher?(key: string): Promise<{ device: string; team?: string } | undefined>;
45
46
  /** Whether `device` is an admin of `team`'s roster now: an admin is a recipient of every question. Absent: nobody is. */
46
47
  admin?(team: string, device: string): boolean;
48
+ /**
49
+ * What a controller is told asks the question in the unit `key`: the unit's name, scrubbed for leaving the box (known after a `read`). Nothing: the dialog's owner, the package that opened it (`rpiv`), is told instead, which names
50
+ * no unit. Absent: the owner.
51
+ */
52
+ unit?(key: string): string | undefined;
47
53
  }
48
54
  /** A dialog nobody has answered this long is told to the controllers, once. */
49
55
  export const UNANSWERED_MS = 30 * 60_000;
@@ -51,9 +57,17 @@ export const UNANSWERED_MS = 30 * 60_000;
51
57
  export const REFRESH_MS = 10 * 60_000;
52
58
  /** How long an answer that no session confirmed (`unknown`) is trusted to have landed before a dialog that is still open is taken as unanswered. */
53
59
  export const UNKNOWN_RECHECK_MS = 15_000;
60
+ /** A pass that has not finished this long is stuck: the next look logs where it waits (`dialog-pass-stuck`), lets it go, and starts a pass of its own, so one call that never returns cannot end forwarding for good. */
61
+ export const PASS_STUCK_MS = 60_000;
62
+ /** A close that failed to post is tried at this many passes, then given up (the card expires at the controller anyway). */
63
+ export const CLOSE_TRIES = 5;
64
+ /** The most closes held to be posted; the oldest is given up when more come. */
65
+ const MAX_CLOSES = 256;
66
+ /** A `dialog-close` (an end, or a withdrawal) not yet posted to a controller: it is kept apart from the open dialog, which is gone from `open` once it ends, so a pass let go as stuck leaves it for the next to post. */
67
+ interface PendingClose { k: string; dialogId: string; by: string; at: number; tries: number }
54
68
  export interface DialogAnswerInput { team: string; from: string; dialogId: string; digest: string; answer: string; answerId: string }
55
69
  /** `sent` is where the dialog was last sent, per controller, and when; `claimed` is set while an answer is on its way or has been accepted; `unknownAt` is when an answer no session confirmed was sent. */
56
- interface Open { key: string; target: Parameters<Hub["send"]>[1]; owner: string; text: string; sent: Map<string, number>; away: Set<string>; claimed: boolean; unknownAt?: number; since: number; told: boolean; /** The log line that nobody can be told of this question was written. */ unrouted?: boolean; /** Whose unit asked, once it is known (`dispatcher`). */ who?: { device: string; team?: string } }
70
+ interface Open { key: string; target: Parameters<Hub["send"]>[1]; /** The package that owns the dialog (`rpiv`). */ owner: string; /** What the controllers are told asks: the unit's name when the box can say it, else `owner`. */ label: string; text: string; sent: Map<string, number>; away: Set<string>; claimed: boolean; unknownAt?: number; since: number; told: boolean; /** The log line that nobody can be told of this question was written. */ unrouted?: boolean; /** Whose unit asked, once it is known (`dispatcher`). */ who?: { device: string; team?: string } }
57
71
 
58
72
  export class DialogForwarder {
59
73
  private readonly d: DialogsOutDeps;
@@ -62,12 +76,29 @@ export class DialogForwarder {
62
76
  private seen = new Set<string>();
63
77
  private off: (() => void) | undefined;
64
78
  private polling: Promise<void> | undefined;
79
+ /** The closes not posted yet, per controller and dialog, drained by every pass until each is posted. */
80
+ private closes = new Map<string, PendingClose>();
81
+ /** The passes let go as stuck that have not returned; at most one is outstanding, so a call that never returns costs one pending pass and not one a minute. */
82
+ private released = new Set<Promise<void>>();
83
+ /** The line that a second stuck pass is not let go was written for this stretch. */
84
+ private hung = false;
65
85
  private again = false;
86
+ /** The pass that counts: a pass let go as stuck has an older number and stops at its next turn. */
87
+ private gen = 0;
88
+ /** When the pass in flight began, and the call it waits at (`dialog-pass-stuck` names it). */
89
+ private passAt = 0;
90
+ private step = "idle";
91
+ /** What the last `dialog-controllers` line said, so a line is written when it changes and not at every pass. */
92
+ private counted: string | undefined;
93
+ /** The room for open questions was used up, and the line that says so was written. */
94
+ private full = false;
66
95
  constructor(d: DialogsOutDeps) {
67
96
  this.d = d;
68
97
  }
69
98
  start(): void {
70
- this.off ??= this.d.hub.onChange(() => void this.poll());
99
+ if (this.off) return;
100
+ this.off = this.d.hub.onChange(() => void this.poll());
101
+ this.d.log({ event: "dialog-forwarder", state: "started" });
71
102
  }
72
103
  stop(): void {
73
104
  this.off?.();
@@ -77,19 +108,39 @@ export class DialogForwarder {
77
108
  tick(): Promise<void> {
78
109
  return this.poll();
79
110
  }
80
- /** Looks at the hub now; calls that overlap run once more after the one in flight. */
111
+ /** Looks at the hub now; calls that overlap run once more after the one in flight, and a pass in flight for `PASS_STUCK_MS` is let go (its next look is a new pass). */
81
112
  poll(): Promise<void> {
82
- if (this.polling) return ((this.again = true), this.polling);
83
- const run = (async () => {
113
+ if (this.polling) {
114
+ if (this.now() - this.passAt < PASS_STUCK_MS) return ((this.again = true), this.polling);
115
+ if (this.released.size) {
116
+ // A pass let go has not returned: its call may never, and another release would leave one more pending pass behind. The pass in flight is waited on until that one ends.
117
+ if (!this.hung) this.d.log({ event: "dialog-pass-hung", step: this.step, ms: this.now() - this.passAt });
118
+ this.hung = true;
119
+ return ((this.again = true), this.polling);
120
+ }
121
+ this.d.log({ event: "dialog-pass-stuck", step: this.step, ms: this.now() - this.passAt });
122
+ this.gen++;
123
+ this.released.add(this.polling);
124
+ this.polling = undefined;
125
+ // What the pass let go marked as sent may never have left (it waited at the post): the controllers get every open question again.
126
+ for (const d of this.open.values()) d.sent.clear();
127
+ }
128
+ const gen = this.gen;
129
+ const run: Promise<void> = (async () => {
84
130
  do {
85
131
  this.again = false;
132
+ this.passAt = this.now();
86
133
  try {
87
- await this.pass();
134
+ await this.pass(gen);
88
135
  } catch (error) {
89
- this.d.log({ event: "dialog-forward-failed", error: String((error as Error).message).slice(0, 200) });
136
+ this.d.log({ event: "dialog-forward-failed", step: this.step, error: String((error as Error).message).slice(0, 200) });
90
137
  }
91
- } while (this.again);
92
- })().finally(() => (this.polling = undefined));
138
+ } while (this.again && gen === this.gen);
139
+ })().finally(() => {
140
+ if (this.polling === run) this.polling = undefined;
141
+ this.released.delete(run);
142
+ if (!this.released.size) this.hung = false;
143
+ });
93
144
  return (this.polling = run);
94
145
  }
95
146
  /** Whose unit asked: looked up until it is known, then kept. A lookup that fails leaves it unknown, which is the admins' alone. */
@@ -101,10 +152,27 @@ export class DialogForwarder {
101
152
  private scopeOf(d: Open): Scope {
102
153
  return this.d.dispatcher ? (d.who ? { device: d.who.device, ...(d.who.team !== undefined ? { team: d.who.team } : {}) } : {}) : EVERYONE;
103
154
  }
104
- private async pass(): Promise<void> {
155
+ /** Records where the pass in flight waits (`dialog-pass-stuck` names it); a pass let go as stuck no longer writes it. */
156
+ private at(gen: number, step: string): void {
157
+ if (gen === this.gen) this.step = step;
158
+ }
159
+ /** One line when the controllers the forwarder sees change in number or in whether the list is complete, so a box with nobody to tell says so once (and the first pass says how it starts). */
160
+ private count(controllers: number, known: boolean): void {
161
+ const line = `${controllers}/${known}`;
162
+ if (line === this.counted) return;
163
+ this.counted = line;
164
+ this.d.log({ event: "dialog-controllers", count: controllers, known, open: this.open.size });
165
+ }
166
+ private async pass(gen: number): Promise<void> {
105
167
  const to = this.d.controllers();
168
+ this.count(to.length, this.d.controllersKnown?.() ?? true);
169
+ if (this.closes.size && !(await this.drain(gen))) return;
106
170
  if (!to.length && !this.open.size) return;
107
- if (to.length) await this.d.read();
171
+ if (to.length) {
172
+ this.at(gen, "read");
173
+ await this.d.read();
174
+ if (gen !== this.gen) return;
175
+ }
108
176
  for (const o of to.length ? this.d.hub.answerable() : []) {
109
177
  const id = o.target.answer.dialog;
110
178
  if (this.open.has(id) || this.skipped.has(id)) continue;
@@ -113,19 +181,33 @@ export class DialogForwarder {
113
181
  this.skipped.add(id);
114
182
  this.d.log({ event: "dialog-oversize", dialog: id, bytes: Buffer.byteLength(text) });
115
183
  const unit = { key: o.key } as Open;
184
+ this.at(gen, "dispatcher");
116
185
  await this.whose(unit);
117
- await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${o.owner} was not forwarded: it is ${Buffer.byteLength(text)} bytes, over the ${LIMITS.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
186
+ this.at(gen, "notice");
187
+ await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${this.labelOf(o.key, o.owner)} was not forwarded: it is ${Buffer.byteLength(text)} bytes, over the ${LIMITS.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
188
+ if (gen !== this.gen) return;
189
+ continue;
190
+ }
191
+ if (this.open.size >= MAX_OPEN) {
192
+ // The one case where a question is left unforwarded with no line of its own: the line says the room is used up, once until it frees.
193
+ if (!this.full) this.d.log({ event: "dialog-full", open: this.open.size });
194
+ this.full = true;
118
195
  continue;
119
196
  }
120
- if (this.open.size >= MAX_OPEN) continue;
121
- this.open.set(id, { key: o.key, target: o.target, owner: o.owner, text, sent: new Map(), away: new Set(), claimed: false, since: this.now(), told: false });
197
+ this.open.set(id, { key: o.key, target: o.target, owner: o.owner, label: this.labelOf(o.key, o.owner), text, sent: new Map(), away: new Set(), claimed: false, since: this.now(), told: false });
198
+ this.d.log({ event: "dialog-seen", dialog: id, unit: o.key, owner: o.owner });
122
199
  }
123
200
  for (const [id, d] of [...this.open]) {
201
+ this.at(gen, "dispatcher");
124
202
  await this.whose(d);
203
+ if (gen !== this.gen) return;
125
204
  const state = this.d.hub.outcome(id);
126
205
  if (!state.open) {
127
206
  this.open.delete(id);
128
- for (const k of d.sent.keys()) await this.send(k, "dialog-close", { dialogId: id, by: state.by });
207
+ this.full = false;
208
+ this.d.log({ event: "dialog-ended", dialog: id, by: state.by, sent: d.sent.size });
209
+ for (const k of d.sent.keys()) this.hold(k, id, state.by);
210
+ if (!(await this.drain(gen))) return;
129
211
  continue;
130
212
  }
131
213
  // An answer no session confirmed, and the dialog is still open a while after: it did not land, so the next valid answer may win and the controllers get the question open again.
@@ -140,11 +222,14 @@ export class DialogForwarder {
140
222
  for (const k of this.d.controllersKnown?.() === false ? [] : [...d.sent.keys()]) {
141
223
  if (mine.some((c) => `${c.team}\n${c.device}` === k)) continue;
142
224
  d.sent.delete(k);
143
- await this.send(k, "dialog-close", { dialogId: id, by: "withdrawn" });
225
+ this.hold(k, id, "withdrawn");
226
+ if (!(await this.drain(gen))) return;
144
227
  }
145
228
  if (!d.told && !d.claimed && to.length && this.now() - d.since >= UNANSWERED_MS) {
146
229
  // Marked told only once the notice reached somebody: with no admin at `dispatch` it is tried again at the next pass (g346-F005).
147
- const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.owner} has waited ${Math.round((this.now() - d.since) / 60_000)} minutes for an answer.` }, this.scopeOf(d));
230
+ this.at(gen, "notice");
231
+ const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.label} has waited ${Math.round((this.now() - d.since) / 60_000)} minutes for an answer.` }, this.scopeOf(d));
232
+ if (gen !== this.gen) return;
148
233
  d.told = sent !== false;
149
234
  }
150
235
  // A question no controller can be told of (an unattributed unit, and no admin at `dispatch`) stays open and is never widened to the others; the box's own log says it waits.
@@ -160,16 +245,53 @@ export class DialogForwarder {
160
245
  if (at !== undefined) continue;
161
246
  } else if (at !== undefined && !d.away.delete(k) && this.now() - at < REFRESH_MS) continue;
162
247
  d.sent.set(k, this.now());
163
- if (!(await this.send(k, "dialog-open", { dialogId: id, digest: d.target.answer.digest, owner: d.owner, dialog: d.text }))) d.sent.delete(k);
248
+ this.at(gen, "post");
249
+ const posted = await this.send(k, "dialog-open", { dialogId: id, digest: d.target.answer.digest, owner: d.label, dialog: d.text });
250
+ // A pass let go no longer owns `sent`: the pass that replaced it has marked what it posted.
251
+ if (gen !== this.gen) return;
252
+ if (!posted) d.sent.delete(k);
164
253
  }
165
254
  }
166
255
  for (const id of [...this.skipped]) if (!this.d.hub.outcome(id).open) this.skipped.delete(id);
167
256
  }
257
+ /** Keeps a close to post: the oldest is given up when the room is used up. */
258
+ private hold(k: string, dialogId: string, by: string): void {
259
+ this.closes.set(`${k}\n${dialogId}`, { k, dialogId, by, at: this.now(), tries: 0 });
260
+ for (const [key, c] of this.closes) {
261
+ if (this.closes.size <= MAX_CLOSES) break;
262
+ this.closes.delete(key);
263
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "full" });
264
+ }
265
+ }
266
+ /** Posts the held closes, each removed once posted. One that failed `CLOSE_TRIES` times, or whose card has expired at the controller (`OPEN_STALE_MS`), is given up with a line. False when this pass was let go meanwhile. */
267
+ private async drain(gen: number): Promise<boolean> {
268
+ for (const [key, c] of [...this.closes]) {
269
+ if (this.now() - c.at > OPEN_STALE_MS) {
270
+ this.closes.delete(key);
271
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "stale" });
272
+ continue;
273
+ }
274
+ this.at(gen, "post");
275
+ const posted = await this.send(c.k, "dialog-close", { dialogId: c.dialogId, by: c.by });
276
+ if (posted) this.closes.delete(key);
277
+ else if (++c.tries >= CLOSE_TRIES) {
278
+ this.closes.delete(key);
279
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "failed" });
280
+ }
281
+ if (gen !== this.gen) return false;
282
+ }
283
+ return true;
284
+ }
168
285
  private now = () => this.d.now?.() ?? Date.now();
286
+ /** What the controllers are told asks: the unit's name when it is known, else the owner of the dialog. */
287
+ private labelOf(key: string, owner: string): string {
288
+ return this.d.unit?.(key) || owner;
289
+ }
169
290
  private async send(k: string, kind: "dialog-open" | "dialog-close", payload: DialogOpen | DialogClose): Promise<boolean> {
170
291
  const [team, device] = k.split("\n") as [string, string];
171
292
  try {
172
293
  await this.d.post(team, device, kind, payload);
294
+ this.d.log({ event: "dialog-sent", kind, dialog: payload.dialogId, team, to: device });
173
295
  return true;
174
296
  } catch (error) {
175
297
  this.d.log({ event: "dialog-post-failed", kind, dialog: payload.dialogId, error: String((error as Error).message).slice(0, 200) });
@@ -1129,6 +1129,7 @@ export class LinkHolder {
1129
1129
  // Only an attended device shows a question; only an unattended member of the team asks one (the envelope check enforces the kind's sender rule, this keeps the receiver honest on its own).
1130
1130
  if (!(this.o.attended?.() ?? true) || !sender || sender.attended) return void this.o.store.log({ event: "drop", team: run.team, reason: "dialog-open-unexpected", from: header.from, kind: "dialog-open" });
1131
1131
  const fresh = this.dialogViews.put({ team: run.team, device: header.from, dialogId: p.dialogId, digest: p.digest, owner: p.owner, dialog: p.dialog, at: this.now() });
1132
+ this.o.store.log({ event: "dialog-in", team: run.team, from: header.from, dialog: p.dialogId, fresh });
1132
1133
  if (fresh) this.o.notify(`Straw Link: ${sender.label} asks a question (${restrictLabel(p.owner)})`, "warning");
1133
1134
  }
1134
1135
  private takeDialogClose(header: Header, p: DialogClose): void {
@@ -1,6 +1,7 @@
1
+ import { allyLabel } from "../boardauto.ts";
1
2
  import type { Pane } from "../herdr.ts";
2
3
 
3
- // Whose unit a pane is (docs/specs/2026-11-05-link-shared-box.md, Who is told): the controller that dispatched the work done in it. The box has two kinds of unit that a controller starts: an ally run, whose pane the
4
+ // Whose unit a pane is (docs/specs/2026-11-05-link-shared-box.md, Who is told): the controller that dispatched the work done in it, and what the controller is told the unit is called (`unitLabel`). The box has two kinds of unit that a controller starts: an ally run, whose pane the
4
5
  // server itself split off (and keeps in its own record), and a main a run opened with `boss_open_main`, in a tab of its own whose pane carries `sb_opened_by=link:<device id>` from before its pi starts. Everything else
5
6
  // (the tower's own runs, a session the boss opened on the box) is nobody's. The token is Herdr state any process of the box's user can write, the same trust the main's close and the route table already place in it;
6
7
  // this scope is a matter of whom the box bothers and whom it believes, not a wall between users of one account.
@@ -26,3 +27,17 @@ export function dispatcherLookup(d: { paneOf(key: string): string | undefined; l
26
27
  return panes ? dispatcherOf(pane, panes, d.allyPanes()) : undefined;
27
28
  };
28
29
  }
30
+
31
+ /**
32
+ * The roles whose name alone does not say what the unit is: `main - docs`, `subagent - scout`. The others (a tower, an outside pi) are their name. The separator is ASCII because the controller's holder writes the label into its notice through
33
+ * `restrictLabel` (letters, digits, space, `.`, `_`, `-`; anything else becomes `_`, cut at 40), so the separator survives it. The name does not always: the card shows it whole, the notice restricted.
34
+ */
35
+ const KIND: Record<string, string> = { main: "main", member: "member", subagent: "subagent", ally: "ally run", run: "run" };
36
+
37
+ /** The unit's name for a controller, or nothing when the unit has none left after the scrub (a name that was all path or token is a bare mark). `found` is what the net's registry holds for the unit's key. */
38
+ export function unitLabel(found: { name: string; role: string } | undefined): string | undefined {
39
+ const name = allyLabel(found?.name);
40
+ if (!found || !/[\p{L}\p{N}]/u.test(name)) return undefined;
41
+ const kind = KIND[found.role];
42
+ return kind ? `${kind} - ${name}` : name;
43
+ }
@@ -353,7 +353,7 @@ export function createGuiHost(o: GuiHostOptions) {
353
353
  let forwardNet: ReturnType<typeof netSource> | undefined;
354
354
  return {
355
355
  /** The hub and a net reading that binds its dialogs, for the forwarder an unattended device runs. */
356
- dialogs: () => ({ hub: theHub(), read: async () => void (await (forwardNet ??= netSource({ ...(o.tower ? { services: (workspace: string) => o.tower!.view(workspace) } : {}), run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read()), /** The pane of a unit by the key the hub gives its dialogs (known after a `read`). */ paneOf: (key: string) => forwardNet?.lookup(key)?.pane }),
356
+ dialogs: () => ({ hub: theHub(), read: async () => void (await (forwardNet ??= netSource({ ...(o.tower ? { services: (workspace: string) => o.tower!.view(workspace) } : {}), run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read()), /** The unit the hub files dialogs under `key` (known after a `read`): its pane for the dispatcher, its name and role for the label a controller is told. */ lookup: (key: string) => forwardNet?.lookup(key) }),
357
357
  /** At the server's start: the GUI on the recorded ports unless it was stopped on purpose (which only the boss's own command undoes). */
358
358
  async start(): Promise<void> {
359
359
  // The gates' tracking does not depend on the page: a GUI stopped on purpose, or one that could not bind, still has the Allows of the boss's yes in the terminal.
@@ -18,7 +18,7 @@ import { createAsks } from "./asks.ts";
18
18
  import { cliBeside, createGuiHost, spawnRestart } from "./guihost.ts";
19
19
  import { type IntercomModule, loadIntercom, LoaderError } from "./intercom.ts";
20
20
  import { DialogForwarder } from "../link/dialogs-out.ts";
21
- import { dispatcherLookup } from "./dispatcher.ts";
21
+ import { dispatcherLookup, unitLabel } from "./dispatcher.ts";
22
22
  import { openTower } from "./opentower.ts";
23
23
  import { createLinkService, type LinkService, type ServiceDeps } from "../link/service.ts";
24
24
  import { createLog, tailLog } from "./log.ts";
@@ -563,7 +563,7 @@ export async function runServer(o: ServerOptions): Promise<ServerHandle> {
563
563
  // pi-intercom: the server's own session, reconnecting with the sidecar's backoff (1, 2, 5, 10, then 30 s). While it is disconnected its identities are not live.
564
564
  // The link holder (B3): the primary server only, built once the first connection is attached (the kit's channel needs a client). A computer with no team set up gets no channel, election, or files.
565
565
  const dialogsOut = () => (forwarder ??= new DialogForwarder({
566
- hub: guiHost!.dialogs().hub, read: guiHost!.dialogs().read, dispatcher: dispatcherLookup({ paneOf: (key) => guiHost!.dialogs().paneOf(key), list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }), admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false, controllers: () => link?.holder?.dialogControllers() ?? [], online: (team, device) => link?.holder?.deviceOnline(team, device) ?? false, controllersKnown: () => link?.holder?.controllersKnown() ?? false,
566
+ hub: guiHost!.dialogs().hub, read: guiHost!.dialogs().read, dispatcher: dispatcherLookup({ paneOf: (key) => guiHost!.dialogs().lookup(key)?.pane, list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }), admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false, controllers: () => link?.holder?.dialogControllers() ?? [], online: (team, device) => link?.holder?.deviceOnline(team, device) ?? false, controllersKnown: () => link?.holder?.controllersKnown() ?? false, unit: (key) => unitLabel(guiHost!.dialogs().lookup(key)),
567
567
  post: async (team, device, kind, payload) => { if (!link?.holder) throw new Error("The link holder is not running"); await link.holder.sendDialog(team, device, kind, payload); },
568
568
  log: ({ event, ...rest }) => log.line(String(event), Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, typeof v === "number" || typeof v === "boolean" ? v : phrase(v, 200)]))),
569
569
  now, notice: (key, notice, scope) => unitsOut().notify(key, notice, scope),
@@ -664,7 +664,7 @@ export async function runServer(o: ServerOptions): Promise<ServerHandle> {
664
664
  dialogsOut().start();
665
665
  // A controller that gained `dispatch` mid-dialog gets it, a dialog unanswered for 30 minutes is told, and each controller's unit-status goes out when it changed or a minute has passed.
666
666
  timers.push(setInterval(() => void Promise.all([dialogsOut().tick(), unitsOut().tick()]), o.unitsMs ?? 15_000));
667
- }
667
+ } else log.line("dialog-forwarder", { state: "skipped", primary: slot.primary, leaving: Boolean(leaving), phase });
668
668
 
669
669
  // Ready: `restarting` goes, and the start-error of this attempt (and any before it) with it.
670
670
  if (phase === "starting") {