@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.
- package/dist/index.js +1852 -368
- package/dist/mcp-server.js +1782 -296
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +3 -0
- package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
- package/templates/commonproject/template/mise.toml.jinja +9 -2
- package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
- package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
- package/templates/hermes-agent/README.md +70 -29
- package/templates/hermes-agent/copier.yml +9 -6
- package/templates/hermes-agent/docs/architecture.md +75 -51
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
- package/templates/hermes-agent/docs/operations.md +179 -81
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
- package/templates/hermes-agent/docs/sentinel/README.md +10 -10
- package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
- package/templates/hermes-agent/docs/sentinel/development.md +12 -15
- package/templates/hermes-agent/docs/sentinel.md +1 -1
- package/templates/hermes-agent/install-local.sh +41 -4
- package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
- package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
- package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
- package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
- package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
- package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
- package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
- package/templates/hermes-agent/template/.gitignore.jinja +5 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
- package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
- package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
- package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
- package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
- package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
- package/templates/hermes-agent/template/hermes.jinja +6 -3
- package/templates/hermes-agent/template/role.yaml.jinja +24 -13
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
- package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
- package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
- package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
- package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
- package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
- package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
- package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
- package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
- package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
- package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
- package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
|
@@ -1,138 +1,437 @@
|
|
|
1
|
-
import
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import os
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|