@delorenj/pjangler 1.4.2 → 1.4.3
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 +3336 -1359
- package/dist/index.js.map +7 -0
- package/dist/mcp-server.js +1520 -838
- package/dist/mcp-server.js.map +7 -0
- package/dist/prompt.js +2 -1
- package/dist/prompt.js.map +7 -0
- package/package.json +8 -4
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- package/templates/hermes-agent/template/role.yaml.jinja +11 -4
|
@@ -24,10 +24,6 @@ fi
|
|
|
24
24
|
# shellcheck source=_lib.sh
|
|
25
25
|
source "$(dirname "$0")/_lib.sh"
|
|
26
26
|
load_role_env
|
|
27
|
-
# shellcheck source=lib/ticket-provider.sh
|
|
28
|
-
source "$(dirname "$0")/lib/ticket-provider.sh"
|
|
29
|
-
|
|
30
|
-
already_done 42-ticket-provider && { log "[42] ticket provider already set up — skipping"; exit 0; }
|
|
31
27
|
|
|
32
28
|
# Locate the repo-root .project.json (the SOT).
|
|
33
29
|
REPO_ROOT="$(project_repo_path 2>/dev/null || true)"
|
|
@@ -35,15 +31,59 @@ REPO_ROOT="$(project_repo_path 2>/dev/null || true)"
|
|
|
35
31
|
PROJECT_JSON="$REPO_ROOT/.project.json"
|
|
36
32
|
ROLE_DIR_REL="${ROLE_DIR#"$REPO_ROOT"/}"
|
|
37
33
|
|
|
34
|
+
# Serialize the complete read / provider check-or-create / atomic write
|
|
35
|
+
# transaction per project. The lock lives outside the checkout so a normal
|
|
36
|
+
# provision cannot leave repository dirt behind.
|
|
37
|
+
command -v flock >/dev/null 2>&1 \
|
|
38
|
+
|| die "flock is required for safe ticket-provider binding"
|
|
39
|
+
PROJECT_LOCK_KEY="$(printf '%s' "$PROJECT_JSON" | sha256sum)"
|
|
40
|
+
PROJECT_LOCK_KEY="${PROJECT_LOCK_KEY%% *}"
|
|
41
|
+
PROJECT_LOCK_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/pjangler-ticket-provider"
|
|
42
|
+
mkdir -p "$PROJECT_LOCK_DIR"
|
|
43
|
+
PROJECT_LOCK_FILE="$PROJECT_LOCK_DIR/$PROJECT_LOCK_KEY.lock"
|
|
44
|
+
[[ ! -L "$PROJECT_LOCK_FILE" ]] \
|
|
45
|
+
|| die "refusing ticket-provider lock symlink: $PROJECT_LOCK_FILE"
|
|
46
|
+
exec {PROJECT_LOCK_FD}>"$PROJECT_LOCK_FILE"
|
|
47
|
+
chmod 600 "$PROJECT_LOCK_FILE"
|
|
48
|
+
flock -w "${PROJECT_LOCK_TIMEOUT_SECONDS:-30}" "$PROJECT_LOCK_FD" \
|
|
49
|
+
|| die "timed out waiting for ticket-provider lock: $PROJECT_JSON"
|
|
50
|
+
project_lock_release() {
|
|
51
|
+
flock -u "$PROJECT_LOCK_FD" 2>/dev/null || true
|
|
52
|
+
exec {PROJECT_LOCK_FD}>&-
|
|
53
|
+
}
|
|
54
|
+
trap project_lock_release EXIT
|
|
55
|
+
|
|
56
|
+
[[ ! -L "$PROJECT_JSON" ]] || die "refusing .project.json symlink: $PROJECT_JSON"
|
|
57
|
+
if [[ -e "$PROJECT_JSON" ]]; then
|
|
58
|
+
python3 - "$PROJECT_JSON" <<'PY' \
|
|
59
|
+
|| die "malformed .project.json; refusing ticket-provider mutation or board creation"
|
|
60
|
+
import json
|
|
61
|
+
import pathlib
|
|
62
|
+
import sys
|
|
63
|
+
|
|
64
|
+
path = pathlib.Path(sys.argv[1])
|
|
65
|
+
try:
|
|
66
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
68
|
+
raise SystemExit(1)
|
|
69
|
+
raise SystemExit(0 if isinstance(value, dict) else 1)
|
|
70
|
+
PY
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# shellcheck source=lib/ticket-provider.sh
|
|
74
|
+
source "$(dirname "$0")/lib/ticket-provider.sh"
|
|
75
|
+
|
|
76
|
+
already_done 42-ticket-provider \
|
|
77
|
+
&& log "[42] ticket-provider marker found — revalidating canonical board binding"
|
|
78
|
+
|
|
38
79
|
# pj <dotted.key> — read a string value from .project.json (empty if absent).
|
|
39
80
|
pj() {
|
|
40
81
|
[ -f "$PROJECT_JSON" ] || { printf ''; return 0; }
|
|
41
82
|
python3 - "$PROJECT_JSON" "$1" <<'PY'
|
|
42
83
|
import sys, json, pathlib
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
print(""); raise SystemExit(0)
|
|
84
|
+
d = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
85
|
+
if not isinstance(d, dict):
|
|
86
|
+
raise SystemExit(".project.json root must be an object")
|
|
47
87
|
cur = d
|
|
48
88
|
for k in sys.argv[2].split("."):
|
|
49
89
|
if isinstance(cur, dict) and k in cur:
|
|
@@ -55,19 +95,63 @@ PY
|
|
|
55
95
|
}
|
|
56
96
|
|
|
57
97
|
# pj_write — merge board binding (optional) + this agent into .project.json.
|
|
58
|
-
# args: set_provider(0|1) provider board_id
|
|
98
|
+
# args: set_provider(0|1) provider board_id workspace identifier team
|
|
99
|
+
# identifier_source
|
|
100
|
+
#
|
|
101
|
+
# identifier_source is REQUIRED whenever identifier is non-empty and must be
|
|
102
|
+
# `provider` (read back out of the provider's own response) or `proposed` (a
|
|
103
|
+
# local prefix the provider never confirmed, e.g. the string Trello echoes back
|
|
104
|
+
# because Trello mints no key). There is deliberately no third option and no
|
|
105
|
+
# default: an unstamped identifier is refused outright, so no locally-computed
|
|
106
|
+
# string can ever land in .project.json wearing the authority of a live board.
|
|
107
|
+
#
|
|
108
|
+
# `provider` is further refused outright for a provider that assigns no
|
|
109
|
+
# identifiers at all. Trello cannot have named a key it has no concept of, so
|
|
110
|
+
# the claim is not merely unproven, it is impossible — and a downstream reader
|
|
111
|
+
# must always be able to tell "Plane assigned PJAN" from "we picked INT and
|
|
112
|
+
# Trello confirmed a board exists".
|
|
113
|
+
#
|
|
114
|
+
# Those are two different facts, so they get two different fields. Whether the
|
|
115
|
+
# BOARD is real is stamped in board_confirmed_at, and that is what a link rests
|
|
116
|
+
# on; whether the KEY came from the provider is identifier_source. Every
|
|
117
|
+
# provider can answer the first. Only some can answer the second.
|
|
59
118
|
pj_write() {
|
|
60
119
|
REPO="$REPO" REPO_ROOT="$REPO_ROOT" AGENT_ID="$AGENT_ID" ROLE="$ROLE" \
|
|
61
120
|
ROLE_DIR_REL="$ROLE_DIR_REL" PROJECT_DESC="${PROJECT_DESC:-}" \
|
|
62
121
|
python3 - "$PROJECT_JSON" "$@" <<'PY'
|
|
63
|
-
import
|
|
64
|
-
|
|
122
|
+
import datetime
|
|
123
|
+
import errno
|
|
124
|
+
import sys, os, json, pathlib, stat, tempfile
|
|
125
|
+
(path, set_provider, provider, board_id, workspace, identifier, team,
|
|
126
|
+
identifier_source) = sys.argv[1:9]
|
|
127
|
+
# Providers that mint their own keys and hand them back on read.
|
|
128
|
+
IDENTIFIER_ASSIGNING = ("plane", "linear")
|
|
129
|
+
if identifier and identifier_source not in ("provider", "proposed"):
|
|
130
|
+
raise SystemExit(
|
|
131
|
+
"refusing to persist ticket_provider.identifier %r with source %r: "
|
|
132
|
+
"every identifier must be stamped provider|proposed" % (identifier, identifier_source)
|
|
133
|
+
)
|
|
134
|
+
if identifier_source == "provider" and provider not in IDENTIFIER_ASSIGNING:
|
|
135
|
+
raise SystemExit(
|
|
136
|
+
"refusing to persist ticket_provider.identifier %r as provider-sourced: "
|
|
137
|
+
"%s assigns no identifiers, so it cannot have named one" % (identifier, provider)
|
|
138
|
+
)
|
|
139
|
+
now = (
|
|
140
|
+
datetime.datetime.now(datetime.timezone.utc)
|
|
141
|
+
.isoformat(timespec="milliseconds")
|
|
142
|
+
.replace("+00:00", "Z")
|
|
143
|
+
)
|
|
65
144
|
p = pathlib.Path(path)
|
|
66
|
-
|
|
67
|
-
|
|
145
|
+
if p.is_symlink():
|
|
146
|
+
raise SystemExit(f"refusing .project.json symlink: {p}")
|
|
147
|
+
if p.exists():
|
|
148
|
+
try:
|
|
149
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
150
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
151
|
+
raise SystemExit(f"malformed .project.json: {type(exc).__name__}")
|
|
68
152
|
if not isinstance(d, dict):
|
|
69
|
-
|
|
70
|
-
|
|
153
|
+
raise SystemExit(".project.json root must be an object")
|
|
154
|
+
else:
|
|
71
155
|
d = {}
|
|
72
156
|
repo = os.environ.get("REPO", "")
|
|
73
157
|
d.setdefault("project_name", repo)
|
|
@@ -78,10 +162,22 @@ if set_provider == "1":
|
|
|
78
162
|
tp = d.setdefault("ticket_provider", {})
|
|
79
163
|
tp["type"] = provider
|
|
80
164
|
if workspace: tp["workspace"] = workspace
|
|
81
|
-
if identifier:
|
|
82
|
-
|
|
83
|
-
|
|
165
|
+
if identifier:
|
|
166
|
+
tp["identifier"] = identifier
|
|
167
|
+
tp["identifier_source"] = identifier_source
|
|
168
|
+
if identifier_source == "provider":
|
|
169
|
+
tp["identifier_fetched_at"] = now
|
|
170
|
+
else:
|
|
171
|
+
tp.pop("identifier_fetched_at", None)
|
|
172
|
+
if board_id:
|
|
173
|
+
tp["board_id"] = board_id
|
|
174
|
+
# Every caller that reaches here with a board id got it out of the
|
|
175
|
+
# provider itself (create_board or resolve), so this write IS the
|
|
176
|
+
# confirmation and may stamp its own instant.
|
|
177
|
+
tp["board_confirmed_at"] = now
|
|
84
178
|
if team: tp["team"] = team
|
|
179
|
+
tp.pop("board_url", None)
|
|
180
|
+
tp["state"] = "linked" if board_id else "deferred"
|
|
85
181
|
ag = d.setdefault("agents", {})
|
|
86
182
|
entry = ag.get(os.environ["AGENT_ID"], {})
|
|
87
183
|
if not isinstance(entry, dict):
|
|
@@ -89,21 +185,46 @@ if not isinstance(entry, dict):
|
|
|
89
185
|
entry.update({
|
|
90
186
|
"role": os.environ["ROLE"],
|
|
91
187
|
"role_dir": os.environ["ROLE_DIR_REL"],
|
|
92
|
-
"provisioning_state": "
|
|
188
|
+
"provisioning_state": "linked" if board_id else "deferred",
|
|
93
189
|
})
|
|
94
190
|
ag[os.environ["AGENT_ID"]] = entry
|
|
95
|
-
|
|
191
|
+
rendered = json.dumps(d, indent=2) + "\n"
|
|
192
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
mode = stat.S_IMODE(p.stat().st_mode) if p.exists() else 0o644
|
|
194
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{p.name}.ticket-provider-", dir=p.parent)
|
|
195
|
+
try:
|
|
196
|
+
os.fchmod(fd, mode)
|
|
197
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
198
|
+
handle.write(rendered)
|
|
199
|
+
handle.flush()
|
|
200
|
+
os.fsync(handle.fileno())
|
|
201
|
+
os.replace(temporary, p)
|
|
202
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
203
|
+
directory_fd = os.open(p.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
204
|
+
try:
|
|
205
|
+
try:
|
|
206
|
+
os.fsync(directory_fd)
|
|
207
|
+
except OSError as exc:
|
|
208
|
+
if exc.errno not in unsupported:
|
|
209
|
+
raise
|
|
210
|
+
finally:
|
|
211
|
+
os.close(directory_fd)
|
|
212
|
+
except BaseException:
|
|
213
|
+
try:
|
|
214
|
+
os.unlink(temporary)
|
|
215
|
+
except FileNotFoundError:
|
|
216
|
+
pass
|
|
217
|
+
raise
|
|
96
218
|
PY
|
|
97
219
|
log " .project.json updated (agent=$AGENT_ID)"
|
|
98
220
|
}
|
|
99
221
|
|
|
100
222
|
# Mirror the binding into role.yaml so legacy consumers keep working.
|
|
101
223
|
mirror_to_role_yaml() {
|
|
102
|
-
# mirror_to_role_yaml <provider> <board_id> <
|
|
103
|
-
local provider="$1" bid="$2"
|
|
224
|
+
# mirror_to_role_yaml <provider> <board_id> <workspace> <identifier> <team>
|
|
225
|
+
local provider="$1" bid="$2" ws="$3" ident="$4" team="$5"
|
|
104
226
|
yaml_set ticket_provider.name "$provider" 2>/dev/null || true
|
|
105
227
|
[ -n "$bid" ] && yaml_set ticket_provider.board_id "$bid" 2>/dev/null || true
|
|
106
|
-
[ -n "$burl" ] && yaml_set ticket_provider.board_url "$burl" 2>/dev/null || true
|
|
107
228
|
case "$provider" in
|
|
108
229
|
plane)
|
|
109
230
|
[ -n "$bid" ] && echo "$bid" > "$ROLE_DIR/.scripts/.plane-project-id"
|
|
@@ -125,9 +246,9 @@ mirror_to_role_yaml() {
|
|
|
125
246
|
# An existing repo board (in .project.json) wins — every agent binds to it.
|
|
126
247
|
SOT_TYPE="$(pj ticket_provider.type)"
|
|
127
248
|
SOT_BOARD_ID="$(pj ticket_provider.board_id)"
|
|
128
|
-
SOT_URL="$(pj ticket_provider.board_url)"
|
|
129
249
|
SOT_WS="$(pj ticket_provider.workspace)"
|
|
130
250
|
SOT_IDENT="$(pj ticket_provider.identifier)"
|
|
251
|
+
SOT_IDENT_SOURCE="$(pj ticket_provider.identifier_source)"
|
|
131
252
|
SOT_TEAM="$(pj ticket_provider.team)"
|
|
132
253
|
|
|
133
254
|
# role.yaml provider comes from copier --data (the operator's pjangler choice).
|
|
@@ -140,8 +261,30 @@ if [ -n "$SOT_BOARD_ID" ]; then
|
|
|
140
261
|
warn "[42] requested provider '$ROLE_PROVIDER' but repo board is '$PROVIDER' (.project.json wins); binding to existing board"
|
|
141
262
|
fi
|
|
142
263
|
log "[42] binding $AGENT_ID to existing repo board (provider=$PROVIDER, id=$SOT_BOARD_ID)"
|
|
143
|
-
|
|
144
|
-
|
|
264
|
+
LIVE_IDENT="$SOT_IDENT"
|
|
265
|
+
# An identifier already sitting in .project.json is only as trustworthy as
|
|
266
|
+
# the source recorded beside it. A record written before sources existed
|
|
267
|
+
# carries none, and an unsourced identifier is a proposal — not a fact.
|
|
268
|
+
IDENT_SOURCE=""
|
|
269
|
+
[ -z "$LIVE_IDENT" ] || IDENT_SOURCE="${SOT_IDENT_SOURCE:-proposed}"
|
|
270
|
+
# …and a `provider` stamp beside a provider that assigns no identifiers is
|
|
271
|
+
# not evidence either. It is a stale claim from a writer that conflated
|
|
272
|
+
# "the board is confirmed" with "the provider named this key". Correct it
|
|
273
|
+
# on the way through rather than carrying the lie into another manifest.
|
|
274
|
+
case "$PROVIDER" in
|
|
275
|
+
plane|linear) ;;
|
|
276
|
+
*) if [ "$IDENT_SOURCE" = provider ]; then IDENT_SOURCE=proposed; fi ;;
|
|
277
|
+
esac
|
|
278
|
+
if [ "$PROVIDER" = plane ]; then
|
|
279
|
+
OUT="$(tp resolve)" || die "existing Plane board could not be validated"
|
|
280
|
+
LIVE_IDENT="$(printf '%s' "$OUT" | python3 -c 'import sys,json
|
|
281
|
+
try: print(str(json.load(sys.stdin).get("identifier") or ""))
|
|
282
|
+
except Exception: print("")')"
|
|
283
|
+
[ -n "$LIVE_IDENT" ] || die "existing Plane board has no authoritative live identifier"
|
|
284
|
+
IDENT_SOURCE=provider
|
|
285
|
+
fi
|
|
286
|
+
mirror_to_role_yaml "$PROVIDER" "$SOT_BOARD_ID" "$SOT_WS" "$LIVE_IDENT" "$SOT_TEAM"
|
|
287
|
+
pj_write 1 "$PROVIDER" "$SOT_BOARD_ID" "$SOT_WS" "$LIVE_IDENT" "$SOT_TEAM" "$IDENT_SOURCE"
|
|
145
288
|
mark_done 42-ticket-provider
|
|
146
289
|
exit 0
|
|
147
290
|
fi
|
|
@@ -150,10 +293,14 @@ fi
|
|
|
150
293
|
PROVIDER="${ROLE_PROVIDER:-${SOT_TYPE:-plane}}"
|
|
151
294
|
log "[42] no board in .project.json — bootstrapping a repo board (provider: $PROVIDER)"
|
|
152
295
|
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
296
|
+
# Identifier PROPOSAL for a brand-new board only. It is sent to the provider in
|
|
297
|
+
# the create request and is NEVER persisted as though it were confirmed: every
|
|
298
|
+
# write below uses LIVE_IDENT, taken from the provider's own response, carrying
|
|
299
|
+
# the source that response earns it (`provider` where the provider mints the
|
|
300
|
+
# identifier, `proposed` where — as with Trello — it only echoes ours back).
|
|
301
|
+
PROPOSED_RAW=$(printf '%s' "$REPO" | tr -cd '[:alnum:]' | tr '[:lower:]' '[:upper:]')
|
|
302
|
+
while [ ${#PROPOSED_RAW} -lt 2 ]; do PROPOSED_RAW="${PROPOSED_RAW}X"; done
|
|
303
|
+
PROPOSED_IDENT="${SOT_IDENT:-${PROPOSED_RAW:0:4}}"
|
|
157
304
|
# Board NAME = repo name, separators->space, title-cased. NOT display_name —
|
|
158
305
|
# display_name carries the role suffix and must never become the board name.
|
|
159
306
|
NAME="$(printf '%s' "$REPO" | tr '_-' ' ' | python3 -c 'import sys; print(" ".join(w[:1].upper()+w[1:] for w in sys.stdin.read().split()))')"
|
|
@@ -161,24 +308,28 @@ DESC="Ticket board for $REPO"
|
|
|
161
308
|
|
|
162
309
|
case "$PROVIDER" in
|
|
163
310
|
linear)
|
|
311
|
+
# Linear DOES mint an authoritative identifier — the team key — so nothing
|
|
312
|
+
# here ever persists the local proposal. Without a live team read there is
|
|
313
|
+
# no identifier to record, and the record simply carries none.
|
|
164
314
|
if [[ -z "${LINEAR_API_KEY:-}" ]]; then
|
|
165
315
|
warn "[42] LINEAR_API_KEY not set; set role.yaml/.project.json ticket_provider.team and re-run ./.scripts/42-ticket-provider.sh"
|
|
166
|
-
pj_write 1 linear "" "" "" "$
|
|
316
|
+
pj_write 1 linear "" "" "" "$SOT_TEAM" ""
|
|
167
317
|
mark_done 42-ticket-provider; exit 0
|
|
168
318
|
fi
|
|
169
319
|
OUT="$(tp resolve 2>/dev/null || true)"
|
|
170
320
|
BID="$(printf '%s' "$OUT" | python3 -c 'import sys,json
|
|
171
321
|
try: print(json.load(sys.stdin).get("board_id",""))
|
|
172
322
|
except Exception: print("")')"
|
|
173
|
-
|
|
174
|
-
try: print(json.load(sys.stdin).get("
|
|
323
|
+
LIVE_IDENT="$(printf '%s' "$OUT" | python3 -c 'import sys,json
|
|
324
|
+
try: print(str(json.load(sys.stdin).get("identifier") or ""))
|
|
175
325
|
except Exception: print("")')"
|
|
176
326
|
if [ -n "$BID" ]; then
|
|
177
|
-
|
|
178
|
-
|
|
327
|
+
[ -n "$LIVE_IDENT" ] || die "linear resolved a team with no authoritative key"
|
|
328
|
+
mirror_to_role_yaml linear "$BID" "" "$LIVE_IDENT" "$SOT_TEAM"
|
|
329
|
+
pj_write 1 linear "$BID" "" "$LIVE_IDENT" "$SOT_TEAM" provider
|
|
179
330
|
else
|
|
180
331
|
warn "[42] linear resolve returned no board; set ticket_provider.team and re-run"
|
|
181
|
-
pj_write 1 linear "" "" "" "$
|
|
332
|
+
pj_write 1 linear "" "" "" "$SOT_TEAM" ""
|
|
182
333
|
fi
|
|
183
334
|
;;
|
|
184
335
|
|
|
@@ -186,16 +337,34 @@ except Exception: print("")')"
|
|
|
186
337
|
KEYVAR=PLANE_API_KEY; [ "$PROVIDER" = trello ] && KEYVAR=TRELLO_KEY
|
|
187
338
|
if [[ -z "${!KEYVAR:-}" ]]; then
|
|
188
339
|
warn "[42] $KEYVAR not set; skipping board creation. Set creds and re-run ./.scripts/42-ticket-provider.sh"
|
|
189
|
-
|
|
340
|
+
# Deferred: no board was created, so there is no confirmed identifier.
|
|
341
|
+
# Persist nothing rather than freezing the proposal into .project.json.
|
|
342
|
+
pj_write 1 "$PROVIDER" "" "${SOT_WS:-$PLANE_WORKSPACE}" "" "" ""
|
|
190
343
|
mark_done 42-ticket-provider; exit 0
|
|
191
344
|
fi
|
|
192
|
-
OUT="$(tp create_board "$NAME" "$
|
|
345
|
+
OUT="$(tp create_board "$NAME" "$PROPOSED_IDENT" "$DESC")" || die "create_board failed for $PROVIDER"
|
|
193
346
|
BID="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_id",""))')"
|
|
194
|
-
|
|
347
|
+
LIVE_IDENT="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("identifier","") or "")')"
|
|
195
348
|
WS="${SOT_WS:-$PLANE_WORKSPACE}"
|
|
196
349
|
[ "$PROVIDER" = trello ] && WS=""
|
|
197
|
-
|
|
198
|
-
|
|
350
|
+
case "$PROVIDER" in
|
|
351
|
+
plane)
|
|
352
|
+
# Plane mints the identifier and hands it back on create; anything else
|
|
353
|
+
# is a guess and must not be written.
|
|
354
|
+
[ -n "$LIVE_IDENT" ] \
|
|
355
|
+
|| die "created/bound Plane board has no authoritative live identifier"
|
|
356
|
+
IDENT_SOURCE=provider
|
|
357
|
+
;;
|
|
358
|
+
trello)
|
|
359
|
+
# Trello mints no key. create_board echoes back the prefix it was
|
|
360
|
+
# handed, which confirms what the board is BOUND under — not a value
|
|
361
|
+
# Trello chose. Record it stamped as the proposal it is, so nothing
|
|
362
|
+
# downstream can read it as provider truth.
|
|
363
|
+
IDENT_SOURCE=proposed
|
|
364
|
+
;;
|
|
365
|
+
esac
|
|
366
|
+
mirror_to_role_yaml "$PROVIDER" "$BID" "$WS" "$LIVE_IDENT" ""
|
|
367
|
+
pj_write 1 "$PROVIDER" "$BID" "$WS" "$LIVE_IDENT" "" "$IDENT_SOURCE"
|
|
199
368
|
;;
|
|
200
369
|
|
|
201
370
|
*) die "unknown ticket provider: $PROVIDER (expected linear|plane|trello)" ;;
|
|
@@ -8,6 +8,8 @@ load_role_env
|
|
|
8
8
|
if [[ "$ROLE" == "reporter" ]]; then
|
|
9
9
|
log "[70] reporter systemd — gateway/consumer intentionally not installed"
|
|
10
10
|
log " use the reporter runtime's explicit install command after credential and policy preflight"
|
|
11
|
+
yaml_upsert_block_value service_state gateway not-applicable
|
|
12
|
+
yaml_upsert_block_value service_state heartbeat not-applicable
|
|
11
13
|
mark_done 70-systemd
|
|
12
14
|
exit 0
|
|
13
15
|
fi
|
|
@@ -18,6 +20,7 @@ RUNTIME="$ROLE_DIR/runtime"
|
|
|
18
20
|
FLEET_HOME="${HERMES_FLEET_HOME:-$HOME/.hermes}"
|
|
19
21
|
PROFILE_HOME="$FLEET_HOME/profiles/${PROFILE_NAME:-$AGENT_ID}"
|
|
20
22
|
REPO_ROOT="$(project_repo_path)" || REPO_ROOT="$ROLE_DIR"
|
|
23
|
+
|
|
21
24
|
SYS_DIR="$HOME/.config/systemd/user"
|
|
22
25
|
GW_UNIT="hermes-${AGENT_ID}-gateway.service"
|
|
23
26
|
HB_SVC="hermes-${AGENT_ID}-heartbeat.service"
|
|
@@ -37,6 +40,19 @@ if [[ "${SKIP_SYSTEMD:-0}" == "1" ]]; then
|
|
|
37
40
|
exit 0
|
|
38
41
|
fi
|
|
39
42
|
|
|
43
|
+
# Legacy manifests defaulted reconciliation off and had no way to distinguish
|
|
44
|
+
# that default from an operator decision. The new explicit_opt_out sentinel is
|
|
45
|
+
# authoritative: migrate unmarked roles to the operational PM default, while a
|
|
46
|
+
# rendered/operator-recorded opt-out remains checkpoint-only on every rerun.
|
|
47
|
+
if [[ "$(yaml_get reconcile.explicit_opt_out)" == "true" ]]; then
|
|
48
|
+
yaml_upsert_block_value reconcile enabled false bool
|
|
49
|
+
log " PM reconciliation explicit opt-out preserved"
|
|
50
|
+
else
|
|
51
|
+
yaml_upsert_block_value reconcile enabled true bool
|
|
52
|
+
yaml_upsert_block_value reconcile explicit_opt_out false bool
|
|
53
|
+
log " PM reconciliation enabled (operational default)"
|
|
54
|
+
fi
|
|
55
|
+
|
|
40
56
|
# Render every caller-controlled systemd scalar through the same data-only
|
|
41
57
|
# serializer used by fleet backfill. This validation happens before mkdir,
|
|
42
58
|
# systemctl, or unit writes, so CR/LF/NUL cannot create a second directive and
|
|
@@ -72,19 +88,13 @@ HB_LOG_OUTPUT="$(systemd_scalar "append:$RUNTIME/logs/heartbeat.log")"
|
|
|
72
88
|
GW_EXEC_START="$(systemd_exec_value "$ROLE_DIR/.scripts/credential-launch.sh")"
|
|
73
89
|
HB_EXEC_START="$GW_EXEC_START"
|
|
74
90
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
91
|
+
# A model credential may be supplied through systemd's encrypted credential
|
|
92
|
+
# store. Chat-channel values are never materialized here: Hermes resolves their
|
|
93
|
+
# profile-scoped op:// references natively at process startup.
|
|
78
94
|
CREDENTIAL_DIR="${HERMES_SYSTEMD_CREDENTIAL_DIR:-$HOME/.config/hermes-agent/credentials}"
|
|
79
|
-
TELEGRAM_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-telegram-bot-token.cred"
|
|
80
95
|
MODEL_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-model-api-key.cred"
|
|
81
96
|
GW_CREDENTIAL_LINES=""
|
|
82
97
|
HB_CREDENTIAL_LINES=""
|
|
83
|
-
if [[ -f "$TELEGRAM_CREDENTIAL" ]]; then
|
|
84
|
-
command -v systemd-creds >/dev/null 2>&1 \
|
|
85
|
-
|| die "encrypted Telegram credential exists but systemd-creds is unavailable"
|
|
86
|
-
GW_CREDENTIAL_LINES="LoadCredentialEncrypted=$(systemd_value "telegram_bot_token:$TELEGRAM_CREDENTIAL")"
|
|
87
|
-
fi
|
|
88
98
|
if [[ -f "$MODEL_CREDENTIAL" ]]; then
|
|
89
99
|
[[ -n "$(yaml_get model.key_env)" ]] \
|
|
90
100
|
|| die "encrypted model credential exists but model.key_env is blank in role.yaml"
|
|
@@ -94,6 +104,42 @@ if [[ -f "$MODEL_CREDENTIAL" ]]; then
|
|
|
94
104
|
HB_CREDENTIAL_LINES="LoadCredentialEncrypted=$(systemd_value "model_api_key:$MODEL_CREDENTIAL")"
|
|
95
105
|
fi
|
|
96
106
|
|
|
107
|
+
# A gateway is eligible only when at least one channel identity is verified AND
|
|
108
|
+
# both the named-profile mapping and the referenced 1Password value resolve.
|
|
109
|
+
# Role metadata alone is not sufficient: a stale done marker must never revive
|
|
110
|
+
# a credential-less crash loop.
|
|
111
|
+
gateway_ready=0
|
|
112
|
+
gateway_validation_unavailable=0
|
|
113
|
+
if [[ "$(yaml_get telegram.provisioning_status)" == "verified" ]] \
|
|
114
|
+
&& profile_onepassword_ref_exists "$PROFILE_HOME" TELEGRAM_BOT_TOKEN; then
|
|
115
|
+
telegram_reference_rc=0
|
|
116
|
+
profile_onepassword_ref_validate "$PROFILE_HOME" TELEGRAM_BOT_TOKEN \
|
|
117
|
+
|| telegram_reference_rc=$?
|
|
118
|
+
if [[ $telegram_reference_rc -eq 0 ]]; then
|
|
119
|
+
gateway_ready=1
|
|
120
|
+
elif [[ $telegram_reference_rc -eq 75 ]]; then
|
|
121
|
+
gateway_validation_unavailable=1
|
|
122
|
+
fi
|
|
123
|
+
fi
|
|
124
|
+
if [[ "$(yaml_get slack.provisioning_status)" == "verified" ]] \
|
|
125
|
+
&& profile_onepassword_ref_exists "$PROFILE_HOME" SLACK_BOT_TOKEN \
|
|
126
|
+
&& profile_onepassword_ref_exists "$PROFILE_HOME" SLACK_APP_TOKEN; then
|
|
127
|
+
slack_bot_reference_rc=0
|
|
128
|
+
slack_app_reference_rc=0
|
|
129
|
+
profile_onepassword_ref_validate "$PROFILE_HOME" SLACK_BOT_TOKEN \
|
|
130
|
+
|| slack_bot_reference_rc=$?
|
|
131
|
+
profile_onepassword_ref_validate "$PROFILE_HOME" SLACK_APP_TOKEN \
|
|
132
|
+
|| slack_app_reference_rc=$?
|
|
133
|
+
if [[ $slack_bot_reference_rc -eq 0 && $slack_app_reference_rc -eq 0 ]]; then
|
|
134
|
+
gateway_ready=1
|
|
135
|
+
elif [[ $slack_bot_reference_rc -eq 75 || $slack_app_reference_rc -eq 75 ]]; then
|
|
136
|
+
gateway_validation_unavailable=1
|
|
137
|
+
fi
|
|
138
|
+
fi
|
|
139
|
+
if [[ $gateway_ready -eq 0 && $gateway_validation_unavailable -eq 1 ]]; then
|
|
140
|
+
die "channel 1Password validation is temporarily unavailable; preserving existing gateway state for retry"
|
|
141
|
+
fi
|
|
142
|
+
|
|
97
143
|
# Singleton-runtime contract: units set HERMES_HOME to the agent's NAMED PROFILE
|
|
98
144
|
# dir, never the raw runtime path — Hermes derives profile identity and shared
|
|
99
145
|
# fleet auth from the unresolved HERMES_HOME. $RUNTIME stays correct for
|
|
@@ -159,11 +205,21 @@ chmod +x "$HEARTBEAT_BIN" "$CREDENTIAL_LAUNCHER" "$ROLE_DIR/.scripts/checkpoint.
|
|
|
159
205
|
|| die "named profile is not a real directory; run: pj migrate hermes.runtime-singleton '$REPO_ROOT'"
|
|
160
206
|
|
|
161
207
|
# Gateway unit
|
|
208
|
+
#
|
|
209
|
+
# StartLimit* belongs in [Unit], not [Service] — systemd only still parses it
|
|
210
|
+
# under [Service] for backwards compatibility. Without it, Restart=on-failure
|
|
211
|
+
# retries forever and a gateway that can never start (bad token, bad config)
|
|
212
|
+
# sits in `activating` indefinitely instead of settling into `failed`, so
|
|
213
|
+
# `systemctl --user --failed` never lists it and neither the sentinel nor a
|
|
214
|
+
# human ever sees the crashloop. That is exactly how the fleet accumulated
|
|
215
|
+
# 10,427 invisible restarts. 5 tries in 5 minutes, then stop and report failed.
|
|
162
216
|
cat > "$SYS_DIR/$GW_UNIT" <<UNIT
|
|
163
217
|
[Unit]
|
|
164
218
|
Description=$GW_DESCRIPTION
|
|
165
219
|
After=network-online.target
|
|
166
220
|
Wants=network-online.target
|
|
221
|
+
StartLimitIntervalSec=300
|
|
222
|
+
StartLimitBurst=5
|
|
167
223
|
|
|
168
224
|
[Service]
|
|
169
225
|
Type=simple
|
|
@@ -227,14 +283,72 @@ UNIT
|
|
|
227
283
|
|
|
228
284
|
if systemd_user_available; then
|
|
229
285
|
systemctl --user daemon-reload
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
systemctl --user
|
|
235
|
-
|
|
286
|
+
if systemctl --user enable --now "$HB_TIMER" >/dev/null 2>&1; then
|
|
287
|
+
# The timer's first scheduled tick can be a minute away. Run the oneshot
|
|
288
|
+
# once now so deployment proves the heartbeat command itself completed;
|
|
289
|
+
# timer activity alone is not an operational postcondition.
|
|
290
|
+
if ! systemctl --user start "$HB_SVC" >/dev/null 2>&1; then
|
|
291
|
+
systemctl --user disable --now "$HB_TIMER" >/dev/null 2>&1 || true
|
|
292
|
+
yaml_upsert_block_value service_state heartbeat error
|
|
293
|
+
clear_done 70-systemd
|
|
294
|
+
die "required heartbeat oneshot failed its deployment probe: $HB_SVC"
|
|
295
|
+
fi
|
|
296
|
+
if hb_health="$(systemd_wait_for_stable_health \
|
|
297
|
+
systemd_timer_health_snapshot "$HB_TIMER" "$HB_SVC")"; then
|
|
298
|
+
yaml_upsert_block_value service_state heartbeat active
|
|
299
|
+
log " heartbeat enabled + active with healthy latest result: $HB_TIMER"
|
|
300
|
+
else
|
|
301
|
+
systemctl --user disable --now "$HB_TIMER" >/dev/null 2>&1 || true
|
|
302
|
+
yaml_upsert_block_value service_state heartbeat error
|
|
303
|
+
clear_done 70-systemd
|
|
304
|
+
die "heartbeat did not stabilize healthy: $hb_health"
|
|
305
|
+
fi
|
|
306
|
+
else
|
|
307
|
+
yaml_upsert_block_value service_state heartbeat error
|
|
308
|
+
clear_done 70-systemd
|
|
309
|
+
die "failed to enable/start required heartbeat timer: $HB_TIMER"
|
|
310
|
+
fi
|
|
311
|
+
|
|
312
|
+
if [[ $gateway_ready -eq 1 ]]; then
|
|
313
|
+
if systemctl --user enable --now "$GW_UNIT" >/dev/null 2>&1; then
|
|
314
|
+
if gw_health="$(systemd_wait_for_stable_health \
|
|
315
|
+
systemd_service_health_snapshot "$GW_UNIT" running)"; then
|
|
316
|
+
yaml_upsert_block_value service_state gateway active
|
|
317
|
+
log " credentialed gateway enabled + active and stabilized: $GW_UNIT"
|
|
318
|
+
else
|
|
319
|
+
systemctl --user disable --now "$GW_UNIT" >/dev/null 2>&1 || true
|
|
320
|
+
systemctl --user reset-failed "$GW_UNIT" >/dev/null 2>&1 || true
|
|
321
|
+
yaml_upsert_block_value service_state gateway error
|
|
322
|
+
clear_done 70-systemd
|
|
323
|
+
die "credentialed gateway did not stabilize healthy: $gw_health"
|
|
324
|
+
fi
|
|
325
|
+
else
|
|
326
|
+
yaml_upsert_block_value service_state gateway error
|
|
327
|
+
clear_done 70-systemd
|
|
328
|
+
die "failed to enable/start credentialed gateway: $GW_UNIT"
|
|
329
|
+
fi
|
|
330
|
+
else
|
|
331
|
+
systemctl --user disable --now "$GW_UNIT" >/dev/null 2>&1 \
|
|
332
|
+
|| die "could not enforce deferred gateway disablement: $GW_UNIT"
|
|
333
|
+
systemctl --user reset-failed "$GW_UNIT" >/dev/null 2>&1 || true
|
|
334
|
+
gw_deferred_health="$(systemd_gateway_deferred_snapshot "$GW_UNIT")"
|
|
335
|
+
if [[ "$gw_deferred_health" == "ok|deferred" ]]; then
|
|
336
|
+
yaml_upsert_block_value service_state gateway deferred
|
|
337
|
+
log " gateway deferred: disabled + inactive until a channel credential is verified"
|
|
338
|
+
else
|
|
339
|
+
yaml_upsert_block_value service_state gateway error
|
|
340
|
+
clear_done 70-systemd
|
|
341
|
+
die "gateway deferral was not proven disabled + inactive ($gw_deferred_health)"
|
|
342
|
+
fi
|
|
343
|
+
fi
|
|
236
344
|
else
|
|
237
345
|
warn " systemd --user not available; units installed at $SYS_DIR but not enabled"
|
|
346
|
+
yaml_upsert_block_value service_state heartbeat installed
|
|
347
|
+
if [[ $gateway_ready -eq 1 ]]; then
|
|
348
|
+
yaml_upsert_block_value service_state gateway installed
|
|
349
|
+
else
|
|
350
|
+
yaml_upsert_block_value service_state gateway deferred
|
|
351
|
+
fi
|
|
238
352
|
fi
|
|
239
353
|
|
|
240
354
|
mark_done 70-systemd
|
|
@@ -40,8 +40,10 @@ python3 - "$REGISTRY_FILE" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" \
|
|
|
40
40
|
"$PLANE_WORKSPACE" "$PLANE_PROJECT_ID" "$(yaml_get plane.identifier)" \
|
|
41
41
|
"$RUNTIME_REPO" "$HERMES_BIN" "$HERMES_AGENT_REPO" "$HERMES_RUNTIME_GIT_URL" \
|
|
42
42
|
"$HERMES_RUNTIME_GIT_REF" "$HERMES_RUNTIME_GIT_SHA" "$FLEET_ENV" \
|
|
43
|
-
"hermes-${AGENT_ID}-gateway.service" "hermes-${AGENT_ID}-heartbeat.timer"
|
|
43
|
+
"hermes-${AGENT_ID}-gateway.service" "hermes-${AGENT_ID}-heartbeat.timer" \
|
|
44
|
+
"$(yaml_get service_state.gateway)" "$(yaml_get service_state.heartbeat)" <<'PYEOF'
|
|
44
45
|
import datetime
|
|
46
|
+
import copy
|
|
45
47
|
import errno
|
|
46
48
|
import os
|
|
47
49
|
import pathlib
|
|
@@ -56,11 +58,17 @@ except ImportError:
|
|
|
56
58
|
slack_status, slack_team_id, slack_team_name, slack_user_id, slack_bot_id,
|
|
57
59
|
slack_username, bloodbank_enabled, bloodbank_scope, bloodbank_target, plane_ws, plane_id,
|
|
58
60
|
plane_ident, runtime_repo, hermes_bin, hermes_repo, hermes_git_url,
|
|
59
|
-
hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat
|
|
61
|
+
hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat,
|
|
62
|
+
gateway_state, heartbeat_state) = sys.argv[1:35]
|
|
60
63
|
p = pathlib.Path(path)
|
|
61
64
|
if p.is_symlink():
|
|
62
65
|
raise SystemExit(f"refusing to update registry symlink: {p}")
|
|
63
|
-
data = yaml.safe_load(p.read_text()) or {"schema_version": 1, "agents": {}}
|
|
66
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {"schema_version": 1, "agents": {}}
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
raise SystemExit("fleet registry root must be a mapping")
|
|
69
|
+
agents = data.setdefault("agents", {})
|
|
70
|
+
if not isinstance(agents, dict):
|
|
71
|
+
raise SystemExit("fleet registry agents must be a mapping")
|
|
64
72
|
if bloodbank_enabled == "":
|
|
65
73
|
bloodbank_enabled_value = False
|
|
66
74
|
elif bloodbank_enabled == "true":
|
|
@@ -69,7 +77,13 @@ elif bloodbank_enabled == "false":
|
|
|
69
77
|
bloodbank_enabled_value = False
|
|
70
78
|
else:
|
|
71
79
|
raise SystemExit("bloodbank.enabled must be the strict YAML boolean true or false")
|
|
72
|
-
|
|
80
|
+
existing = agents.get(agent_id, {})
|
|
81
|
+
if not isinstance(existing, dict):
|
|
82
|
+
raise SystemExit(f"fleet registry entry for {agent_id} must be a mapping")
|
|
83
|
+
provisioned_at = existing.get("provisioned_at")
|
|
84
|
+
if not isinstance(provisioned_at, str) or not provisioned_at:
|
|
85
|
+
provisioned_at = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
86
|
+
managed = {
|
|
73
87
|
"repo": repo, "role": role, "display_name": display,
|
|
74
88
|
"project_path": project, "role_dir": role_dir,
|
|
75
89
|
"profile_name": profile,
|
|
@@ -101,9 +115,31 @@ data.setdefault("agents", {})[agent_id] = {
|
|
|
101
115
|
"git_sha": hermes_git_sha,
|
|
102
116
|
"fleet_env": fleet_env,
|
|
103
117
|
},
|
|
104
|
-
"systemd": {
|
|
105
|
-
|
|
118
|
+
"systemd": {
|
|
119
|
+
"gateway_unit": gw,
|
|
120
|
+
"heartbeat_timer": heartbeat,
|
|
121
|
+
"gateway_state": gateway_state,
|
|
122
|
+
"heartbeat_state": heartbeat_state,
|
|
123
|
+
},
|
|
124
|
+
"provisioned_at": provisioned_at,
|
|
106
125
|
}
|
|
126
|
+
|
|
127
|
+
def merge_managed(current, update):
|
|
128
|
+
result = copy.deepcopy(current)
|
|
129
|
+
for key, value in update.items():
|
|
130
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
131
|
+
result[key] = merge_managed(result[key], value)
|
|
132
|
+
else:
|
|
133
|
+
result[key] = copy.deepcopy(value)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
entry = merge_managed(existing, managed)
|
|
137
|
+
# This is retired managed schema, not extension metadata. Keeping it would
|
|
138
|
+
# falsely advertise a second per-agent Bloodbank execution path.
|
|
139
|
+
systemd = entry.get("systemd")
|
|
140
|
+
if isinstance(systemd, dict):
|
|
141
|
+
systemd.pop("consumer_unit", None)
|
|
142
|
+
agents[agent_id] = entry
|
|
107
143
|
rendered = yaml.safe_dump(data, sort_keys=False)
|
|
108
144
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
109
145
|
|