agents-city 0.5.1 → 0.5.3

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "city",
11
11
  "source": "./plugin",
12
- "version": "0.5.1",
12
+ "version": "0.5.3",
13
13
  "description": "Operate one autonomous city seat, its domain, role, repo support agents, goal, recognised skills and explicit roads to other cities."
14
14
  }
15
15
  ]
package/bin/navegador.mjs CHANGED
@@ -553,6 +553,114 @@ async function main() {
553
553
  JSON.stringify(demo?.dice),
554
554
  );
555
555
 
556
+ // Building a house for real: type a name, press the button, get a house.
557
+ //
558
+ // The suite used to open this form, check its fields existed and press
559
+ // Escape — so the one thing it is FOR was never exercised. What that hid:
560
+ // the roles list arrives a few hundred milliseconds after the form opens,
561
+ // the form repainted when it did, and the repaint redrew the name field
562
+ // from a state that had never been told what was typed in it. Typing while
563
+ // that answer was in flight lost the name, and then the button said to
564
+ // give it one. Intermittent, which is the worst kind.
565
+ //
566
+ // So this types IMMEDIATELY, before the roles can land, which is exactly
567
+ // what a person does.
568
+ const construida = await cdp.evalua(`(async () => {
569
+ const espera = async (sel, n = 40) => {
570
+ for (let i = 0; i < n; i++) {
571
+ const el = document.querySelector(sel);
572
+ if (el) return el;
573
+ await new Promise(r => setTimeout(r, 250));
574
+ }
575
+ return null;
576
+ };
577
+ const ir = [...document.querySelectorAll('nav li')].find(l => /houses|casas/i.test(l.textContent));
578
+ ir?.click();
579
+ await new Promise(r => setTimeout(r, 600));
580
+ document.getElementById('altaAgente')?.click();
581
+ const campo = await espera('.dlgFondo #bvNombre');
582
+ if (!campo) return { falta: true };
583
+ // Straight away: the roles request is still in flight right now.
584
+ campo.value = 'urgencias';
585
+ campo.dispatchEvent(new Event('input', { bubbles: true }));
586
+ // Long enough for that answer to land and do whatever it does.
587
+ await new Promise(r => setTimeout(r, 1500));
588
+ const sobrevive = (document.querySelector('.dlgFondo #bvNombre') || {}).value;
589
+ const boton = document.querySelector('.dlgFondo [data-dlg="si"]');
590
+ boton?.click();
591
+ const leeNombres = () =>
592
+ [...document.querySelectorAll('.fichaRPG h3')].map(h => h.textContent.trim());
593
+ // Wait for the house, not for a guess at how long building takes. A
594
+ // fixed sleep here made this check itself flaky, which is the one thing
595
+ // a check for a flaky bug must not be.
596
+ let nombres = [];
597
+ for (let i = 0; i < 60; i++) {
598
+ nombres = leeNombres();
599
+ if (!document.querySelector('.dlgFondo') && nombres.includes('urgencias')) break;
600
+ await new Promise(r => setTimeout(r, 250));
601
+ }
602
+ return { sobrevive, cerrado: !document.querySelector('.dlgFondo'), nombres };
603
+ })()`);
604
+ comprueba(
605
+ 'a name typed while the roles are still loading is still there afterwards',
606
+ construida?.sobrevive === 'urgencias',
607
+ JSON.stringify(construida),
608
+ );
609
+ comprueba(
610
+ 'and pressing build actually builds the house',
611
+ !!construida?.cerrado && (construida?.nombres ?? []).includes('urgencias'),
612
+ JSON.stringify(construida),
613
+ );
614
+
615
+ // And the other half: pressing build with nothing in the name.
616
+ //
617
+ // The failure this guards is not "it refuses" — it is refusing SILENTLY, or
618
+ // refusing while closing the dialog, which loses everything the person had
619
+ // already chosen. That is the same complaint from the other side: a form
620
+ // that says no and takes the work with it.
621
+ const vacia = await cdp.evalua(`(async () => {
622
+ const espera = async (sel, n = 40) => {
623
+ for (let i = 0; i < n; i++) {
624
+ const el = document.querySelector(sel);
625
+ if (el) return el;
626
+ await new Promise(r => setTimeout(r, 250));
627
+ }
628
+ return null;
629
+ };
630
+ const antes = document.querySelectorAll('.fichaRPG h3').length;
631
+ document.getElementById('altaAgente')?.click();
632
+ const campo = await espera('.dlgFondo #bvNombre');
633
+ if (!campo) return { falta: true };
634
+ // Choose something first: whatever it says, this must not be thrown away.
635
+ document.querySelector('.dlgFondo [data-bv=\"clase\"][data-id=\"knowledge\"]')?.click();
636
+ await new Promise(r => setTimeout(r, 300));
637
+ document.querySelector('.dlgFondo [data-dlg=\"si\"]')?.click();
638
+ await new Promise(r => setTimeout(r, 1200));
639
+ const caja = document.querySelector('.dlgFondo');
640
+ const elegida = !!document.querySelector('.dlgFondo .bvOpcion.elegida[data-id=\"knowledge\"]');
641
+ const aviso = document.querySelector('#aviso,.aviso,.toast');
642
+ const fuera = {
643
+ sigueAbierto: !!caja,
644
+ elegida,
645
+ dice: (aviso?.textContent ?? '').trim().slice(0, 80),
646
+ despues: document.querySelectorAll('.fichaRPG h3').length,
647
+ antes,
648
+ };
649
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
650
+ await new Promise(r => setTimeout(r, 200));
651
+ return fuera;
652
+ })()`);
653
+ comprueba(
654
+ 'building with no name refuses without closing the form',
655
+ !!vacia && !vacia.falta && vacia.sigueAbierto === true,
656
+ JSON.stringify(vacia),
657
+ );
658
+ comprueba(
659
+ 'and keeps what was already chosen, and builds nothing',
660
+ !!vacia?.elegida && vacia?.despues === vacia?.antes,
661
+ JSON.stringify(vacia),
662
+ );
663
+
556
664
  // Spanish and English, on a switch. The READMEs shipped bilingual and the
557
665
  // product did not; a language button that does not actually change the page
558
666
  // is worse than none at all.
package/bin/serve.py CHANGED
@@ -29,6 +29,7 @@ import stat
29
29
  import subprocess
30
30
  import sys
31
31
  import threading
32
+ import time
32
33
  import urllib.parse
33
34
  import webbrowser
34
35
 
@@ -44,6 +45,7 @@ import parcels # noqa: E402
44
45
  import units # noqa: E402
45
46
  import busca # noqa: E402 the disk scanner, and the one list of where work lives
46
47
  import cities # noqa: E402
48
+ import diario # noqa: E402 what happened, written down
47
49
  import demos # noqa: E402 the recorded demos the Hall plays back
48
50
  import roads # noqa: E402
49
51
  import reception # noqa: E402
@@ -523,6 +525,15 @@ def actividad_viva(datos):
523
525
  return {"online": False, "url": "", "city": "", "started_at": ""}
524
526
 
525
527
 
528
+ def _resumen_cuerpo(cuerpo):
529
+ """On success, the shape rather than the content: which fields arrived and
530
+ how long each was. A log that repeats every goal somebody types is a log
531
+ they will not send anywhere."""
532
+ if not isinstance(cuerpo, dict):
533
+ return {}
534
+ return {k: (len(v) if isinstance(v, (str, list)) else v) for k, v in cuerpo.items()}
535
+
536
+
526
537
  def _que_es_git(ruta):
527
538
  """`repo`, `worktree`, or nothing. A label on a row, never a filter: the
528
539
  picker shows every folder and lets the person decide which one matters."""
@@ -603,7 +614,44 @@ class Manejador(http.server.BaseHTTPRequestHandler):
603
614
  if os.environ.get("CITY_SETUP_DEBUG"):
604
615
  sys.stderr.write(" %s\n" % (format % args))
605
616
 
617
+ #: What this handler last answered, so the request log can say so without
618
+ #: every endpoint having to report for itself.
619
+ _ultimo = None
620
+ _error = None
621
+
622
+ def _ciudad_para_apuntar(self, q):
623
+ """The same city `ciudad` would resolve, resolved without touching it.
624
+
625
+ `ciudad` calls `asegura_metadata`, which does `makedirs` and writes
626
+ `city.yml` — right when a request is about to act on a city, and wrong
627
+ when the only reason we are asking is to write a log line. The journal
628
+ runs AFTER the handler, so on a request that archived or reset a city it
629
+ recreated the very folder that had just been taken away, leaving a ghost
630
+ behind. Observing something must not create it.
631
+ """
632
+ pedida = q.get("city", [""])[0]
633
+ if pedida:
634
+ resuelta = self.resuelve_conocida(pedida)
635
+ if resuelta:
636
+ return resuelta
637
+ return seat.donde_viven_las_fichas()
638
+
639
+ def apunta(self, q, tipo, **campos):
640
+ """One journal line, in the journal of the city the request acted on.
641
+
642
+ Not the selected one: a request carries `?city=`, and writing its line
643
+ into whichever city happened to be current put the record of what
644
+ happened to one city in another city's file. Which is worse than no
645
+ record, because it is a record that lies about where.
646
+ """
647
+ try:
648
+ diario.apunta(self._ciudad_para_apuntar(q), tipo, **campos)
649
+ except Exception: # noqa: BLE001 logging must not break the request
650
+ pass
651
+
606
652
  def responde(self, cuerpo, tipo="application/json", codigo=200):
653
+ self._ultimo = codigo
654
+ self._error = cuerpo.get("error") if isinstance(cuerpo, dict) else None
607
655
  if not isinstance(cuerpo, bytes):
608
656
  cuerpo = json.dumps(cuerpo).encode()
609
657
  self.send_response(codigo)
@@ -690,6 +738,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
690
738
  "/api/instrucciones": "g_instrucciones",
691
739
  "/api/live": "g_live",
692
740
  "/api/carpeta": "g_carpeta",
741
+ "/api/diario": "g_diario",
693
742
  "/api/demos": "g_demos",
694
743
  "/api/domains": "g_domains",
695
744
  "/api/roles": "g_roles",
@@ -707,6 +756,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
707
756
  "/api/roads": "p_roads",
708
757
  "/api/reception": "p_reception",
709
758
  "/api/agente": "p_agente",
759
+ "/api/diario": "p_diario",
710
760
  "/api/agentes": "p_agentes",
711
761
  "/api/montaje": "p_montaje",
712
762
  "/api/motor": "p_motor",
@@ -759,9 +809,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
759
809
  cuerpo = json.loads(self.rfile.read(largo) or b"{}")
760
810
  except (ValueError, json.JSONDecodeError) as e:
761
811
  return self.responde({"error": f"unreadable body: {e}"}, codigo=400)
762
- if ruta in self.POSTS:
763
- return getattr(self, self.POSTS[ruta])(q, cuerpo)
764
- return self.responde({"error": "no such thing"}, codigo=404)
812
+ if ruta not in self.POSTS:
813
+ self.apunta(q, "post", ruta=ruta, estado=404, error="no such thing")
814
+ return self.responde({"error": "no such thing"}, codigo=404)
815
+ # Every write is recorded before it happens and judged after, so a
816
+ # request that never came back says so too — a handler that hangs or
817
+ # dies leaves a line with no verdict, which is itself the finding.
818
+ empezado = time.monotonic()
819
+ self._ultimo = None
820
+ try:
821
+ salida = getattr(self, self.POSTS[ruta])(q, cuerpo)
822
+ except Exception as e: # noqa: BLE001 the log is the point
823
+ self.apunta(q, "post", ruta=ruta, error=f"{type(e).__name__}: {e}",
824
+ cuerpo=cuerpo, ms=int((time.monotonic() - empezado) * 1000))
825
+ raise
826
+ self.apunta(q, "post", ruta=ruta, estado=self._ultimo,
827
+ cuerpo=cuerpo if self._ultimo != 200 else _resumen_cuerpo(cuerpo),
828
+ error=self._error, ms=int((time.monotonic() - empezado) * 1000))
829
+ return salida
765
830
 
766
831
  def g_estado(self, q):
767
832
  datos = self.ciudad(q)
@@ -871,6 +936,16 @@ class Manejador(http.server.BaseHTTPRequestHandler):
871
936
  return self.responde({"error": "no such demo"}, codigo=404)
872
937
  return self.responde({**(demos.ficha(cual, eventos) or {}), "eventos": eventos})
873
938
 
939
+ def g_diario(self, q):
940
+ """The journal, for `doctor --log` and for anybody about to send it."""
941
+ try:
942
+ cuantas = max(1, min(2000, int(q.get("n", ["200"])[0])))
943
+ except ValueError:
944
+ cuantas = 200
945
+ return self.responde(
946
+ {"ruta": diario.ruta(self.ciudad(q)), "lineas": diario.lee(self.ciudad(q), cuantas)}
947
+ )
948
+
874
949
  def g_carpeta(self, q):
875
950
  """One folder, listed: what is in it, and what each thing is.
876
951
 
@@ -1323,8 +1398,23 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1323
1398
  return self.responde({"error": "this city has no owner card yet"}, codigo=409)
1324
1399
  nombre = " ".join(str(cuerpo.get("name") or "").split())
1325
1400
  slug = card.ventana(nombre)
1326
- if not nombre or not card.ventana_valida(slug):
1401
+ # `card.ventana` falls back to the word `repo` when nothing in the name
1402
+ # survives slugging — which is right for a repository whose folder is
1403
+ # punctuation, and wrong here: a house called `///` would be created
1404
+ # with the window `repo`, and the name a person reads would stop being
1405
+ # the thing the city addresses. A name has to carry a letter or a digit.
1406
+ legible = bool(re.search(r"[a-z0-9]", nombre.lower()))
1407
+ if not nombre or not card.ventana_valida(slug) or not legible:
1327
1408
  return self.responde({"error": "an agent needs a plain name"}, codigo=400)
1409
+ # A window slug is cut at 80 characters, so a longer name would be
1410
+ # stored in full on the card and addressed by a truncated one — the
1411
+ # name a person reads and the window it opens quietly stop being the
1412
+ # same thing. Refuse rather than silently rename.
1413
+ if len(nombre) > 80:
1414
+ return self.responde(
1415
+ {"error": "an agent's name has to fit in a window title: 80 characters"},
1416
+ codigo=400,
1417
+ )
1328
1418
  clase = str(cuerpo.get("kind") or workspace.CLASE_DEFECTO).strip().lower()
1329
1419
  if clase not in workspace.CLASES:
1330
1420
  return self.responde(
@@ -1419,6 +1509,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1419
1509
  }
1420
1510
  )
1421
1511
 
1512
+ def p_diario(self, q, cuerpo):
1513
+ """The browser's half of the log.
1514
+
1515
+ Half of what goes wrong here goes wrong in the page — a handler that
1516
+ threw, a fetch that never came back, a button that did nothing. A log
1517
+ that stops at the network boundary tells half the story, so the page
1518
+ writes into the same file the server does, and one file answers "what
1519
+ happened" instead of two that have to be lined up by hand.
1520
+ """
1521
+ diario.apunta(
1522
+ self.ciudad(q),
1523
+ "browser",
1524
+ que=str(cuerpo.get("que") or "")[:120],
1525
+ detalle=cuerpo.get("detalle"),
1526
+ donde=str(cuerpo.get("donde") or "")[:200],
1527
+ )
1528
+ return self.responde({"ok": True})
1529
+
1422
1530
  def p_agente(self, q, cuerpo):
1423
1531
  """Tune one agent from its character sheet: model, effort, runtime and
1424
1532
  avatar seed, written to the card keys the launcher already resolves. An
package/bin/test CHANGED
@@ -22,7 +22,7 @@ if [ -d .git ] && [ -x .githooks/pre-commit ] \
22
22
  && printf ' pre-commit gate armed (core.hooksPath .githooks)\n'
23
23
  fi
24
24
 
25
- SUITES=(widgets card parcels domains busca arnes i18n serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch)
25
+ SUITES=(widgets card parcels domains busca arnes i18n diario serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch)
26
26
  [ $# -gt 0 ] && SUITES=("$@")
27
27
 
28
28
  fallos=0
@@ -91,6 +91,7 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
91
91
  "plugin/scripts/crecimiento.py",
92
92
  "plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
93
93
  "plugin/scripts/demos.py", "plugin/scripts/arnes.py",
94
+ "plugin/scripts/diario.py",
94
95
  "plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
95
96
  "plugin/scripts/deliberations.py",
96
97
  "plugin/scripts/runtime_processes.py",
package/bin/test-arnes.py CHANGED
@@ -20,6 +20,7 @@ So there are two things to defend here, and the second is the hard one:
20
20
  import json
21
21
  import os
22
22
  import re
23
+ import subprocess
23
24
  import sys
24
25
  import tempfile
25
26
 
@@ -119,12 +120,119 @@ def sin_deriva():
119
120
  arnes.banderas("codex") == "" and arnes.banderas("opencode") == "",
120
121
  f"codex={arnes.banderas('codex')!r}")
121
122
 
123
+ # And it survives a SHELL, which is what receives it.
124
+ #
125
+ # This is the check that was missing, and its absence cost every Claude
126
+ # window in a city. Emitted bare, `--settings {"a":"b","c":true}` is
127
+ # destroyed twice before Claude sees it: brace expansion splits it on the
128
+ # comma, quote removal eats the double quotes, and what arrives is
129
+ # `--settings {a:b}` — "Invalid JSON provided to --settings".
130
+ #
131
+ # Asserting that the string CONTAINS the right words could never have
132
+ # caught that. So this parses the line the way a shell does and reads the
133
+ # value back as JSON, which is the only claim that matters.
134
+ import shlex # noqa: PLC0415
135
+
136
+ palabras = shlex.split(arnes.banderas("claude"))
137
+ afirma("· the flags survive shell parsing as separate words",
138
+ "--settings" in palabras and "--disallowed-tools" in palabras, str(palabras))
139
+ valor = palabras[palabras.index("--settings") + 1]
140
+ try:
141
+ ajustes = json.loads(valor)
142
+ except json.JSONDecodeError as e:
143
+ ajustes = None
144
+ afirma("· and the settings value is still JSON afterwards", False, f"{valor!r}: {e}")
145
+ if ajustes is not None:
146
+ afirma("· and the settings value is still JSON afterwards", True, "")
147
+ comprueba("· with the cross-session path closed",
148
+ ajustes.get("crossSessionInbound"), "refuse")
149
+ afirma("· and every declared settings key inside it",
150
+ all(t["clave"] in ajustes
151
+ for t in arnes.declaracion()["claude"]["trato"]
152
+ if t.get("rinde") == "settings"),
153
+ str(ajustes))
154
+ # A real shell, not just a parser: brace expansion is the half `shlex`
155
+ # forgives, and it is the half that broke.
156
+ import subprocess # noqa: PLC0415
157
+
158
+ r = subprocess.run(
159
+ ["bash", "-c", 'set -- ' + arnes.banderas("claude") + '; printf "%s\n" "$@"'],
160
+ capture_output=True, text=True,
161
+ )
162
+ entregado = [l for l in r.stdout.split("\n") if l]
163
+ comprueba("· a real shell hands over exactly four words", len(entregado), 4)
164
+ try:
165
+ json.loads(entregado[1])
166
+ afirma("· and the second is the settings, intact", True, "")
167
+ except json.JSONDecodeError as e:
168
+ afirma("· and the second is the settings, intact", False, f"{entregado!r}: {e}")
169
+
122
170
 
123
171
  def _es_metodo_declarado(aguja, declaradas):
124
172
  """`approvalPolicy` declared, read through a method of the same name."""
125
173
  return any(aguja.lower() == d.lower() for d in declaradas)
126
174
 
127
175
 
176
+ def valores_que_no_sobrevivirian(tmp):
177
+ """Values that a shell would eat, or run.
178
+
179
+ The happy checks above prove today's declaration survives. They cannot
180
+ prove the NEXT one will: the values there are mild strings, and the bug
181
+ that broke every Claude window was a comma. The day somebody declares a
182
+ value with a space in it, or a quote, this has to hold — and if it does
183
+ not, it must fail here rather than in a person's terminal.
184
+
185
+ The last two are not a formatting concern. `$(...)` and backticks in an
186
+ unquoted argument are executed by the shell, and this declaration is read
187
+ from a file: a value that runs a command is a value that runs somebody
188
+ else's command.
189
+ """
190
+ print(" values a shell would eat, or run")
191
+ testigo = os.path.join(tmp, "ejecutado")
192
+ hostiles = {
193
+ "conEspacio": "dos palabras",
194
+ "conComillas": 'dice "hola" y \'adios\'',
195
+ "conLlaves": "{a,b}",
196
+ "conPuntoYComa": "uno; echo dos",
197
+ "conDolar": "$HOME y ${OTRO}",
198
+ "conSustitucion": f"$(touch {testigo})",
199
+ "conAcentoGrave": f"`touch {testigo}`",
200
+ }
201
+ real = arnes.declaracion
202
+ arnes.declaracion = lambda: {
203
+ "claude": {
204
+ "trato": [
205
+ {"clave": k, "valor": v, "via": "x", "porque": "y" * 30, "rinde": "settings"}
206
+ for k, v in hostiles.items()
207
+ ]
208
+ }
209
+ }
210
+ try:
211
+ linea = arnes.banderas("claude")
212
+ finally:
213
+ arnes.declaracion = real
214
+
215
+ # A real shell, because `shlex` forgives what bash does not.
216
+ r = subprocess.run(
217
+ ["bash", "-c", "set -- " + linea + '; printf "%s\\n" "$@"'],
218
+ capture_output=True, text=True,
219
+ )
220
+ entregado = [l for l in r.stdout.split("\n") if l]
221
+ afirma("· a hostile declaration still comes out as two words",
222
+ len(entregado) == 2 and entregado[0] == "--settings",
223
+ f"{entregado!r} from {linea!r}")
224
+ afirma("· nothing in it was executed on the way",
225
+ not os.path.exists(testigo),
226
+ f"{testigo} exists: a declared value ran a command")
227
+ try:
228
+ vuelta = json.loads(entregado[1]) if len(entregado) > 1 else {}
229
+ except json.JSONDecodeError as e:
230
+ vuelta = {}
231
+ afirma("· and it is still JSON on the other side", False, f"{entregado[1]!r}: {e}")
232
+ for clave, valor in hostiles.items():
233
+ comprueba(f"· {clave} arrives exactly as declared", vuelta.get(clave), valor)
234
+
235
+
128
236
  def lo_que_hay_en_el_disco():
129
237
  print(" it reads the machine, and admits what it cannot read")
130
238
  casa = tempfile.mkdtemp()
@@ -183,6 +291,7 @@ def el_informe():
183
291
  def main():
184
292
  la_declaracion()
185
293
  sin_deriva()
294
+ valores_que_no_sobrevivirian(tempfile.mkdtemp())
186
295
  lo_que_hay_en_el_disco()
187
296
  el_informe()
188
297
  return resumen("arnes")
@@ -22,6 +22,37 @@ CHANNEL = os.path.join(RAIZ, 'plugin', 'channel', 'run.sh')
22
22
  CLIENT = os.path.join(RAIZ, 'plugin', 'channel', 'client.js')
23
23
 
24
24
 
25
+ def espera_avisos(bus, quieto=0.6, tope=10):
26
+ """The seat's wake-ups, once they have arrived AND stopped arriving.
27
+
28
+ Reading `bus.mensajes` at a fixed moment samples a race, and a sample lies
29
+ in both directions: it saw zero on a loaded Linux runner and failed a
30
+ working product, and on a slow one it could see two of a hundred and pass a
31
+ broken one. Both halves of the assertion need the burst to be over — the
32
+ lower bound needs them to have arrived, the upper bound needs no more to be
33
+ coming.
34
+
35
+ So: wait for the first, then wait until none has appeared for `quieto`.
36
+ Quiescence, not a guess at how long a hundred arrivals take.
37
+ """
38
+ def cuantos():
39
+ return sum(1 for m in bus.mensajes
40
+ if m.get('method') == 'notifications/claude/channel')
41
+
42
+ limite = time.monotonic() + tope
43
+ bus.espera(lambda m: m.get('method') == 'notifications/claude/channel', segundos=tope)
44
+ ultimo, estable = cuantos(), time.monotonic()
45
+ while time.monotonic() < limite:
46
+ time.sleep(0.05)
47
+ ahora = cuantos()
48
+ if ahora != ultimo:
49
+ ultimo, estable = ahora, time.monotonic()
50
+ elif time.monotonic() - estable >= quieto:
51
+ break
52
+ return [m for m in bus.mensajes
53
+ if m.get('method') == 'notifications/claude/channel']
54
+
55
+
25
56
  def espera(condicion, segundos=8):
26
57
  limite = time.monotonic() + segundos
27
58
  while time.monotonic() < limite:
@@ -398,8 +429,9 @@ def main():
398
429
  'alice/home' in texto(roster)
399
430
  and 'alice/ghost' not in texto(roster)
400
431
  and '"online": true' in texto(roster), texto(roster))
401
- road_notices = [m for m in b.mensajes
402
- if m.get('method') == 'notifications/claude/channel']
432
+ # Waited for, not sampled. Reading the list at a fixed moment saw zero
433
+ # on a loaded runner and failed a product that was working.
434
+ road_notices = espera_avisos(b)
403
435
  # Coalesced, not exactly-one. The property is that a hundred arrivals
404
436
  # do not become a hundred interruptions; whether the window happens to
405
437
  # close once or twice mid-burst is the machine's business, and asserting