agentainer 0.1.6 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +248 -677
- package/agentainer +16 -18
- package/agentainer.example.yaml +86 -0
- package/bin/agentainer.js +9 -8
- package/examples/brainstorm.yaml +27 -128
- package/examples/bug-hunt.yaml +51 -96
- package/examples/code-review.yaml +73 -0
- package/examples/debate.yaml +16 -90
- package/examples/incident-response.yaml +52 -109
- package/examples/localization.yaml +56 -123
- package/examples/quickstart.yaml +48 -0
- package/examples/research.yaml +25 -0
- package/examples/software-company.yaml +71 -128
- package/examples/tdd-pingpong.yaml +36 -68
- package/examples/writers-room.yaml +49 -111
- package/hooks/claude_stop.sh +5 -3
- package/hooks/codex_notify.sh +4 -3
- package/lib/cli.py +929 -0
- package/lib/config.py +247 -305
- package/lib/hooks.py +246 -0
- package/lib/lock.py +75 -0
- package/lib/log.py +64 -0
- package/lib/mail.py +634 -0
- package/lib/minyaml.py +1 -39
- package/lib/reconcile.py +473 -0
- package/lib/sessions.py +223 -0
- package/lib/supervisor.py +216 -0
- package/lib/telegram.py +372 -0
- package/lib/tmux.py +355 -0
- package/lib/turn.py +159 -0
- package/lib/ui.py +1020 -0
- package/llms.txt +145 -429
- package/package.json +9 -7
- package/scripts/check-deps.js +18 -61
- package/ui/app.js +869 -0
- package/ui/index.html +348 -0
- package/agents.example.yaml +0 -257
- package/examples/code-review-broadcast.yaml +0 -109
- package/examples/existing-repo.yaml +0 -74
- package/examples/multi-language-broadcast.yaml +0 -127
- package/examples/ping-pong.yaml +0 -89
- package/examples/red-team.yaml +0 -117
- package/examples/research-swarm.yaml +0 -129
- package/lib/swarm.py +0 -2461
package/lib/sessions.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Session / resume machinery for the Agentainer orchestrator.
|
|
2
|
+
|
|
3
|
+
A faithful port of v1's ``lib/swarm.py`` session helpers (``read_sessions``,
|
|
4
|
+
``write_sessions``, ``record_session``, ``codex_session``) and the lifecycle
|
|
5
|
+
helpers ``session_env`` / ``resume_command``, adapted to the v2 branding:
|
|
6
|
+
|
|
7
|
+
* ``SWARM_HOME`` -> ``AGENTAINER_HOME``, ``SWARM_ROOT`` -> ``AGENTAINER_ROOT``,
|
|
8
|
+
etc. on every env var name and log string;
|
|
9
|
+
* "swarm" -> "agentainer" in every message;
|
|
10
|
+
* the session file moved to ``cfg.sessions_file`` (``.agentainer/sessions.yaml``),
|
|
11
|
+
which ``config.py`` already exposes.
|
|
12
|
+
|
|
13
|
+
The YAML session file is the bridge that lets ``agentainer up`` (resume is the
|
|
14
|
+
default) reattach each agent to its own conversation after a restart. It is
|
|
15
|
+
written atomically
|
|
16
|
+
because the turn-completion hooks write to it concurrently.
|
|
17
|
+
|
|
18
|
+
Zero runtime dependencies: stdlib + our own ``config`` / ``minyaml`` / ``tmux``
|
|
19
|
+
helpers only. PyYAML is used when importable, otherwise the bundled ``minyaml``
|
|
20
|
+
subset parser (imported through ``config.parse_yaml`` so the two paths stay in
|
|
21
|
+
parity). The hand-written ``yaml_dump`` keeps the no-PyYAML path alive for writes.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from config import Agent, SwarmConfig, parse_yaml
|
|
33
|
+
from tmux import file_lock
|
|
34
|
+
|
|
35
|
+
# Repo root: AGENTAINER_HOME overrides, else this file's grandparent (lib/..).
|
|
36
|
+
AGENTAINER_HOME = Path(
|
|
37
|
+
os.environ.get("AGENTAINER_HOME") or Path(__file__).resolve().parent.parent
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------
|
|
42
|
+
# small utilities
|
|
43
|
+
# --------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def now_iso() -> str:
|
|
47
|
+
"""Current UTC time as an ISO-8601 string with second precision."""
|
|
48
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def info(msg: str) -> None:
|
|
52
|
+
print(f"\033[36m::\033[0m {msg}", file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def warn(msg: str) -> None:
|
|
56
|
+
print(f"\033[33m!!\033[0m {msg}", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --------------------------------------------------------------------------
|
|
60
|
+
# sessions.yaml -- the conversation id of every agent, so `up --resume` works
|
|
61
|
+
# --------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def yaml_scalar(value) -> str:
|
|
65
|
+
if value is None:
|
|
66
|
+
return "null"
|
|
67
|
+
if isinstance(value, bool):
|
|
68
|
+
return "true" if value else "false"
|
|
69
|
+
if isinstance(value, (int, float)):
|
|
70
|
+
return str(value)
|
|
71
|
+
text = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
72
|
+
return f'"{text}"'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def yaml_dump(data: dict, indent: int = 0) -> str:
|
|
76
|
+
"""Emit the small subset we need. Written by hand so PyYAML stays optional."""
|
|
77
|
+
pad = " " * indent
|
|
78
|
+
out = []
|
|
79
|
+
for key, value in data.items():
|
|
80
|
+
if isinstance(value, dict):
|
|
81
|
+
out.append(f"{pad}{key}:")
|
|
82
|
+
out.append(yaml_dump(value, indent + 2) if value else f"{pad} {{}}")
|
|
83
|
+
else:
|
|
84
|
+
out.append(f"{pad}{key}: {yaml_scalar(value)}")
|
|
85
|
+
return "\n".join(out)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_sessions(cfg: SwarmConfig) -> dict:
|
|
89
|
+
"""The agents block of sessions.yaml, or {} if it is missing or unreadable."""
|
|
90
|
+
try:
|
|
91
|
+
data = parse_yaml(cfg.sessions_file.read_text())
|
|
92
|
+
except OSError:
|
|
93
|
+
return {}
|
|
94
|
+
except Exception as exc: # noqa: BLE001 - a corrupt file must not stop the swarm
|
|
95
|
+
warn(f"could not parse {cfg.sessions_file}: {exc}")
|
|
96
|
+
return {}
|
|
97
|
+
if not isinstance(data, dict):
|
|
98
|
+
return {}
|
|
99
|
+
return data.get("agents") or {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_sessions(cfg: SwarmConfig, agents: dict) -> None:
|
|
103
|
+
cfg.runtime.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
header = (
|
|
105
|
+
"# Agentainer session state -- written automatically as agents work.\n"
|
|
106
|
+
"# `agentainer up` reads this to reattach each agent to its own\n"
|
|
107
|
+
"# conversation after a restart (resume is the default; `remove-session`\n"
|
|
108
|
+
"# wipes it for a clean start). Safe to delete; you then start fresh.\n"
|
|
109
|
+
)
|
|
110
|
+
body = yaml_dump(
|
|
111
|
+
{
|
|
112
|
+
"swarm": cfg.name,
|
|
113
|
+
"config": str(cfg.path),
|
|
114
|
+
"updated_at": now_iso(),
|
|
115
|
+
"agents": agents or {},
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
tmp = cfg.sessions_file.with_suffix(".yaml.tmp")
|
|
119
|
+
tmp.write_text(header + body + "\n")
|
|
120
|
+
os.replace(tmp, cfg.sessions_file) # atomic: hooks write this concurrently
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def record_session(cfg: SwarmConfig, agent: Agent, session_id, **fields) -> None:
|
|
124
|
+
"""Merge this agent's conversation id into sessions.yaml, under a lock."""
|
|
125
|
+
if not session_id:
|
|
126
|
+
return
|
|
127
|
+
with file_lock(cfg, "sessions", "lock"):
|
|
128
|
+
agents = read_sessions(cfg)
|
|
129
|
+
entry = agents.get(agent.name) or {}
|
|
130
|
+
if entry.get("session_id") == session_id:
|
|
131
|
+
return # unchanged: do not rewrite the file after every single turn
|
|
132
|
+
entry.update({k: v for k, v in fields.items() if v})
|
|
133
|
+
entry["session_id"] = session_id
|
|
134
|
+
entry["type"] = agent.type
|
|
135
|
+
entry["workdir"] = str(agent.workdir)
|
|
136
|
+
entry["updated_at"] = now_iso()
|
|
137
|
+
agents[agent.name] = entry
|
|
138
|
+
write_sessions(cfg, agents)
|
|
139
|
+
info(f"{agent.name}: recorded conversation {session_id}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def codex_session(agent: Agent) -> tuple[str | None, str | None]:
|
|
143
|
+
"""Find the id of the codex conversation running in this agent's CODEX_HOME.
|
|
144
|
+
|
|
145
|
+
Codex does not hand its session id to the notify program, but it writes one
|
|
146
|
+
rollout file per session under CODEX_HOME/sessions, and the newest of those is
|
|
147
|
+
the conversation currently in progress.
|
|
148
|
+
"""
|
|
149
|
+
sessions = agent.workdir / ".codex" / "sessions"
|
|
150
|
+
if not sessions.is_dir():
|
|
151
|
+
return None, None
|
|
152
|
+
|
|
153
|
+
rollouts = sorted(sessions.rglob("rollout-*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
154
|
+
if not rollouts:
|
|
155
|
+
return None, None
|
|
156
|
+
|
|
157
|
+
newest = rollouts[-1]
|
|
158
|
+
try:
|
|
159
|
+
with newest.open() as fh:
|
|
160
|
+
first = fh.readline()
|
|
161
|
+
record = json.loads(first)
|
|
162
|
+
if record.get("type") == "session_meta":
|
|
163
|
+
payload = record.get("payload", {})
|
|
164
|
+
return payload.get("session_id") or payload.get("id"), str(newest)
|
|
165
|
+
except (OSError, json.JSONDecodeError):
|
|
166
|
+
pass
|
|
167
|
+
return None, str(newest)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------
|
|
171
|
+
# lifecycle
|
|
172
|
+
# --------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def session_env(cfg: SwarmConfig, agent: Agent, extra: dict[str, str]) -> dict[str, str]:
|
|
176
|
+
"""The environment given to an agent's tmux session.
|
|
177
|
+
|
|
178
|
+
Always includes the authoritative Agentainer locations (``AGENTAINER_HOME``,
|
|
179
|
+
``AGENTAINER_ROOT`` == ``cfg.root``, the config path, the swarm name, and the
|
|
180
|
+
agent's own name/session/peers), then the agent's own ``env`` block, then any
|
|
181
|
+
caller-supplied ``extra`` (e.g. capture-hook vars from ``hooks.install_capture``
|
|
182
|
+
when the agent is launched).
|
|
183
|
+
"""
|
|
184
|
+
env = {
|
|
185
|
+
"AGENTAINER_HOME": str(AGENTAINER_HOME),
|
|
186
|
+
"AGENTAINER_CONFIG": str(cfg.path),
|
|
187
|
+
"AGENTAINER_ROOT": str(cfg.root),
|
|
188
|
+
"AGENTAINER_NAME": cfg.name,
|
|
189
|
+
"AGENTAINER_AGENT": agent.name,
|
|
190
|
+
"AGENTAINER_SESSION": agent.session,
|
|
191
|
+
"AGENTAINER_PEERS": ",".join(agent.can_talk_to),
|
|
192
|
+
}
|
|
193
|
+
env.update(agent.env)
|
|
194
|
+
env.update(extra)
|
|
195
|
+
return env
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def resume_command(cfg: SwarmConfig, agent: Agent, session_id: str) -> str | None:
|
|
199
|
+
"""The command that reattaches *agent* to conversation *session_id*.
|
|
200
|
+
|
|
201
|
+
``resume_command`` (an exact recipe) wins, because a command like
|
|
202
|
+
``bash -ic chy3`` invokes the CLI through an alias and flags cannot simply be
|
|
203
|
+
appended to it. Failing that, ``resume_args`` is formatted and appended to the
|
|
204
|
+
agent's command. Agents whose type has no recoverable session (gemini/hermes --
|
|
205
|
+
a session id cannot be scraped from a pane) have no recipe, so we warn and start
|
|
206
|
+
a fresh conversation; a malformed recipe is treated the same way.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
if agent.resume_command:
|
|
210
|
+
return agent.resume_command.format(session_id=session_id, command=agent.command)
|
|
211
|
+
if agent.resume_args:
|
|
212
|
+
return f"{agent.command} {agent.resume_args.format(session_id=session_id)}"
|
|
213
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
214
|
+
warn(
|
|
215
|
+
f"{agent.name}: resume recipe is malformed ({exc}); "
|
|
216
|
+
"starting a fresh conversation"
|
|
217
|
+
)
|
|
218
|
+
return None
|
|
219
|
+
warn(
|
|
220
|
+
f"{agent.name}: type {agent.type} has no resume recipe; "
|
|
221
|
+
"starting a fresh conversation"
|
|
222
|
+
)
|
|
223
|
+
return None
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agentainer -- the liveness supervisor (the orchestrator's heartbeat).
|
|
3
|
+
|
|
4
|
+
v1 shipped without this once and had to add it: the swarm is otherwise purely
|
|
5
|
+
event-driven -- progress only happens when an agent's capture fires (hook/notify)
|
|
6
|
+
or a human sends mail. If that event never arrives (a crashed CLI, a killed tmux
|
|
7
|
+
session, a capture that never fired, a "silent but alive" agent whose completion
|
|
8
|
+
we cannot trust), nothing wakes the loop and an agent sits on unread mail forever.
|
|
9
|
+
|
|
10
|
+
This module is the periodic heartbeat that reconciles those failure modes:
|
|
11
|
+
|
|
12
|
+
* STALE-BUSY -- the turn-completion signal (delivered > completed) is older
|
|
13
|
+
than ``busy_timeout_ms``; the hook/notify never fired, so we mark the turn
|
|
14
|
+
finished and let the queue advance.
|
|
15
|
+
* DEAD -- the tmux session is gone; we warn once (not every tick) and
|
|
16
|
+
reconcile the turn so the agent is not "busy" forever.
|
|
17
|
+
* SILENT-BUT-ALIVE -- the session is up but ``capture == "none"`` (v2 health
|
|
18
|
+
probe), so we have no reliable turn-completion signal; we surface it once.
|
|
19
|
+
* IDLE -- only then do we process read receipts, release the next queued
|
|
20
|
+
message (one-at-a-time) + nudge, or fire a periodic ping when the inbox would
|
|
21
|
+
be empty. Real mail always takes priority over pings.
|
|
22
|
+
|
|
23
|
+
Ported from v1 ``lib/swarm.py`` (start_supervisor / stop_supervisor /
|
|
24
|
+
supervisor_alive / supervise_once / run_supervisor) and adapted to drive the v2
|
|
25
|
+
file-based mailroom. Zero runtime dependencies: stdlib + bundled lib/ only.
|
|
26
|
+
|
|
27
|
+
Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
39
|
+
|
|
40
|
+
import config as cfgmod # noqa: E402
|
|
41
|
+
from config import SwarmConfig # noqa: E402
|
|
42
|
+
|
|
43
|
+
import log # noqa: E402
|
|
44
|
+
import mail # noqa: E402
|
|
45
|
+
import tmux # noqa: E402
|
|
46
|
+
import turn # noqa: E402
|
|
47
|
+
from tmux import sleep_ms # noqa: E402
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# PID file lives under the run dir (v1 used run_dir/supervisor.pid). Branded path.
|
|
51
|
+
SUPERVISOR_PID = "supervisor.pid"
|
|
52
|
+
|
|
53
|
+
# The supervise subcommand is the internal plumbing entry the CLI exposes; we keep
|
|
54
|
+
# its surface minimal/stable. Launched via the project's launcher so the same
|
|
55
|
+
# install resolution (AGENTAINER_HOME) the `./agentainer` script uses applies.
|
|
56
|
+
_CLI = Path(__file__).resolve().parent / "cli.py"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --------------------------------------------------------------------------
|
|
60
|
+
# process management: launch / stop / probe the background supervisor
|
|
61
|
+
# --------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def start_supervisor(cfg: SwarmConfig, names: list[str]) -> None:
|
|
65
|
+
"""Launch the background liveness supervisor for *names* (the agents we started)."""
|
|
66
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
logfile = (cfg.log_dir / "supervisor.log").open("a")
|
|
69
|
+
# Pass the config via AGENTAINER_CONFIG (mirrors v1's SWARM_CONFIG) and export
|
|
70
|
+
# AGENTAINER_HOME so the spawned launcher resolves the install and lib/ path.
|
|
71
|
+
env = dict(os.environ)
|
|
72
|
+
env["AGENTAINER_HOME"] = str(Path(__file__).resolve().parent.parent)
|
|
73
|
+
env["AGENTAINER_CONFIG"] = str(cfg.path)
|
|
74
|
+
proc = subprocess.Popen(
|
|
75
|
+
[sys.executable, str(_CLI), "supervise", *names],
|
|
76
|
+
stdout=logfile,
|
|
77
|
+
stderr=subprocess.STDOUT,
|
|
78
|
+
stdin=subprocess.DEVNULL,
|
|
79
|
+
env=env,
|
|
80
|
+
start_new_session=True,
|
|
81
|
+
)
|
|
82
|
+
(cfg.run_dir / SUPERVISOR_PID).write_text(str(proc.pid))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def stop_supervisor(cfg: SwarmConfig) -> None:
|
|
86
|
+
pid_file = cfg.run_dir / SUPERVISOR_PID
|
|
87
|
+
if not pid_file.is_file():
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
pid = int(pid_file.read_text().strip())
|
|
91
|
+
os.kill(pid, 15)
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
pass
|
|
94
|
+
pid_file.unlink(missing_ok=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def supervisor_alive(cfg: SwarmConfig) -> bool:
|
|
98
|
+
pid_file = cfg.run_dir / SUPERVISOR_PID
|
|
99
|
+
if not pid_file.is_file():
|
|
100
|
+
return False
|
|
101
|
+
try:
|
|
102
|
+
os.kill(int(pid_file.read_text().strip()), 0)
|
|
103
|
+
return True
|
|
104
|
+
except (OSError, ValueError):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
# the heartbeat
|
|
110
|
+
# --------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _seen_silent_set(cfg: SwarmConfig) -> set[str]:
|
|
114
|
+
"""Return the per-config set of agents currently known silent-but-alive.
|
|
115
|
+
|
|
116
|
+
Stored on the config object itself (a process-global "state flag") so the
|
|
117
|
+
fixed ``supervise_once(cfg, names, seen_dead)`` signature need not grow a
|
|
118
|
+
parameter, and the transition (log once, not every tick) survives across
|
|
119
|
+
ticks. A fresh config starts with an empty set.
|
|
120
|
+
"""
|
|
121
|
+
s = getattr(cfg, "_seen_silent", None)
|
|
122
|
+
if s is None:
|
|
123
|
+
s = set()
|
|
124
|
+
cfg._seen_silent = s
|
|
125
|
+
return s
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def supervise_once(cfg: SwarmConfig, names: list[str], seen_dead: set[str]) -> None:
|
|
129
|
+
"""One reconciliation pass over the watched agents.
|
|
130
|
+
|
|
131
|
+
Split out of the loop so it can be unit-tested without the timer. Reconciles
|
|
132
|
+
the failure modes the event-driven design otherwise never notices (see the
|
|
133
|
+
module docstring). ``seen_dead`` is an in-memory set (the supervisor is one
|
|
134
|
+
long-lived process) so a dead session is warned about once, not every tick.
|
|
135
|
+
"""
|
|
136
|
+
for name in names:
|
|
137
|
+
agent = cfg.get(name)
|
|
138
|
+
|
|
139
|
+
# (a) STALE-BUSY. v2's ``turn.busy_info`` already fails open (warns and
|
|
140
|
+
# returns None) when the turn is older than busy_timeout_ms, so read the
|
|
141
|
+
# authoritative turn state directly to detect + recover the wedged agent
|
|
142
|
+
# and record it in the event log.
|
|
143
|
+
state = turn.turn_state(cfg, name)
|
|
144
|
+
if state.get("delivered", 0) > state.get("completed", 0):
|
|
145
|
+
age_ms = (time.time() - state.get("since", 0)) * 1000
|
|
146
|
+
if age_ms > cfg.busy_timeout_ms:
|
|
147
|
+
log.log_event(cfg, name, "stale-busy", age_s=int(age_ms / 1000))
|
|
148
|
+
turn.mark_turn_finished(cfg, name)
|
|
149
|
+
|
|
150
|
+
# (b) DEAD session. Don't try to deliver into a pane that no longer
|
|
151
|
+
# exists; reconcile the turn and warn once per transition.
|
|
152
|
+
if not tmux.session_exists(agent.session):
|
|
153
|
+
if name not in seen_dead:
|
|
154
|
+
seen_dead.add(name)
|
|
155
|
+
log.log_event(cfg, name, "dead")
|
|
156
|
+
turn.mark_turn_finished(cfg, name)
|
|
157
|
+
continue
|
|
158
|
+
seen_dead.discard(name)
|
|
159
|
+
|
|
160
|
+
# (c) SILENT-BUT-ALIVE. The v2 health probe surfaces an agent that is up
|
|
161
|
+
# in tmux but whose turn-completion signal the orchestrator cannot trust
|
|
162
|
+
# (capture == "none"). Log the transition once; clear it when it resolves.
|
|
163
|
+
hp = turn.health_probe(cfg, agent)
|
|
164
|
+
if hp["silent_but_alive"]:
|
|
165
|
+
silent = _seen_silent_set(cfg)
|
|
166
|
+
if name not in silent:
|
|
167
|
+
silent.add(name)
|
|
168
|
+
log.log_event(cfg, name, "silent-but-alive")
|
|
169
|
+
else:
|
|
170
|
+
_seen_silent_set(cfg).discard(name)
|
|
171
|
+
|
|
172
|
+
# (d) IDLE: the only time we push work. Read receipts first, then deliver
|
|
173
|
+
# real queued mail one-at-a-time (nudge), else a periodic ping -- real
|
|
174
|
+
# mail always takes priority over pings.
|
|
175
|
+
if turn.busy_info(cfg, agent) is None:
|
|
176
|
+
mail.process_read_folder(cfg, name)
|
|
177
|
+
if not mail.release_next(cfg, name):
|
|
178
|
+
mail.maybe_ping(cfg, name)
|
|
179
|
+
else:
|
|
180
|
+
mail.nudge(cfg, name)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _emit(cfg: SwarmConfig, kind: str, msg: str) -> None:
|
|
184
|
+
"""Log a supervisor lifecycle event and echo it to stderr for the operator."""
|
|
185
|
+
log.log_event(cfg, "supervisor", kind)
|
|
186
|
+
print(f"supervisor: {msg}", file=sys.stderr)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run_supervisor(cfg: SwarmConfig, names: list[str]) -> None:
|
|
190
|
+
"""Background loop: the heartbeat that keeps one silent agent from wedging the swarm.
|
|
191
|
+
|
|
192
|
+
The swarm is otherwise purely event-driven -- progress only happens when an
|
|
193
|
+
agent's capture fires (hook/pane) or a human sends a message. If that event
|
|
194
|
+
never arrives, nothing wakes the loop. This polls on a timer and reconciles
|
|
195
|
+
stale/dead/silent state. It self-exits once every watched session is gone
|
|
196
|
+
(after `down`), so it does not run forever.
|
|
197
|
+
"""
|
|
198
|
+
_emit(cfg, "supervisor-start", "started")
|
|
199
|
+
seen_dead: set[str] = set()
|
|
200
|
+
try:
|
|
201
|
+
while True:
|
|
202
|
+
sleep_ms(cfg.supervise_interval_ms)
|
|
203
|
+
if not any(tmux.session_exists(cfg.get(n).session) for n in names):
|
|
204
|
+
_emit(cfg, "no-watched-sessions", "no watched sessions remain, exiting")
|
|
205
|
+
return
|
|
206
|
+
try:
|
|
207
|
+
supervise_once(cfg, names, seen_dead)
|
|
208
|
+
except Exception as exc: # noqa: BLE001 - one agent's failure must not kill the heartbeat
|
|
209
|
+
# The supervisor exists precisely so one wedged/silent agent
|
|
210
|
+
# can't take down the swarm. A lost release_next race or a
|
|
211
|
+
# bad pane read must be logged and survived, never propagated
|
|
212
|
+
# into the process that keeps the whole swarm alive.
|
|
213
|
+
log.log_event(cfg, "supervisor", "tick-error", error=str(exc))
|
|
214
|
+
print(f"supervisor: tick error (continuing): {exc}", file=sys.stderr)
|
|
215
|
+
except KeyboardInterrupt:
|
|
216
|
+
_emit(cfg, "supervisor-interrupted", "interrupted, exiting")
|