@delorenj/pjangler 1.2.21 → 1.2.25

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.
Files changed (63) hide show
  1. package/dist/index.js +1852 -368
  2. package/dist/mcp-server.js +1782 -296
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +3 -0
  5. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  6. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  7. package/templates/commonproject/template/mise.toml.jinja +9 -2
  8. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  9. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  10. package/templates/hermes-agent/README.md +70 -29
  11. package/templates/hermes-agent/copier.yml +9 -6
  12. package/templates/hermes-agent/docs/architecture.md +75 -51
  13. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  14. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  15. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  16. package/templates/hermes-agent/docs/operations.md +179 -81
  17. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  18. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  19. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  20. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  21. package/templates/hermes-agent/docs/sentinel.md +1 -1
  22. package/templates/hermes-agent/install-local.sh +41 -4
  23. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  24. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  25. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  26. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  27. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  28. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  29. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  30. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  31. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  32. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  33. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  34. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  37. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  38. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  41. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  42. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  43. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  44. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  45. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  46. package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
  47. package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
  48. package/templates/hermes-agent/template/hermes.jinja +6 -3
  49. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  50. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  51. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  52. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  53. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  54. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  55. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  56. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  57. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  58. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  59. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  60. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  61. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  62. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  63. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
@@ -1,138 +1,437 @@
1
- import importlib.util
1
+ from __future__ import annotations
2
+
2
3
  import os
3
- import pathlib
4
- import re
5
- import sys
6
- import tempfile
7
- import types
8
- import unittest
9
-
10
-
11
- ROOT = pathlib.Path(__file__).resolve().parents[1]
12
- CONSUMER_PATH = ROOT / "runtime-scaffold" / "bloodbank-consumer.py"
13
- GENERATED_CONSUMER_PATH = ROOT / "template" / ".runtime-scaffold" / "bloodbank-consumer.py"
14
-
15
-
16
- def load_consumer():
17
- sys.modules.setdefault("nats", types.ModuleType("nats"))
18
- spec = importlib.util.spec_from_file_location("bloodbank_consumer_contract", CONSUMER_PATH)
19
- module = importlib.util.module_from_spec(spec)
20
- spec.loader.exec_module(module)
21
- module.AGENT_ID = "demo-pm"
22
- module.REPO = "demo"
23
- module.PRODUCER = "hermes-agent:demo-pm"
24
- module.SOURCE = "hermes://agent/demo-pm"
25
- return module
26
-
27
-
28
- class BloodbankConsumerContractTests(unittest.TestCase):
29
- @classmethod
30
- def setUpClass(cls):
31
- cls.temp_home = tempfile.TemporaryDirectory()
32
- cls.previous_home = os.environ.get("HERMES_HOME")
33
- os.environ["HERMES_HOME"] = cls.temp_home.name
34
- cls.consumer = load_consumer()
35
-
36
- @classmethod
37
- def tearDownClass(cls):
38
- if cls.previous_home is None:
39
- os.environ.pop("HERMES_HOME", None)
40
- else:
41
- os.environ["HERMES_HOME"] = cls.previous_home
42
- cls.temp_home.cleanup()
43
-
44
- def test_scaffold_copies_stay_identical(self):
45
- self.assertEqual(CONSUMER_PATH.read_bytes(), GENERATED_CONSUMER_PATH.read_bytes())
46
-
47
- def test_subscriptions_use_fixed_canonical_routes(self):
48
- self.assertEqual(
49
- self.consumer.SUBJECTS,
50
- ["bloodbank.evt.v1.repo.>", "bloodbank.cmd.v1.agent.>"],
51
- )
52
- self.assertNotIn("demo", ".".join(self.consumer.SUBJECTS))
53
- self.assertNotIn("demo-pm", ".".join(self.consumer.SUBJECTS))
4
+ import shutil
5
+ import subprocess
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from pathlib import Path
54
8
 
55
- def test_envelope_keeps_identity_out_of_type_and_subject(self):
56
- envelope = self.consumer.build_envelope(
57
- "bloodbank.v1.repo.issue.updated",
58
- {"repo": "demo", "issue": "PJAN-1"},
59
- )
60
- self.assertEqual(envelope["type"], "bloodbank.v1.repo.issue.updated")
61
- self.assertEqual(envelope["subject"], "bloodbank.evt.v1.repo.issue.updated")
62
- self.assertEqual(envelope["data"]["repo"], "demo")
63
- self.assertEqual(envelope["actor"]["agent_id"], "demo-pm")
64
- self.assertEqual(envelope["source"], "hermes://agent/demo-pm")
65
- self.assertNotIn("demo", envelope["type"])
66
- self.assertNotIn("demo", envelope["subject"])
67
- with self.assertRaisesRegex(ValueError, r"bloodbank\.v1"):
68
- self.consumer.build_envelope(
69
- "bloodbank.v2.repo.issue.updated",
70
- {"repo": "demo"},
71
- )
9
+ import pytest
10
+ import yaml
72
11
 
73
- def test_repo_events_route_by_data_repo(self):
74
- subject = "bloodbank.evt.v1.repo.issue.updated"
75
- envelope = self.consumer.build_envelope(
76
- "bloodbank.v1.repo.issue.updated",
77
- {"repo": "demo"},
78
- )
79
- self.assertTrue(self.consumer._is_for_consumer(subject, envelope))
80
- envelope["data"]["repo"] = "another-repo"
81
- self.assertFalse(self.consumer._is_for_consumer(subject, envelope))
82
-
83
- def test_agent_commands_route_by_target_agent_id(self):
84
- subject = "bloodbank.cmd.v1.agent.task.assign"
85
- envelope = self.consumer.build_envelope(
86
- "bloodbank.v1.agent.task.assign",
87
- {"target_agent_id": "demo-pm"},
88
- kind="command",
89
- )
90
- self.assertTrue(self.consumer._is_for_consumer(subject, envelope))
91
- envelope["data"]["target_agent_id"] = "other-agent"
92
- self.assertFalse(self.consumer._is_for_consumer(subject, envelope))
93
-
94
- def test_rejects_identifier_bearing_or_mismatched_routes(self):
95
- envelope = self.consumer.build_envelope(
96
- "bloodbank.v1.repo.issue.updated",
97
- {"repo": "demo"},
98
- )
99
- self.assertFalse(
100
- self.consumer._is_for_consumer(
101
- "bloodbank.evt.v1.repo.demo.issue.updated",
102
- envelope,
103
- )
104
- )
105
- self.assertFalse(
106
- self.consumer._is_for_consumer(
107
- "bloodbank.evt.v1.repo.issue.created",
108
- envelope,
109
- )
110
- )
111
- envelope["kind"] = "command"
112
- self.assertFalse(
113
- self.consumer._is_for_consumer(
114
- "bloodbank.evt.v1.repo.issue.updated",
115
- envelope,
12
+
13
+ ROOT = Path(__file__).parents[1]
14
+ SCRIPTS = ROOT / "template" / ".scripts"
15
+
16
+
17
+ def _make_role(tmp_path: Path) -> tuple[Path, Path]:
18
+ role = tmp_path / "role"
19
+ scripts = role / ".scripts"
20
+ runtime = role / "runtime"
21
+ scripts.mkdir(parents=True)
22
+ runtime.mkdir()
23
+ for name in ("_lib.sh", "60-bloodbank.sh", "70-systemd.sh", "80-registry.sh"):
24
+ shutil.copy2(SCRIPTS / name, scripts / name)
25
+ (scripts / "heartbeat.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
26
+ (scripts / "checkpoint.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
27
+ (role / "role.yaml").write_text(
28
+ """repo: demo
29
+ role: pm
30
+ agent_id: demo-pm
31
+ display_name: "Demo PM"
32
+ profile: demo-pm
33
+ telegram:
34
+ provisioning_status: "deferred"
35
+ bot_username: "demo_pm_bot"
36
+ bot_id: ""
37
+ slack:
38
+ provisioning_status: "deferred"
39
+ team_id: ""
40
+ team_name: ""
41
+ bot_user_id: ""
42
+ bot_id: ""
43
+ bot_username: ""
44
+ bloodbank:
45
+ gateway_scope: fleet
46
+ target_agent_id: "demo-pm"
47
+ producer: "hermes-agent:demo-pm"
48
+ plane:
49
+ workspace: "test"
50
+ identifier: ""
51
+ runtime:
52
+ github_owner: "test"
53
+ github_repo: "agent-hm-demo-pm"
54
+ """,
55
+ encoding="utf-8",
56
+ )
57
+ registry = tmp_path / "agents-registry.yaml"
58
+ registry.write_text("schema_version: 1\nagents: {}\n", encoding="utf-8")
59
+ return role, registry
60
+
61
+
62
+ def _environment(tmp_path: Path, registry: Path) -> dict[str, str]:
63
+ home = tmp_path / "home"
64
+ home.mkdir(exist_ok=True)
65
+ env = dict(os.environ)
66
+ env.update(
67
+ {
68
+ "HOME": str(home),
69
+ "HERMES_FLEET_ENV": str(home / ".hermes" / "fleet.env"),
70
+ "REGISTRY_FILE": str(registry),
71
+ }
72
+ )
73
+ return env
74
+
75
+
76
+ def _run(role: Path, name: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
77
+ return subprocess.run(
78
+ ["bash", str(role / ".scripts" / name)],
79
+ env=env,
80
+ text=True,
81
+ capture_output=True,
82
+ check=False,
83
+ )
84
+
85
+
86
+ def _inject_parent_fsync_failure(
87
+ tmp_path: Path, env: dict[str, str], parent: Path
88
+ ) -> None:
89
+ site_dir = tmp_path / "fsync-failure-site"
90
+ site_dir.mkdir()
91
+ (site_dir / "sitecustomize.py").write_text(
92
+ """import errno
93
+ import os
94
+ import stat
95
+
96
+ _real_fsync = os.fsync
97
+ _failed_parent = os.path.realpath(os.environ["FAIL_PARENT_FSYNC"])
98
+
99
+ def _fsync(fd):
100
+ if stat.S_ISDIR(os.fstat(fd).st_mode):
101
+ resolved = os.path.realpath(f"/proc/self/fd/{fd}")
102
+ if resolved == _failed_parent:
103
+ raise OSError(errno.EIO, "injected parent directory fsync failure")
104
+ return _real_fsync(fd)
105
+
106
+ os.fsync = _fsync
107
+ """,
108
+ encoding="utf-8",
109
+ )
110
+ env["PYTHONPATH"] = str(site_dir)
111
+ env["FAIL_PARENT_FSYNC"] = str(parent)
112
+
113
+
114
+ def test_future_runtime_scaffolds_have_no_profile_consumer_or_inbox() -> None:
115
+ for scaffold in (ROOT / "runtime-scaffold", ROOT / "template" / ".runtime-scaffold"):
116
+ assert not (scaffold / "bloodbank-consumer.py").exists()
117
+ assert "bloodbank-inbox" not in (scaffold / ".gitignore").read_text(encoding="utf-8")
118
+ readme = (scaffold / "README.md").read_text(encoding="utf-8")
119
+ assert "fleet-shared" in readme
120
+ assert "no consumer process or inbox bridge" in readme
121
+
122
+
123
+ @pytest.mark.parametrize("skip", ["0", "1"])
124
+ def test_step_60_is_a_harmless_compatibility_noop(tmp_path: Path, skip: str) -> None:
125
+ role, registry = _make_role(tmp_path)
126
+ env = _environment(tmp_path, registry)
127
+ env["SKIP_BLOODBANK"] = skip
128
+
129
+ result = _run(role, "60-bloodbank.sh", env)
130
+
131
+ assert result.returncode == 0, result.stderr
132
+ assert (role / ".scripts" / ".done-60-bloodbank").is_file()
133
+ assert not (role / "runtime" / "bloodbank-consumer.py").exists()
134
+ assert "NATS" not in result.stdout + result.stderr
135
+
136
+
137
+ def test_systemd_installs_only_profile_gateway_and_heartbeat(tmp_path: Path) -> None:
138
+ role, registry = _make_role(tmp_path)
139
+ env = _environment(tmp_path, registry)
140
+ fake_bin = tmp_path / "bin"
141
+ fake_bin.mkdir()
142
+ fake_systemctl = fake_bin / "systemctl"
143
+ fake_systemctl.write_text(
144
+ """#!/usr/bin/env bash
145
+ case "$*" in
146
+ *"is-active hermes-demo-pm-consumer.service"*) echo inactive; exit 4 ;;
147
+ *"is-enabled hermes-demo-pm-consumer.service"*) echo not-found; exit 4 ;;
148
+ *"is-system-running"*) exit 1 ;;
149
+ esac
150
+ exit 1
151
+ """,
152
+ encoding="utf-8",
153
+ )
154
+ fake_systemctl.chmod(0o755)
155
+ env["PATH"] = f"{fake_bin}:{env['PATH']}"
156
+
157
+ result = _run(role, "70-systemd.sh", env)
158
+
159
+ assert result.returncode == 0, result.stderr
160
+ unit_dir = Path(env["HOME"]) / ".config" / "systemd" / "user"
161
+ assert (unit_dir / "hermes-demo-pm-gateway.service").is_file()
162
+ assert (unit_dir / "hermes-demo-pm-heartbeat.service").is_file()
163
+ assert (unit_dir / "hermes-demo-pm-heartbeat.timer").is_file()
164
+ assert not (unit_dir / "hermes-demo-pm-consumer.service").exists()
165
+ rendered = "\n".join(path.read_text(encoding="utf-8") for path in unit_dir.iterdir())
166
+ assert "bloodbank-consumer.py" not in rendered
167
+ assert "consumer.log" not in rendered
168
+
169
+
170
+ def test_systemd_done_marker_still_retires_legacy_consumer(tmp_path: Path) -> None:
171
+ role, registry = _make_role(tmp_path)
172
+ env = _environment(tmp_path, registry)
173
+ unit_dir = Path(env["HOME"]) / ".config" / "systemd" / "user"
174
+ unit_dir.mkdir(parents=True)
175
+ consumer = unit_dir / "hermes-demo-pm-consumer.service"
176
+ consumer.write_text("legacy\n", encoding="utf-8")
177
+ (role / ".scripts" / ".done-70-systemd").touch()
178
+ fake_bin = tmp_path / "bin"
179
+ fake_bin.mkdir()
180
+ log = tmp_path / "systemctl.log"
181
+ retired = tmp_path / "retired"
182
+ systemctl = fake_bin / "systemctl"
183
+ systemctl.write_text(
184
+ """#!/usr/bin/env bash
185
+ printf '%s\n' "$*" >> "$SYSTEMCTL_LOG"
186
+ case "$*" in
187
+ *"disable --now hermes-demo-pm-consumer.service"*) touch "$SYSTEMCTL_RETIRED"; exit 0 ;;
188
+ *"is-active hermes-demo-pm-consumer.service"*)
189
+ if [[ -f "$SYSTEMCTL_RETIRED" ]]; then echo inactive; exit 3; else echo active; exit 0; fi ;;
190
+ *"is-enabled hermes-demo-pm-consumer.service"*)
191
+ if [[ -f "$SYSTEMCTL_RETIRED" ]]; then echo disabled; exit 1; else echo enabled; exit 0; fi ;;
192
+ *"is-system-running"*) echo running; exit 0 ;;
193
+ esac
194
+ exit 1
195
+ """,
196
+ encoding="utf-8",
197
+ )
198
+ systemctl.chmod(0o755)
199
+ env.update(
200
+ {
201
+ "PATH": f"{fake_bin}:{env['PATH']}",
202
+ "SYSTEMCTL_LOG": str(log),
203
+ "SYSTEMCTL_RETIRED": str(retired),
204
+ }
205
+ )
206
+
207
+ result = _run(role, "70-systemd.sh", env)
208
+
209
+ assert result.returncode == 0, result.stderr
210
+ assert not consumer.exists()
211
+ assert "disable --now hermes-demo-pm-consumer.service" in log.read_text(encoding="utf-8")
212
+ assert "legacy cleanup checked" in result.stderr
213
+ assert not (unit_dir / "hermes-demo-pm-gateway.service").exists()
214
+
215
+
216
+ def test_systemd_preserves_legacy_consumer_when_disable_fails(tmp_path: Path) -> None:
217
+ role, registry = _make_role(tmp_path)
218
+ env = _environment(tmp_path, registry)
219
+ unit_dir = Path(env["HOME"]) / ".config" / "systemd" / "user"
220
+ unit_dir.mkdir(parents=True)
221
+ consumer = unit_dir / "hermes-demo-pm-consumer.service"
222
+ consumer.write_text("legacy\n", encoding="utf-8")
223
+ marker = role / ".scripts" / ".done-70-systemd"
224
+ marker.touch()
225
+ fake_bin = tmp_path / "bin"
226
+ fake_bin.mkdir()
227
+ systemctl = fake_bin / "systemctl"
228
+ systemctl.write_text(
229
+ """#!/usr/bin/env bash
230
+ case "$*" in
231
+ *"is-active hermes-demo-pm-consumer.service"*) echo active; exit 0 ;;
232
+ *"is-enabled hermes-demo-pm-consumer.service"*) echo enabled; exit 0 ;;
233
+ *"disable --now hermes-demo-pm-consumer.service"*) exit 1 ;;
234
+ esac
235
+ exit 1
236
+ """,
237
+ encoding="utf-8",
238
+ )
239
+ systemctl.chmod(0o755)
240
+ env["PATH"] = f"{fake_bin}:{env['PATH']}"
241
+
242
+ result = _run(role, "70-systemd.sh", env)
243
+
244
+ assert result.returncode != 0
245
+ assert "disable failed" in result.stderr
246
+ assert consumer.read_text(encoding="utf-8") == "legacy\n"
247
+ assert marker.exists()
248
+ assert not (unit_dir / "hermes-demo-pm-gateway.service").exists()
249
+
250
+
251
+ @pytest.mark.parametrize("failed_query", ["is-active", "is-enabled"])
252
+ def test_systemd_preserves_legacy_consumer_when_state_query_fails(
253
+ tmp_path: Path, failed_query: str
254
+ ) -> None:
255
+ role, registry = _make_role(tmp_path)
256
+ env = _environment(tmp_path, registry)
257
+ unit_dir = Path(env["HOME"]) / ".config" / "systemd" / "user"
258
+ unit_dir.mkdir(parents=True)
259
+ consumer = unit_dir / "hermes-demo-pm-consumer.service"
260
+ consumer.write_text("legacy\n", encoding="utf-8")
261
+ marker = role / ".scripts" / ".done-70-systemd"
262
+ marker.touch()
263
+ fake_bin = tmp_path / "bin"
264
+ fake_bin.mkdir()
265
+ log = tmp_path / "systemctl.log"
266
+ systemctl = fake_bin / "systemctl"
267
+ systemctl.write_text(
268
+ """#!/usr/bin/env bash
269
+ printf '%s\n' "$*" >> "$SYSTEMCTL_LOG"
270
+ case "$*" in
271
+ *"$FAILED_QUERY hermes-demo-pm-consumer.service"*) echo "Failed to connect to bus" >&2; exit 1 ;;
272
+ *"is-active hermes-demo-pm-consumer.service"*) echo active; exit 0 ;;
273
+ *"is-enabled hermes-demo-pm-consumer.service"*) echo enabled; exit 0 ;;
274
+ esac
275
+ exit 1
276
+ """,
277
+ encoding="utf-8",
278
+ )
279
+ systemctl.chmod(0o755)
280
+ env.update(
281
+ {
282
+ "PATH": f"{fake_bin}:{env['PATH']}",
283
+ "FAILED_QUERY": failed_query,
284
+ "SYSTEMCTL_LOG": str(log),
285
+ }
286
+ )
287
+
288
+ result = _run(role, "70-systemd.sh", env)
289
+
290
+ assert result.returncode != 0
291
+ assert "cannot safely query" in result.stderr
292
+ assert consumer.read_text(encoding="utf-8") == "legacy\n"
293
+ assert marker.exists()
294
+ assert "disable --now" not in log.read_text(encoding="utf-8")
295
+
296
+
297
+ def test_registry_records_fleet_gateway_contract_without_consumer_unit(tmp_path: Path) -> None:
298
+ role, registry = _make_role(tmp_path)
299
+ env = _environment(tmp_path, registry)
300
+ registry.write_text(
301
+ yaml.safe_dump(
302
+ {
303
+ "schema_version": 1,
304
+ "agents": {
305
+ "demo-pm": {
306
+ "systemd": {
307
+ "consumer_unit": "hermes-demo-pm-consumer.service"
308
+ }
309
+ }
310
+ },
311
+ },
312
+ sort_keys=False,
313
+ ),
314
+ encoding="utf-8",
315
+ )
316
+
317
+ result = _run(role, "80-registry.sh", env)
318
+
319
+ assert result.returncode == 0, result.stderr
320
+ entry = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]["demo-pm"]
321
+ assert entry["bloodbank"] == {
322
+ "gateway_scope": "fleet",
323
+ "target_agent_id": "demo-pm",
324
+ }
325
+ assert entry["systemd"] == {
326
+ "gateway_unit": "hermes-demo-pm-gateway.service",
327
+ "heartbeat_timer": "hermes-demo-pm-heartbeat.timer",
328
+ }
329
+ assert "consumer_unit" not in entry["systemd"]
330
+
331
+
332
+ def test_concurrent_registry_upserts_are_atomic_and_lossless(tmp_path: Path) -> None:
333
+ role_a, _ = _make_role(tmp_path / "a")
334
+ role_b, _ = _make_role(tmp_path / "b")
335
+ role_b_yaml = role_b / "role.yaml"
336
+ role_b_yaml.write_text(
337
+ role_b_yaml.read_text(encoding="utf-8").replace("demo-pm", "demo-reviewer"),
338
+ encoding="utf-8",
339
+ )
340
+ registry = tmp_path / "agents-registry.yaml"
341
+ registry.write_text("schema_version: 1\nagents: {}\n", encoding="utf-8")
342
+ (tmp_path / "env-a").mkdir()
343
+ (tmp_path / "env-b").mkdir()
344
+ env_a = _environment(tmp_path / "env-a", registry)
345
+ env_b = _environment(tmp_path / "env-b", registry)
346
+
347
+ with ThreadPoolExecutor(max_workers=2) as pool:
348
+ results = list(
349
+ pool.map(
350
+ lambda args: _run(*args),
351
+ (
352
+ (role_a, "80-registry.sh", env_a),
353
+ (role_b, "80-registry.sh", env_b),
354
+ ),
116
355
  )
117
356
  )
118
357
 
119
- def test_generated_contract_docs_do_not_put_identifiers_in_routes(self):
120
- coupled_paths = [
121
- ROOT / "docs" / "architecture.md",
122
- ROOT / "docs" / "operations.md",
123
- ROOT / "template" / "SOUL.md.jinja",
124
- ROOT / "template" / "role.yaml.jinja",
125
- ROOT / "runtime-scaffold" / "memories" / "MEMORY.md",
126
- ROOT / "template" / ".runtime-scaffold" / "memories" / "MEMORY.md",
127
- ]
128
- forbidden = re.compile(
129
- r"bloodbank\.(?:v1\.repo|evt\.v1\.repo|cmd\.v1\.agent)\."
130
- r"(?:\{\{|<repo>|<agent_id>)"
131
- )
132
- for path in coupled_paths:
133
- with self.subTest(path=path):
134
- self.assertIsNone(forbidden.search(path.read_text()))
358
+ assert all(result.returncode == 0 for result in results), [r.stderr for r in results]
359
+ agents = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]
360
+ assert set(agents) == {"demo-pm", "demo-reviewer"}
361
+ assert (registry.stat().st_mode & 0o777) == 0o600
362
+
363
+
364
+ def test_registry_parent_fsync_failure_is_reported_and_retryable(tmp_path: Path) -> None:
365
+ role, registry = _make_role(tmp_path)
366
+ env = _environment(tmp_path, registry)
367
+ _inject_parent_fsync_failure(tmp_path, env, registry.parent)
368
+
369
+ failed = _run(role, "80-registry.sh", env)
370
+
371
+ assert failed.returncode != 0
372
+ assert "injected parent directory fsync failure" in failed.stderr
373
+ assert "demo-pm" in yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]
374
+ assert (registry.stat().st_mode & 0o777) == 0o600
375
+ assert not (role / ".scripts" / ".done-80-registry").exists()
376
+
377
+ retry_env = _environment(tmp_path, registry)
378
+ retried = _run(role, "80-registry.sh", retry_env)
379
+
380
+ assert retried.returncode == 0, retried.stderr
381
+ assert (role / ".scripts" / ".done-80-registry").exists()
382
+
383
+
384
+ def test_registry_upsert_refuses_lock_symlink(tmp_path: Path) -> None:
385
+ role, registry = _make_role(tmp_path)
386
+ env = _environment(tmp_path, registry)
387
+ lock_target = tmp_path / "lock-target"
388
+ lock_target.write_text("untouched", encoding="utf-8")
389
+ Path(f"{registry}.lock").symlink_to(lock_target)
390
+
391
+ result = _run(role, "80-registry.sh", env)
392
+
393
+ assert result.returncode != 0
394
+ assert "lock symlink" in result.stderr
395
+ assert lock_target.read_text(encoding="utf-8") == "untouched"
396
+
397
+
398
+ def test_stale_lock_file_is_safe_when_no_process_holds_flock(tmp_path: Path) -> None:
399
+ role, registry = _make_role(tmp_path)
400
+ env = _environment(tmp_path, registry)
401
+ lock = Path(f"{registry}.lock")
402
+ lock.write_text("left by a crashed provisioner\n", encoding="utf-8")
403
+ lock.chmod(0o644)
404
+
405
+ result = _run(role, "80-registry.sh", env)
406
+
407
+ assert result.returncode == 0, result.stderr
408
+ assert "demo-pm" in yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]
409
+ assert (lock.stat().st_mode & 0o777) == 0o600
410
+
411
+
412
+ def test_template_declares_fleet_scope_and_retains_compatibility_step() -> None:
413
+ role = (ROOT / "template" / "role.yaml.jinja").read_text(encoding="utf-8")
414
+ copier = (ROOT / "copier.yml").read_text(encoding="utf-8")
415
+ step = (SCRIPTS / "60-bloodbank.sh").read_text(encoding="utf-8")
416
+
417
+ assert "gateway_scope: fleet" in role
418
+ assert 'target_agent_id: "{{ agent_id }}"' in role
419
+ assert './.scripts/60-bloodbank.sh' in copier
420
+ assert "SKIP_BLOODBANK accepted as a compatibility no-op" in step
421
+ for legacy in ("/dev/tcp", "uv pip install", "bloodbank-consumer.py"):
422
+ assert legacy not in step
423
+
135
424
 
425
+ def test_documented_gateway_lifecycle_matches_canonical_bloodbank_contract() -> None:
426
+ architecture = (ROOT / "docs" / "architecture.md").read_text(encoding="utf-8")
136
427
 
137
- if __name__ == "__main__":
138
- unittest.main()
428
+ for event_type in (
429
+ "bloodbank.v1.conversation.turn.started",
430
+ "bloodbank.v1.agent.invocation.started",
431
+ "bloodbank.v1.agent.invocation.completed",
432
+ "bloodbank.v1.agent.invocation.failed",
433
+ "bloodbank.v1.conversation.turn.completed",
434
+ ):
435
+ assert event_type in architecture
436
+ assert "There are no separate `received` or `accepted` lifecycle events" in architecture
437
+ assert "acknowledged only after Hermes processing completion" in architecture