agents-city 0.5.11 → 0.5.12

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.11",
12
+ "version": "0.5.12",
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
  ]
@@ -34,12 +34,12 @@ def table(rows):
34
34
  widths = [max(len(label), *(len(str(row[key])) for row in rows))
35
35
  for label, key in columns]
36
36
  lines = [
37
- ' '.join(label.ljust(width) for (label, _), width in zip(columns, widths)),
37
+ ' '.join(label.ljust(width) for (label, _), width in zip(columns, widths, strict=False)),
38
38
  ' '.join('-' * width for width in widths),
39
39
  ]
40
40
  for row in rows:
41
41
  lines.append(' '.join(str(row[key]).ljust(width)
42
- for (_, key), width in zip(columns, widths)))
42
+ for (_, key), width in zip(columns, widths, strict=False)))
43
43
  return '\n'.join(lines)
44
44
 
45
45
 
@@ -42,7 +42,8 @@ def parse_args():
42
42
  parser.add_argument('--timeout', type=int, default=180,
43
43
  help='seconds to wait for model positions (default: 180)')
44
44
  parser.add_argument('--json', action='store_true', help='print only the JSON report')
45
- parser.add_argument('--no-save', action='store_true', help='do not append the local baseline ledger')
45
+ parser.add_argument('--no-save', action='store_true',
46
+ help='do not append the local baseline ledger')
46
47
  parser.add_argument('--keep', action='store_true', help='keep the temporary city and logs')
47
48
  return parser.parse_args()
48
49
 
@@ -81,7 +82,7 @@ def main():
81
82
  os.symlink(os.path.join(ROOT, 'bin', 'agents-city.js'), os.path.join(tools, 'agents-city'))
82
83
  marker = f'CITY_NATIVE_{int(time.time())}_{os.getpid()}'
83
84
  actors = {runtime: f'{runtime}-agent' for runtime in selected}
84
- for runtime, actor in actors.items():
85
+ for _runtime, actor in actors.items():
85
86
  repo = os.path.join(repos, actor)
86
87
  os.makedirs(repo)
87
88
  with open(os.path.join(repo, 'README.md'), 'w', encoding='utf-8') as stream:
@@ -227,7 +228,9 @@ def wait_ready(runtimes, actors, processes, streams, app, timeout):
227
228
  for runtime in runtimes:
228
229
  process = processes[runtime]
229
230
  if process.poll() is not None:
230
- raise RuntimeError(f'{runtime} exited during startup: {tail(read_stream(streams[runtime]), 12)}')
231
+ raise RuntimeError(
232
+ f'{runtime} exited during startup: '
233
+ f'{tail(read_stream(streams[runtime]), 12)}')
231
234
  ready = ready and os.path.exists(
232
235
  os.path.join(status_dir, f'{actors[runtime]}.json'))
233
236
  if ready:
@@ -256,7 +259,8 @@ def report_for(results, unavailable, thread, duration):
256
259
  return {
257
260
  'schema': 'agents-city/native-e2e-latency@1',
258
261
  'runAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
259
- 'scope': 'live task completion through bus, native provider protocol and committee position',
262
+ 'scope': ('live task completion through bus, native provider protocol '
263
+ 'and committee position'),
260
264
  'thread': thread,
261
265
  'durationMs': duration,
262
266
  'results': results + unavailable,
@@ -140,7 +140,8 @@ def run(args):
140
140
  raise RuntimeError('a Codex gateway exited during startup')
141
141
  startup_hubs = hub_pids(city)
142
142
  if len(startup_hubs) != 1:
143
- raise RuntimeError(f'concurrent startup created {len(startup_hubs)} hubs: {startup_hubs}')
143
+ raise RuntimeError(
144
+ f'concurrent startup created {len(startup_hubs)} hubs: {startup_hubs}')
144
145
  report['startupMs'] = round((time.monotonic() - started) * 1000)
145
146
  report['startupHubProcesses'] = len(startup_hubs)
146
147
 
@@ -156,8 +157,11 @@ def run(args):
156
157
  # recovery, not a stale endpoint. What must disappear is the
157
158
  # stopped PID's ownership; the endpoint may validly already name
158
159
  # the one new hub.
160
+ # Bound as a default: the lambda is called in this same
161
+ # iteration, so late binding does not bite — but correct by
162
+ # accident is not correct.
159
163
  if not wait_for(
160
- lambda: endpoint_released_or_replaced(endpoint_path, stopped_pid), 4
164
+ lambda pid=stopped_pid: endpoint_released_or_replaced(endpoint_path, pid), 4
161
165
  ):
162
166
  raise RuntimeError('the stopped hub still owns the endpoint')
163
167
  round_started = time.monotonic()
@@ -178,8 +182,8 @@ def run(args):
178
182
  f'round {round_number} spectator feed did not represent all '
179
183
  f'{args.agents} selected actors exactly once')
180
184
  if not wait_for(
181
- lambda: round_complete(thread, claude_capture, codex_capture, metrics_path,
182
- args.agents // 2, args.agents),
185
+ lambda t=thread: round_complete(t, claude_capture, codex_capture,
186
+ metrics_path, args.agents // 2, args.agents),
183
187
  args.timeout,
184
188
  ):
185
189
  raise RuntimeError(
@@ -197,7 +201,8 @@ def run(args):
197
201
  f'Claude={claude_count}, Codex={codex_count}')
198
202
  if len(metrics) != args.agents:
199
203
  raise RuntimeError(
200
- f'round {round_number} recorded {len(metrics)} acceptances, expected {args.agents}')
204
+ f'round {round_number} recorded {len(metrics)} acceptances, '
205
+ f'expected {args.agents}')
201
206
  latencies = sorted(int(row.get('totalToNativeAcceptMs', 0)) for row in metrics)
202
207
  round_report = {
203
208
  'round': round_number,
package/bin/hall CHANGED
@@ -28,6 +28,7 @@ sys.path.insert(0, AQUI)
28
28
  sys.path.insert(0, os.path.join(os.path.dirname(AQUI), 'plugin', 'scripts'))
29
29
  import serve # noqa: E402
30
30
  import cities # noqa: E402
31
+ import runtime_processes # noqa: E402 how a child is cut loose from a terminal
31
32
 
32
33
  def asegura_build():
33
34
  """hall.js, built if missing or older than its source. Same npm the map already
@@ -358,7 +358,40 @@ def la_puerta_de_windows():
358
358
  afirma('· and the town hall is not one of them',
359
359
  'hall' not in conShell, str(conShell))
360
360
 
361
- # 3. Every file this product reads or writes is UTF-8, said out loud. A
361
+ # 3. Every Python file this repo ships is one the linter actually reads.
362
+ #
363
+ # `bin/hall` is Python with no `.py` on it, and ruff selects by a glob on
364
+ # the extension — so the town hall was never linted, and shipped a call to
365
+ # a module it had forgotten to import. `F821 Undefined name` would have
366
+ # caught it before a tag did.
367
+ #
368
+ # Third time in this repo that a tool chose files by their NAME instead of
369
+ # by what they are: shellcheck's glob broke when a folder emptied and had
370
+ # been missing seven scripts; the front door called the town hall a shell
371
+ # program because it is long; and this.
372
+ import fnmatch # noqa: PLC0415
373
+ import tomllib # noqa: PLC0415
374
+
375
+ incluidos = tomllib.load(open(os.path.join(RAIZ, 'pyproject.toml'), 'rb'))
376
+ patrones = incluidos['tool']['ruff']['include']
377
+ seguidos = subprocess.run(['git', 'ls-files'], cwd=RAIZ, capture_output=True,
378
+ text=True).stdout.split()
379
+ fuera = []
380
+ for rel in seguidos:
381
+ ruta = os.path.join(RAIZ, rel)
382
+ if not os.path.isfile(ruta) or rel.startswith('bin/test'):
383
+ continue
384
+ if not rel.endswith('.py'):
385
+ with open(ruta, 'rb') as f:
386
+ cabeza = f.readline()
387
+ if not (cabeza.startswith(b'#!') and b'python' in cabeza.lower()):
388
+ continue
389
+ if not any(fnmatch.fnmatch(rel, p) for p in patrones):
390
+ fuera.append(rel)
391
+ afirma('· every Python file this repo ships is one the linter reads',
392
+ not fuera, 'not covered by ruff include: ' + ', '.join(fuera))
393
+
394
+ # 4. Every file this product reads or writes is UTF-8, said out loud. A
362
395
  # bare `open()` takes the console's code page on Windows, which is how a
363
396
  # card with an em dash was written in cp1252 and read back as UTF-8 by
364
397
  # the next door along — the first thing a Windows runner found.
@@ -380,14 +413,14 @@ def la_puerta_de_windows():
380
413
  not sinDecir, 'a bare open() takes the console code page: '
381
414
  + ', '.join(sinDecir[:8]))
382
415
 
383
- # 4. The launcher every window runs. Written in the language of the machine
416
+ # 5. The launcher every window runs. Written in the language of the machine
384
417
  # it will run on — and what it carries is a plan, not a shell sentence.
385
418
  lanzador = open(os.path.join(RAIZ, 'plugin', 'scripts', 'launch.py'),
386
419
  encoding='utf-8').read()
387
420
  afirma('· the launcher is written for the machine it will run on',
388
421
  "'#!/usr/bin/env bash'" in lanzador and '@echo off' in lanzador, '')
389
422
 
390
- # 5. And the promise on the tin matches all of that. Windows is claimed
423
+ # 6. And the promise on the tin matches all of that. Windows is claimed
391
424
  # because a machine says so, not because the code looks portable: the
392
425
  # runner opens a city there every time this is pushed.
393
426
  afirma('· package.json says where this runs, and a runner agrees',
@@ -31,7 +31,7 @@ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
31
31
  sys.path.insert(0, AQUI)
32
32
 
33
33
  import serve # noqa: E402
34
- from testlib import afirma, resumen # noqa: E402
34
+ from testlib import afirma, detiene_hubs_de_ciudad, resumen # noqa: E402
35
35
 
36
36
 
37
37
  def disco_de_prueba():
@@ -145,6 +145,63 @@ def bundle_al_dia():
145
145
  return "rebuilt from src/hall.ts"
146
146
 
147
147
 
148
+ def la_puerta_que_abre_el_ayuntamiento():
149
+ """`bin/hall`, run as a person runs it — detached, and answering.
150
+
151
+ This is the check that was missing. The hall shipped calling a module it had
152
+ forgotten to import, and nothing here noticed, because every suite started
153
+ `serve.py` directly and none of them ever came through the door. A
154
+ `NameError` in `despega` is only reachable by launching one.
155
+
156
+ The linter would have caught it too — and now does, because `bin/hall` is
157
+ Python with no `.py` on it and ruff selects by a glob on the extension. Two
158
+ different holes, one bug through both.
159
+ """
160
+ print(" the door that opens the town hall")
161
+ casa = tempfile.mkdtemp(prefix="agents-city-hall-")
162
+ datos = os.path.join(casa, "app", "navtest", "home")
163
+ os.makedirs(datos)
164
+ ciudad_de_prueba(datos)
165
+ entorno = dict(os.environ, HOME=casa, AGENTS_CITY_HOME=os.path.join(casa, "app"),
166
+ AGENTS_CITY_DATA=datos, AGENTS_CITY_USER="navtest",
167
+ CITY_DIR=os.path.join(casa, "canal"))
168
+ entorno.pop("CITY_BUS_URL", None)
169
+ salida = ""
170
+ try:
171
+ r = subprocess.run([sys.executable, os.path.join(RAIZ, "bin", "hall"),
172
+ "--no-browser"], capture_output=True, text=True,
173
+ timeout=90, env=entorno, cwd=casa)
174
+ salida = r.stdout + r.stderr
175
+ afirma("· happy: the door starts a hall and hands back an address",
176
+ "http://127.0.0.1:" in salida and "Traceback" not in salida,
177
+ salida[-500:])
178
+ direccion = ""
179
+ for trozo in salida.split():
180
+ if trozo.startswith("http://127.0.0.1:"):
181
+ direccion = trozo
182
+ break
183
+ contesta = False
184
+ if direccion:
185
+ import urllib.error # noqa: PLC0415
186
+ import urllib.request # noqa: PLC0415
187
+
188
+ try:
189
+ with urllib.request.urlopen(direccion, timeout=5) as resp:
190
+ contesta = resp.status == 200
191
+ except (OSError, urllib.error.URLError):
192
+ contesta = False
193
+ afirma("· happy: and that address really serves the page",
194
+ contesta, direccion or salida[-300:])
195
+ afirma("· non-happy: nothing it prints is a stack trace",
196
+ "NameError" not in salida and "Traceback" not in salida,
197
+ salida[-500:])
198
+ finally:
199
+ subprocess.run([sys.executable, os.path.join(RAIZ, "plugin", "scripts", "apaga.py"),
200
+ datos, "--yes"], capture_output=True, timeout=60, env=entorno)
201
+ detiene_hubs_de_ciudad(datos)
202
+ shutil.rmtree(casa, ignore_errors=True)
203
+
204
+
148
205
  def un_chrome_que_no_arranca():
149
206
  """What the driver says when the browser never answers.
150
207
 
@@ -181,6 +238,7 @@ def un_chrome_que_no_arranca():
181
238
  def main():
182
239
  print()
183
240
  print(" the hall, in a browser that clicks")
241
+ la_puerta_que_abre_el_ayuntamiento()
184
242
  un_chrome_que_no_arranca()
185
243
  print(f" · {bundle_al_dia()}")
186
244
  datos = tempfile.mkdtemp()
@@ -5,7 +5,10 @@ It exists because the PNGs come out of the browser in base64, and there is no
5
5
  point routing them through anything in between: the browser posts them here and
6
6
  here they are saved.
7
7
  """
8
- import base64, json, os, re
8
+ import base64
9
+ import json
10
+ import os
11
+ import re
9
12
  from http.server import BaseHTTPRequestHandler, HTTPServer
10
13
 
11
14
  DESTINO = os.path.join(os.path.dirname(__file__), '..', 'web', 'assets', 'sprites')
@@ -13,7 +16,9 @@ os.makedirs(DESTINO, exist_ok=True)
13
16
 
14
17
  class Manos(BaseHTTPRequestHandler):
15
18
  def do_OPTIONS(self):
16
- self.send_response(204); self.cors(); self.end_headers()
19
+ self.send_response(204)
20
+ self.cors()
21
+ self.end_headers()
17
22
  def do_POST(self):
18
23
  n = int(self.headers.get('content-length', 0))
19
24
  cuerpo = self.rfile.read(n)
@@ -33,8 +38,10 @@ class Manos(BaseHTTPRequestHandler):
33
38
  json.dump(todas, open(metas, 'w'), indent=1, sort_keys=True)
34
39
  self.send_response(200)
35
40
  except Exception as e:
36
- print('fallo:', e); self.send_response(500)
37
- self.cors(); self.end_headers()
41
+ print('fallo:', e)
42
+ self.send_response(500)
43
+ self.cors()
44
+ self.end_headers()
38
45
  def cors(self):
39
46
  self.send_header('access-control-allow-origin', '*')
40
47
  self.send_header('access-control-allow-headers', '*')
package/city/web/sella.py CHANGED
@@ -8,7 +8,10 @@ dist/index.html to /city.js?v=<hash of the bundle>, so a changed bundle is a
8
8
  changed URL and the browser fetches it. An unchanged bundle keeps its hash and
9
9
  stays cached, which is the whole point of the cache.
10
10
  """
11
- import hashlib, pathlib, re, sys
11
+ import hashlib
12
+ import pathlib
13
+ import re
14
+ import sys
12
15
 
13
16
  dist = pathlib.Path(__file__).parent / 'dist'
14
17
  for pagina, guion in (('index.html', 'city.js'),):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-city",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "description": "Run autonomous agent cities: one chair seat, role-aware repo agents, repo-owned skills, and explicit roads.",
5
5
  "bin": {
6
6
  "agents-city": "bin/agents-city.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "city",
3
3
  "displayName": "Agents City",
4
- "version": "0.5.11",
4
+ "version": "0.5.12",
5
5
  "description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
6
6
  "author": {
7
7
  "name": "jlcases",