agentainer 0.1.4 → 0.1.5
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.
- package/lib/config.py +22 -2
- package/lib/swarm.py +161 -6
- package/package.json +1 -1
package/lib/config.py
CHANGED
|
@@ -242,6 +242,8 @@ class SwarmConfig:
|
|
|
242
242
|
pane_scrollback: int = 400
|
|
243
243
|
tmux_history_limit: int = 50000
|
|
244
244
|
tmux_mouse: bool = True
|
|
245
|
+
supervise: bool = True
|
|
246
|
+
supervise_interval_ms: int = 15000
|
|
245
247
|
warnings: list[str] = field(default_factory=list)
|
|
246
248
|
|
|
247
249
|
@property
|
|
@@ -370,6 +372,8 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
370
372
|
# Pass 1: materialise agents without resolving peer references.
|
|
371
373
|
agents: list[Agent] = []
|
|
372
374
|
seen: set[str] = set()
|
|
375
|
+
# Warnings collected while resolving each agent (e.g. capture upgrades).
|
|
376
|
+
capture_warnings: list[str] = []
|
|
373
377
|
for index, raw in enumerate(raw_agents):
|
|
374
378
|
if not isinstance(raw, dict):
|
|
375
379
|
raise ConfigError(f"agents[{index}]: must be a mapping")
|
|
@@ -407,6 +411,19 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
407
411
|
)
|
|
408
412
|
if capture == "auto":
|
|
409
413
|
capture = str(tconf.get("capture") or "pane")
|
|
414
|
+
# capture: none on a type that HAS a completion hook (claude/codex) removes
|
|
415
|
+
# the agent's only turn-completion signal and leaves the orchestrator blind
|
|
416
|
+
# to a silent turn -- which can wedge the whole swarm. Auto-upgrade to the
|
|
417
|
+
# type's natural capture so the hook keeps the orchestrator informed (and
|
|
418
|
+
# re-enables busy_check / tag parsing / reply reminders, force-disabled for
|
|
419
|
+
# capture: none below). gemini/hermes deliberately keep capture: none.
|
|
420
|
+
if capture == "none" and str(tconf.get("capture")) == "hook":
|
|
421
|
+
capture = "hook"
|
|
422
|
+
capture_warnings.append(
|
|
423
|
+
f"agent {name!r}: capture: none on a {atype} agent gives the "
|
|
424
|
+
f"orchestrator no turn-completion signal -- auto-upgraded to "
|
|
425
|
+
f"capture: hook. Use capture: pane (gemini/hermes) for deliberate-send."
|
|
426
|
+
)
|
|
410
427
|
|
|
411
428
|
boot = raw.get("boot_delay_ms")
|
|
412
429
|
if boot is None:
|
|
@@ -501,8 +518,8 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
501
518
|
True,
|
|
502
519
|
f"agent {name}: reply_reminder",
|
|
503
520
|
),
|
|
504
|
-
resume_args=raw.get("resume_args"
|
|
505
|
-
resume_command=raw.get("resume_command"
|
|
521
|
+
resume_args=raw.get("resume_args") or defaults.get("resume_args") or tconf.get("resume_args"),
|
|
522
|
+
resume_command=raw.get("resume_command") or defaults.get("resume_command") or tconf.get("resume_command"),
|
|
506
523
|
ready_probe=_as_bool(
|
|
507
524
|
raw.get("ready_probe", defaults.get("ready_probe")),
|
|
508
525
|
True,
|
|
@@ -564,6 +581,7 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
564
581
|
# Pass 2b: working directories. They may be auto-created under `root`, or
|
|
565
582
|
# point at an existing project -- possibly one shared by several agents.
|
|
566
583
|
warnings: list[str] = []
|
|
584
|
+
warnings.extend(capture_warnings)
|
|
567
585
|
for agent in agents:
|
|
568
586
|
# No tag parsing means no way to know whether it replied.
|
|
569
587
|
if not agent.parse_outbound_tags:
|
|
@@ -613,6 +631,8 @@ def load(path: str | os.PathLike) -> SwarmConfig:
|
|
|
613
631
|
pane_scrollback=int(swarm.get("pane_scrollback", 400)),
|
|
614
632
|
tmux_history_limit=int(swarm.get("tmux_history_limit", 50000)),
|
|
615
633
|
tmux_mouse=_as_bool(swarm.get("tmux_mouse"), True, "swarm.tmux_mouse"),
|
|
634
|
+
supervise=_as_bool(swarm.get("supervise"), True, "swarm.supervise"),
|
|
635
|
+
supervise_interval_ms=int(swarm.get("supervise_interval_ms", 15000)),
|
|
616
636
|
)
|
|
617
637
|
|
|
618
638
|
# Pass 3: build the full first prompt for each agent.
|
package/lib/swarm.py
CHANGED
|
@@ -384,10 +384,6 @@ def pane_text(session: str, scrollback: int = 0) -> str:
|
|
|
384
384
|
return ""
|
|
385
385
|
|
|
386
386
|
|
|
387
|
-
def visible_pane(session: str) -> str:
|
|
388
|
-
return pane_text(session)
|
|
389
|
-
|
|
390
|
-
|
|
391
387
|
def needle_for(body: str) -> str:
|
|
392
388
|
"""The *tail* of the text, which is what stays on screen after a paste.
|
|
393
389
|
|
|
@@ -1318,6 +1314,131 @@ def watcher_alive(cfg: SwarmConfig, agent: Agent) -> bool:
|
|
|
1318
1314
|
return False
|
|
1319
1315
|
|
|
1320
1316
|
|
|
1317
|
+
# --------------------------------------------------------------------------
|
|
1318
|
+
# supervisor: the heartbeat the orchestrator otherwise lacks
|
|
1319
|
+
# --------------------------------------------------------------------------
|
|
1320
|
+
|
|
1321
|
+
SUPERVISOR_PID = "supervisor.pid"
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def start_supervisor(cfg: SwarmConfig, names: list[str]) -> None:
|
|
1325
|
+
"""Launch the background liveness supervisor for *names* (the agents we started)."""
|
|
1326
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
1327
|
+
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
1328
|
+
logfile = (cfg.log_dir / "supervisor.log").open("a")
|
|
1329
|
+
# Pass the config via SWARM_CONFIG rather than `-c`: the `supervise` subcommand
|
|
1330
|
+
# is an internal plumbing command and we keep its CLI surface minimal/stable.
|
|
1331
|
+
env = dict(os.environ)
|
|
1332
|
+
env["SWARM_CONFIG"] = str(cfg.path)
|
|
1333
|
+
proc = subprocess.Popen(
|
|
1334
|
+
[sys.executable, str(Path(__file__).resolve()), "supervise", *names],
|
|
1335
|
+
stdout=logfile,
|
|
1336
|
+
stderr=subprocess.STDOUT,
|
|
1337
|
+
stdin=subprocess.DEVNULL,
|
|
1338
|
+
env=env,
|
|
1339
|
+
start_new_session=True,
|
|
1340
|
+
)
|
|
1341
|
+
(cfg.run_dir / SUPERVISOR_PID).write_text(str(proc.pid))
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def stop_supervisor(cfg: SwarmConfig) -> None:
|
|
1345
|
+
pid_file = cfg.run_dir / SUPERVISOR_PID
|
|
1346
|
+
if not pid_file.is_file():
|
|
1347
|
+
return
|
|
1348
|
+
try:
|
|
1349
|
+
pid = int(pid_file.read_text().strip())
|
|
1350
|
+
os.kill(pid, 15)
|
|
1351
|
+
except (OSError, ValueError):
|
|
1352
|
+
pass
|
|
1353
|
+
pid_file.unlink(missing_ok=True)
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def supervisor_alive(cfg: SwarmConfig) -> bool:
|
|
1357
|
+
pid_file = cfg.run_dir / SUPERVISOR_PID
|
|
1358
|
+
if not pid_file.is_file():
|
|
1359
|
+
return False
|
|
1360
|
+
try:
|
|
1361
|
+
os.kill(int(pid_file.read_text().strip()), 0)
|
|
1362
|
+
return True
|
|
1363
|
+
except (OSError, ValueError):
|
|
1364
|
+
return False
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def supervise_once(cfg: SwarmConfig, names: list[str], seen_dead: set[str]) -> None:
|
|
1368
|
+
"""One reconciliation pass over the watched agents.
|
|
1369
|
+
|
|
1370
|
+
Split out of the loop so it can be unit-tested without the timer. Reconciles
|
|
1371
|
+
two failure modes that the event-driven design otherwise never notices:
|
|
1372
|
+
|
|
1373
|
+
* a dead tmux session (CLI crashed / killed) -- mark the turn finished and
|
|
1374
|
+
warn; do NOT try to deliver into a pane that no longer exists;
|
|
1375
|
+
* a stale-busy agent (turn `delivered > completed` older than busy_timeout_ms)
|
|
1376
|
+
-- the capture that should have ended the turn never fired, so mark it
|
|
1377
|
+
finished and drain its inbox.
|
|
1378
|
+
|
|
1379
|
+
`seen_dead` is an in-memory set (the supervisor is one long-lived process)
|
|
1380
|
+
so a dead session is warned about once, not every tick.
|
|
1381
|
+
"""
|
|
1382
|
+
for name in names:
|
|
1383
|
+
agent = cfg.get(name)
|
|
1384
|
+
if not session_exists(agent.session):
|
|
1385
|
+
if name not in seen_dead:
|
|
1386
|
+
seen_dead.add(name)
|
|
1387
|
+
warn(f"{name}: tmux session gone; treating as finished")
|
|
1388
|
+
log_event(cfg, name, "dead", text="session gone")
|
|
1389
|
+
# Reconcile the turn so the agent is no longer "busy" forever, and
|
|
1390
|
+
# surface any mail that was queued to it (it cannot be delivered to
|
|
1391
|
+
# a dead pane, but the user should know it is stranded).
|
|
1392
|
+
mark_turn_finished(cfg, name)
|
|
1393
|
+
queued = queue_read(cfg, name)
|
|
1394
|
+
if queued:
|
|
1395
|
+
warn(
|
|
1396
|
+
f"{name}: {len(queued)} queued message(s) stranded "
|
|
1397
|
+
f"(recipient session is gone)"
|
|
1398
|
+
)
|
|
1399
|
+
continue
|
|
1400
|
+
seen_dead.discard(name)
|
|
1401
|
+
|
|
1402
|
+
state = turn_state(cfg, name)
|
|
1403
|
+
if state.get("delivered", 0) <= state.get("completed", 0):
|
|
1404
|
+
continue # idle or already reconciled; nothing to do
|
|
1405
|
+
age_ms = (time.time() - state.get("since", 0)) * 1000
|
|
1406
|
+
if age_ms <= cfg.busy_timeout_ms:
|
|
1407
|
+
continue # legitimately mid-turn; its own capture will end it
|
|
1408
|
+
warn(
|
|
1409
|
+
f"{name}: looked busy for {int(age_ms / 1000)}s "
|
|
1410
|
+
f"(over busy_timeout_ms); marking idle and draining its queue"
|
|
1411
|
+
)
|
|
1412
|
+
mark_turn_finished(cfg, name)
|
|
1413
|
+
try:
|
|
1414
|
+
drain_queue(cfg, agent)
|
|
1415
|
+
except SwarmError as exc:
|
|
1416
|
+
warn(f"{name}: could not drain queue: {exc}")
|
|
1417
|
+
|
|
1418
|
+
# Any turn end is an opportunity to rescue mail stranded on an agent whose own
|
|
1419
|
+
# capture never fired -- otherwise a single missed completion wedges its queue.
|
|
1420
|
+
sweep_stale_queues(cfg)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def run_supervisor(cfg: SwarmConfig, names: list[str]) -> None:
|
|
1424
|
+
"""Background loop: the heartbeat that keeps one silent agent from wedging the swarm.
|
|
1425
|
+
|
|
1426
|
+
The swarm is otherwise purely event-driven -- progress only happens when an
|
|
1427
|
+
agent's capture fires (hook/pane) or a human sends a message. If that event
|
|
1428
|
+
never arrives, nothing wakes the loop. This polls on a timer and reconciles
|
|
1429
|
+
stale/dead state. It self-exits once every watched session is gone (after
|
|
1430
|
+
`down`), so it does not run forever.
|
|
1431
|
+
"""
|
|
1432
|
+
info("supervisor started")
|
|
1433
|
+
seen_dead: set[str] = set()
|
|
1434
|
+
while True:
|
|
1435
|
+
sleep_ms(cfg.supervise_interval_ms)
|
|
1436
|
+
if not any(session_exists(cfg.get(n).session) for n in names):
|
|
1437
|
+
info("supervisor: no watched sessions remain, exiting")
|
|
1438
|
+
return
|
|
1439
|
+
supervise_once(cfg, names, seen_dead)
|
|
1440
|
+
|
|
1441
|
+
|
|
1321
1442
|
READY_TOKEN = "zqxswarmready"
|
|
1322
1443
|
|
|
1323
1444
|
|
|
@@ -1720,6 +1841,7 @@ def cmd_up(args) -> int:
|
|
|
1720
1841
|
continue
|
|
1721
1842
|
info(f"{agent.name}: restarting")
|
|
1722
1843
|
stop_watcher(cfg, agent)
|
|
1844
|
+
stop_supervisor(cfg)
|
|
1723
1845
|
tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
|
|
1724
1846
|
|
|
1725
1847
|
resume_cmd = None
|
|
@@ -1801,11 +1923,28 @@ def cmd_up(args) -> int:
|
|
|
1801
1923
|
if agent.capture == "pane":
|
|
1802
1924
|
start_watcher(cfg, agent)
|
|
1803
1925
|
|
|
1926
|
+
# The supervisor is the heartbeat the event-driven design lacks: it reconciles
|
|
1927
|
+
# dead/stale agents on a timer so one silent agent cannot wedge the swarm.
|
|
1928
|
+
if cfg.supervise and started:
|
|
1929
|
+
start_supervisor(cfg, [a.name for a in started])
|
|
1930
|
+
|
|
1804
1931
|
print()
|
|
1805
1932
|
info(f"swarm {cfg.name!r} is up with {len(started)} agent(s)")
|
|
1806
1933
|
info(f"attach with: tmux attach -t {started[0].session}")
|
|
1807
1934
|
info(f"or: {SWARM_HOME / 'agentainer'} attach {started[0].name}")
|
|
1808
1935
|
|
|
1936
|
+
if resumed:
|
|
1937
|
+
example = sorted(resumed)[0]
|
|
1938
|
+
print()
|
|
1939
|
+
info(
|
|
1940
|
+
"resumed agents are reattached to their previous conversation but sit "
|
|
1941
|
+
"idle at their prompt — they will NOT auto-continue on their own."
|
|
1942
|
+
)
|
|
1943
|
+
info(
|
|
1944
|
+
"to make one pick up where it left off, nudge it, e.g.: "
|
|
1945
|
+
f"agentainer send --to {example} \"Please continue from where you left off.\""
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1809
1948
|
if args.attach:
|
|
1810
1949
|
os.execvp("tmux", ["tmux", "attach", "-t", started[0].session])
|
|
1811
1950
|
return 0
|
|
@@ -1813,6 +1952,10 @@ def cmd_up(args) -> int:
|
|
|
1813
1952
|
|
|
1814
1953
|
def cmd_down(args) -> int:
|
|
1815
1954
|
cfg = cfgmod.load(args.config)
|
|
1955
|
+
# Stop the supervisor first (it is not per-agent); if only some agents are
|
|
1956
|
+
# being stopped, the supervisor self-exits once no watched sessions remain.
|
|
1957
|
+
if not args.only:
|
|
1958
|
+
stop_supervisor(cfg)
|
|
1816
1959
|
for agent in select_agents(cfg, args.only):
|
|
1817
1960
|
stop_watcher(cfg, agent)
|
|
1818
1961
|
if session_exists(agent.session):
|
|
@@ -1870,6 +2013,9 @@ def cmd_status(args) -> int:
|
|
|
1870
2013
|
cells = [str(c).ljust(widths[i]) for i, c in enumerate(row)]
|
|
1871
2014
|
cells[2] = f"{colour}{cells[2]}\033[0m"
|
|
1872
2015
|
print(" ".join(cells))
|
|
2016
|
+
sup = "alive" if supervisor_alive(cfg) else "down"
|
|
2017
|
+
print(f"\nsupervisor: {sup} (reconciles dead/stale agents every "
|
|
2018
|
+
f"{cfg.supervise_interval_ms}ms)")
|
|
1873
2019
|
return 0
|
|
1874
2020
|
|
|
1875
2021
|
|
|
@@ -2131,6 +2277,13 @@ def cmd_watch(args) -> int:
|
|
|
2131
2277
|
return 0
|
|
2132
2278
|
|
|
2133
2279
|
|
|
2280
|
+
def cmd_supervise(args) -> int:
|
|
2281
|
+
cfg = cfgmod.load(args.config) if args.config else cfgmod.load(default_config())
|
|
2282
|
+
names = list(args.names) or [a.name for a in cfg.agents]
|
|
2283
|
+
run_supervisor(cfg, names)
|
|
2284
|
+
return 0
|
|
2285
|
+
|
|
2286
|
+
|
|
2134
2287
|
def select_agents(cfg: SwarmConfig, only: str | None) -> list[Agent]:
|
|
2135
2288
|
if not only:
|
|
2136
2289
|
return list(cfg.agents)
|
|
@@ -2265,6 +2418,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2265
2418
|
p_watch = add("watch", cmd_watch, "internal: poll an agent's tmux pane for completed turns")
|
|
2266
2419
|
p_watch.add_argument("agent")
|
|
2267
2420
|
|
|
2421
|
+
p_sup = add("supervise", cmd_supervise, "internal: background liveness watchdog")
|
|
2422
|
+
p_sup.add_argument("names", nargs="*", help="agents to watch (default: all)")
|
|
2423
|
+
|
|
2268
2424
|
return parser
|
|
2269
2425
|
|
|
2270
2426
|
|
|
@@ -2285,8 +2441,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2285
2441
|
die(f"command failed: {exc}")
|
|
2286
2442
|
except KeyboardInterrupt:
|
|
2287
2443
|
return 130
|
|
2288
|
-
return 0
|
|
2289
2444
|
|
|
2290
2445
|
|
|
2291
|
-
if __name__ == "__main__":
|
|
2446
|
+
if __name__ == "__main__": # pragma: no cover - exercised by running the module as a script
|
|
2292
2447
|
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentainer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Zero-dependency multi-agent orchestrator: run a team of AI coding agents (Claude Code, Codex, Gemini, Hermes) side by side in tmux with a YAML-defined ACL.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|