aidevops 3.13.95 → 3.14.1

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.
@@ -1,978 +0,0 @@
1
- #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
3
- # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
- # =============================================================================
5
- # Schedulers Pulse Sub-Library -- Pulse resolution, supervisor setup, plist
6
- # generation, and watchdog installation functions.
7
- # =============================================================================
8
- # This sub-library is sourced by setup-modules/schedulers.sh (the orchestrator).
9
- # It covers:
10
- # - Modern bash resolution for launchd ProgramArguments
11
- # - Pulse consent resolution and install decision
12
- # - OpenCode binary discovery (nvm/volta/fnm sweep, legacy paths)
13
- # - Supervisor pulse installation (launchd + Linux)
14
- # - Plist content generation (pulse + watchdog)
15
- # - Pulse watchdog installation
16
- #
17
- # Usage: source "${SCRIPT_DIR}/schedulers-pulse.sh"
18
- #
19
- # Dependencies:
20
- # - shared-constants.sh (print_error, print_info, print_warning)
21
- # - schedulers-linux.sh (must be sourced separately; _install_scheduler_linux
22
- # is called by _install_supervisor_pulse and _install_pulse_watchdog_systemd)
23
- #
24
- # Part of aidevops framework: https://aidevops.sh
25
-
26
- # Apply strict mode only when executed directly (not when sourced)
27
- [[ "${BASH_SOURCE[0]}" == "${0}" ]] && set -euo pipefail
28
-
29
- # Include guard
30
- [[ -n "${_SCHEDULERS_PULSE_LIB_LOADED:-}" ]] && return 0
31
- _SCHEDULERS_PULSE_LIB_LOADED=1
32
-
33
- # SCRIPT_DIR fallback — needed when sourced from test harnesses that don't set it.
34
- # Pure-bash dirname replacement (avoids external binary dependency).
35
- if [[ -z "${SCRIPT_DIR:-}" ]]; then
36
- _sched_pulse_lib_path="${BASH_SOURCE[0]%/*}"
37
- [[ "$_sched_pulse_lib_path" == "${BASH_SOURCE[0]}" ]] && _sched_pulse_lib_path="."
38
- SCRIPT_DIR="$(cd "$_sched_pulse_lib_path" && pwd)"
39
- unset _sched_pulse_lib_path
40
- fi
41
-
42
- # --- Functions ---
43
-
44
- # Resolve the modern bash binary path for use in launchd ProgramArguments.
45
- # Launchd bypasses the shebang when ProgramArguments specifies an explicit
46
- # interpreter, so we must resolve the path at plist generation time.
47
- # Falls back to /bin/bash if no modern bash is available (the re-exec guard
48
- # in shared-constants.sh provides defense-in-depth). (GH#19632 / t2176)
49
- _resolve_modern_bash() {
50
- local candidate
51
- for candidate in /opt/homebrew/bin/bash /usr/local/bin/bash /home/linuxbrew/.linuxbrew/bin/bash; do
52
- if [[ -x "$candidate" ]]; then
53
- # Verify it's actually bash 4+
54
- local ver
55
- # shellcheck disable=SC2016 # single quotes intentional: evaluated by $candidate, not current shell
56
- ver=$("$candidate" -c 'echo "${BASH_VERSINFO[0]}"' 2>/dev/null) || continue
57
- if [[ "${ver:-0}" -ge 4 ]]; then
58
- printf '%s' "$candidate"
59
- return 0
60
- fi
61
- fi
62
- done
63
- # No modern bash found — fall back to /bin/bash. The re-exec guard in
64
- # shared-constants.sh handles this case at runtime.
65
- printf '%s' "/bin/bash"
66
- return 0
67
- }
68
-
69
- # Resolve the user's pulse consent setting from all config layers.
70
- # Priority: env var > jsonc config > legacy .conf. Prints the raw value
71
- # (may be empty if never configured, or "true"/"false").
72
- _resolve_pulse_consent() {
73
- local _pulse_user_config=""
74
-
75
- # Read explicit user consent from config.jsonc (not merged defaults).
76
- # Empty = user never configured this; "true"/"false" = explicit choice.
77
- if type _jsonc_get_raw &>/dev/null && [[ -f "${JSONC_USER:-$HOME/.config/aidevops/config.jsonc}" ]]; then
78
- _pulse_user_config=$(_jsonc_get_raw "${JSONC_USER:-$HOME/.config/aidevops/config.jsonc}" "orchestration.supervisor_pulse")
79
- fi
80
-
81
- # Also check legacy .conf user override
82
- if [[ -z "$_pulse_user_config" && -f "${FEATURE_TOGGLES_USER:-$HOME/.config/aidevops/feature-toggles.conf}" ]]; then
83
- local _legacy_val
84
- # Use awk instead of grep|tail|cut — grep exits 1 on no match, which
85
- # aborts the script under set -euo pipefail. awk always exits 0.
86
- _legacy_val=$(awk -F= '/^supervisor_pulse=/{val=$2} END{print val}' "${FEATURE_TOGGLES_USER:-$HOME/.config/aidevops/feature-toggles.conf}")
87
- if [[ -n "$_legacy_val" ]]; then
88
- _pulse_user_config="$_legacy_val"
89
- fi
90
- fi
91
-
92
- # Also check env var override (highest priority)
93
- if [[ -n "${AIDEVOPS_SUPERVISOR_PULSE:-}" ]]; then
94
- _pulse_user_config="$AIDEVOPS_SUPERVISOR_PULSE"
95
- fi
96
-
97
- printf '%s' "$_pulse_user_config"
98
- return 0
99
- }
100
-
101
- # Determine whether to install the pulse based on consent state.
102
- # Handles interactive prompting and persisting the user's choice.
103
- # Args: $1=pulse_user_config (raw), $2=wrapper_script path
104
- # Prints "true" or "false".
105
- _determine_pulse_install() {
106
- local _pulse_user_config="$1"
107
- local wrapper_script="$2"
108
- local _do_install=false
109
- local _pulse_lower
110
- _pulse_lower=$(echo "$_pulse_user_config" | tr '[:upper:]' '[:lower:]')
111
-
112
- if [[ "$_pulse_lower" == "false" ]]; then
113
- # User explicitly declined — never prompt, never install
114
- _do_install=false
115
- elif [[ "$_pulse_lower" == "true" ]]; then
116
- # User explicitly consented — install/regenerate
117
- _do_install=true
118
- elif [[ -z "$_pulse_user_config" ]]; then
119
- # No explicit config — fresh install or never configured
120
- if [[ "$NON_INTERACTIVE" == "true" ]]; then
121
- # Non-interactive: default OFF, do not install without consent
122
- _do_install=false
123
- elif [[ -f "$wrapper_script" ]]; then
124
- # Interactive: prompt with default-no
125
- # All user-facing output goes to stderr so $() captures only the result
126
- local enable_pulse=""
127
- echo "" >&2
128
- echo "The supervisor pulse enables autonomous orchestration." >&2
129
- echo "It will act under your GitHub identity and consume API credits:" >&2
130
- echo " - Dispatches AI workers to implement tasks from GitHub issues" >&2
131
- echo " - Creates PRs, merges passing PRs, files improvement issues" >&2
132
- echo " - 4-hourly strategic review (opus-tier) for queue health" >&2
133
- echo " - Circuit breaker pauses dispatch on consecutive failures" >&2
134
- echo "" >&2
135
- setup_prompt enable_pulse "Enable supervisor pulse? [y/N]: " "n"
136
- if [[ "$enable_pulse" =~ ^[Yy]$ ]]; then
137
- _do_install=true
138
- # Record explicit consent
139
- if type cmd_set &>/dev/null; then
140
- cmd_set "orchestration.supervisor_pulse" "true" || true
141
- fi
142
- else
143
- _do_install=false
144
- # Record explicit decline so we never re-prompt on updates
145
- if type cmd_set &>/dev/null; then
146
- cmd_set "orchestration.supervisor_pulse" "false" || true
147
- fi
148
- print_info "Skipped. Enable later: aidevops config set orchestration.supervisor_pulse true && ./setup.sh" >&2
149
- fi
150
- fi
151
- fi
152
-
153
- # Guard: wrapper must exist
154
- if [[ "$_do_install" == "true" && ! -f "$wrapper_script" ]]; then
155
- # Wrapper not deployed yet — skip (will install on next run after rsync)
156
- _do_install=false
157
- fi
158
-
159
- printf '%s' "$_do_install"
160
- return 0
161
- }
162
-
163
- # GH#17769: These functions are deprecated — model routing is now derived
164
- # from the OAuth pool + routing table at runtime. Kept as no-ops for one
165
- # release cycle in case external scripts call them.
166
- _resolve_headless_models_override() {
167
- printf '%s' ""
168
- return 0
169
- }
170
-
171
- _resolve_pulse_model_override() {
172
- printf '%s' ""
173
- return 0
174
- }
175
-
176
- _is_pulse_installed() {
177
- local pulse_label="$1"
178
-
179
- if _scheduler_detect_installed \
180
- "Supervisor pulse" \
181
- "$pulse_label" \
182
- "" \
183
- "pulse-wrapper" \
184
- "" \
185
- "" \
186
- "" \
187
- "aidevops-supervisor-pulse"; then
188
- return 0
189
- fi
190
-
191
- return 1
192
- }
193
-
194
- # t2954: Sweep Node version manager install roots (nvm, volta, fnm) for
195
- # an opencode binary. Linux runners overwhelmingly install Node via nvm,
196
- # which the legacy fixed-paths sweep in step 4b misses entirely — the
197
- # alex-solovyev runner (Apr 2026, 9-day dispatch outage) is the canonical
198
- # failure mode. Most-recent Node version wins (sort -rV). Each candidate
199
- # is product-validated when the validator is in scope; nvm/volta/fnm can
200
- # all host either anomalyco/opencode (from `npm i -g opencode`) or the
201
- # Anthropic claude CLI (from `npm i -g @anthropic-ai/claude-code`) under
202
- # the same `opencode` bin name, so validation is mandatory.
203
- # $1 = "1" if _setup_validate_opencode_binary is callable, else "0".
204
- # Returns 0 + prints path on hit, 1 on miss.
205
- _sweep_nvm_volta_fnm_for_opencode() {
206
- local _have_validator="${1:-0}"
207
- local _root _version_dir _candidate
208
- for _root in \
209
- "$HOME/.nvm/versions/node" \
210
- "$HOME/.volta/tools/image/node" \
211
- "$HOME/.local/share/fnm/node-versions"; do
212
- [[ -d "$_root" ]] || continue
213
- while IFS= read -r _version_dir; do
214
- # nvm + volta: <ver>/bin/opencode; fnm: <ver>/installation/bin/opencode
215
- for _candidate in \
216
- "$_version_dir/bin/opencode" \
217
- "$_version_dir/installation/bin/opencode"; do
218
- [[ -x "$_candidate" ]] || continue
219
- if [[ "$_have_validator" -eq 1 ]] && \
220
- ! _setup_validate_opencode_binary "$_candidate"; then
221
- continue
222
- fi
223
- printf '%s' "$_candidate"
224
- return 0
225
- done
226
- done < <(find "$_root" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -rV)
227
- done
228
- return 1
229
- }
230
-
231
- # t2954: Legacy fixed-install-paths sweep for an opencode binary. Used as
232
- # a last-resort discovery when persistence, runtime registry, live PATH,
233
- # and the Node-version-manager sweep all came up empty. Each candidate
234
- # is product-validated when the validator is in scope; the claude entries
235
- # remain in the list for documentation but always fail validation and
236
- # are skipped (they were the alex-solovyev silent-product-swap source).
237
- # $1 = "1" if _setup_validate_opencode_binary is callable, else "0".
238
- # Returns 0 + prints path on hit, 1 on miss.
239
- _sweep_legacy_install_paths_for_opencode() {
240
- local _have_validator="${1:-0}"
241
- local _candidate
242
- for _candidate in \
243
- /opt/homebrew/bin/opencode \
244
- /usr/local/bin/opencode \
245
- /home/linuxbrew/.linuxbrew/bin/opencode \
246
- "$HOME/.npm-global/bin/opencode" \
247
- "$HOME/.local/bin/opencode" \
248
- "$HOME/.bun/bin/opencode" \
249
- /opt/homebrew/bin/claude \
250
- /usr/local/bin/claude \
251
- "$HOME/.local/bin/claude"; do
252
- [[ -x "$_candidate" ]] || continue
253
- if [[ "$_have_validator" -eq 1 ]] && \
254
- ! _setup_validate_opencode_binary "$_candidate"; then
255
- continue
256
- fi
257
- printf '%s' "$_candidate"
258
- return 0
259
- done
260
- return 1
261
- }
262
-
263
- # t2954: Persist a resolved runtime path with product validation. Persisting
264
- # an unvalidated path is exactly how the alex-solovyev runner locked in
265
- # its 9-day dispatch outage — claude was persisted as OPENCODE_BIN and
266
- # every subsequent canary fired `config_error` against the 1h negative
267
- # cache. With validation, a wrong-product result silently no-ops the
268
- # write and the next resolver run gets a fresh shot at finding a real
269
- # opencode binary.
270
- # $1 = path to persist; $2 = persistence file path; $3 = "1"/"0" validator-available flag.
271
- _persist_pulse_runtime_path() {
272
- local _bin="${1:-}" _file="${2:-}" _have_validator="${3:-0}"
273
- [[ -n "$_bin" ]] && [[ -x "$_bin" ]] || return 0
274
- if [[ "$_have_validator" -eq 1 ]]; then
275
- _setup_validate_opencode_binary "$_bin" || return 0
276
- fi
277
- mkdir -p "$(dirname "$_file")" 2>/dev/null || true
278
- printf '%s\n' "$_bin" >"$_file" 2>/dev/null || true
279
- return 0
280
- }
281
-
282
- _resolve_pulse_runtime_binary() {
283
- # GH#18439 + t2954. Persist the resolved binary across setup.sh
284
- # invocations so aidevops-auto-update.timer (systemd minimal PATH)
285
- # does not silently regenerate cron with the legacy macOS fallback,
286
- # AND validate every accepted candidate so the wrong product (claude
287
- # CLI under the opencode bin name) cannot silently take the slot.
288
- local _persisted_file="$HOME/.config/aidevops/scheduler-runtime-bin"
289
- local opencode_bin=""
290
- local _have_validator=0
291
- declare -F _setup_validate_opencode_binary >/dev/null 2>&1 && _have_validator=1
292
-
293
- # 1. Persisted path (validated). Drop+re-resolve on validation failure.
294
- if [[ -f "$_persisted_file" ]]; then
295
- local _persisted
296
- _persisted=$(head -n1 "$_persisted_file" 2>/dev/null || true)
297
- if [[ -n "$_persisted" ]] && [[ -x "$_persisted" ]]; then
298
- if [[ "$_have_validator" -eq 0 ]] || \
299
- _setup_validate_opencode_binary "$_persisted"; then
300
- printf '%s' "$_persisted"
301
- return 0
302
- fi
303
- fi
304
- fi
305
-
306
- # 2. Runtime-registry lookup via live PATH.
307
- if type rt_list_headless &>/dev/null; then
308
- local _sched_rt_id="" _sched_bin=""
309
- while IFS= read -r _sched_rt_id; do
310
- _sched_bin=$(rt_binary "$_sched_rt_id") || continue
311
- if [[ -n "$_sched_bin" ]] && command -v "$_sched_bin" &>/dev/null; then
312
- opencode_bin=$(command -v "$_sched_bin")
313
- break
314
- fi
315
- done < <(rt_list_headless)
316
- fi
317
-
318
- # 3. Direct PATH lookup.
319
- [[ -z "$opencode_bin" ]] && opencode_bin=$(command -v opencode 2>/dev/null || true)
320
-
321
- # 4a. Node version manager sweep (nvm, volta, fnm). Linux-friendly.
322
- [[ -z "$opencode_bin" ]] && opencode_bin=$(_sweep_nvm_volta_fnm_for_opencode "$_have_validator" || true)
323
-
324
- # 4b. Legacy fixed-install-paths sweep (Homebrew, npm-global, bun, .local/bin).
325
- [[ -z "$opencode_bin" ]] && opencode_bin=$(_sweep_legacy_install_paths_for_opencode "$_have_validator" || true)
326
-
327
- # 5. Last-resort legacy fallback (pre-GH#18439 behaviour).
328
- [[ -z "$opencode_bin" ]] && opencode_bin="/opt/homebrew/bin/opencode"
329
-
330
- # Persist (validated). Wrong-product results silently no-op the write.
331
- _persist_pulse_runtime_path "$opencode_bin" "$_persisted_file" "$_have_validator"
332
-
333
- printf '%s' "$opencode_bin"
334
- return 0
335
- }
336
-
337
- _build_pulse_linux_env() {
338
- # GH#17546/GH#17769: Model config is derived from pool + routing table at
339
- # runtime. No model env vars embedded in cron/systemd.
340
- local opencode_bin="${1:-}"
341
- local _pulse_env="PULSE_DIR=${HOME}/.aidevops/.agent-workspace
342
- PULSE_STALE_THRESHOLD=${PULSE_STALE_THRESHOLD_SECONDS}"
343
-
344
- # GH#18439 Bug 2: embed resolved runtime binary path so pulse-wrapper.sh
345
- # and headless-runtime-helper.sh find the correct binary under systemd's
346
- # minimal PATH (e.g. when aidevops-auto-update.timer regenerates the
347
- # service file). Mirrors the macOS launchd <OPENCODE_BIN> key.
348
- if [[ -n "$opencode_bin" ]]; then
349
- _pulse_env+=$'\n'"OPENCODE_BIN=${opencode_bin}"
350
- fi
351
-
352
- printf '%s' "$_pulse_env"
353
- return 0
354
- }
355
-
356
- # Read supervisor.pulse_interval_seconds from settings.json.
357
- # Falls back to 180 if the file is missing, the key is absent, or jq is unavailable.
358
- # Clamps to the validated range [30, 3600].
359
- # GH#18018: previously this was hardcoded as "120" in _install_supervisor_pulse.
360
- # t2744: default raised 120 → 180 to reduce GraphQL pressure (33% fewer cycles)
361
- # on multi-repo setups where per-cycle cost chronically exceeds 5000/hr.
362
- _read_pulse_interval_seconds() {
363
- local _settings_file="$HOME/.config/aidevops/settings.json"
364
- local _interval=180
365
-
366
- if command -v jq >/dev/null 2>&1 && [[ -f "$_settings_file" ]]; then
367
- local _raw
368
- _raw=$(jq -r '.supervisor.pulse_interval_seconds // empty' "$_settings_file" 2>/dev/null) || _raw=""
369
- if [[ -n "$_raw" ]] && [[ "$_raw" =~ ^[0-9]+$ ]]; then
370
- _interval="$_raw"
371
- fi
372
- fi
373
-
374
- # Clamp to validated range (mirrors settings-helper.sh validation: 30-3600)
375
- if [[ "$_interval" -lt 30 ]]; then
376
- _interval=30
377
- elif [[ "$_interval" -gt 3600 ]]; then
378
- _interval=3600
379
- fi
380
-
381
- printf '%d' "$_interval"
382
- return 0
383
- }
384
-
385
- # Convert an interval in seconds to a cron schedule expression (e.g. "*/2 * * * *").
386
- # Minimum granularity is 1 minute. Intervals that don't divide evenly into minutes
387
- # are rounded down to whole minutes with a warning.
388
- # Args: $1 = interval_seconds
389
- _seconds_to_cron_schedule() {
390
- local _interval_sec="$1"
391
- local _minutes=$((_interval_sec / 60))
392
- local _remainder=$((_interval_sec % 60))
393
-
394
- # Clamp to at least 1 minute
395
- if [[ "$_minutes" -lt 1 ]]; then
396
- _minutes=1
397
- fi
398
-
399
- # Warn if interval doesn't divide evenly into minutes
400
- if [[ "$_remainder" -ne 0 ]]; then
401
- echo "[schedulers] Warning: pulse_interval_seconds=${_interval_sec} does not divide evenly into minutes; rounding down to ${_minutes}min for cron schedule (systemd uses exact seconds)" >&2
402
- fi
403
-
404
- # cron step values must be 1-59; */60 is invalid. Use @hourly for exactly 60 min,
405
- # clamp anything above 59 to 59 (the _read_pulse_interval_seconds cap is 3600s=60min).
406
- if [[ "$_minutes" -ge 60 ]]; then
407
- printf '@hourly'
408
- else
409
- printf '*/%d * * * *' "$_minutes"
410
- fi
411
- return 0
412
- }
413
-
414
- _install_supervisor_pulse() {
415
- local _os="$1"
416
- local pulse_label="$2"
417
- local wrapper_script="$3"
418
- local opencode_bin="$4"
419
- local _pulse_installed="$5"
420
-
421
- mkdir -p "$HOME/.aidevops/logs"
422
-
423
- if [[ "$_os" == "Darwin" ]]; then
424
- _install_pulse_launchd "$pulse_label" "$wrapper_script" "$opencode_bin" "$_pulse_installed"
425
- return 0
426
- fi
427
-
428
- # GH#18018: read user-configured interval instead of hardcoding 120s / */2 cron
429
- local _pulse_interval_sec
430
- _pulse_interval_sec=$(_read_pulse_interval_seconds)
431
- local _pulse_cron_schedule
432
- _pulse_cron_schedule=$(_seconds_to_cron_schedule "$_pulse_interval_sec")
433
- # Build a human-readable interval label: show minutes for exact multiples of 60, seconds otherwise
434
- local _pulse_interval_label
435
- if (( _pulse_interval_sec % 60 == 0 )); then
436
- _pulse_interval_label="$((_pulse_interval_sec / 60)) min"
437
- else
438
- _pulse_interval_label="${_pulse_interval_sec}s"
439
- fi
440
-
441
- local _pulse_timeout_sec=$((PULSE_STALE_THRESHOLD_SECONDS + 60))
442
- local _pulse_env=""
443
- # GH#18439 Bug 2: thread resolved runtime binary path through to the
444
- # Linux env builder so OPENCODE_BIN is embedded in the systemd service
445
- # file (parity with the macOS launchd plist at line 415).
446
- _pulse_env=$(_build_pulse_linux_env "$opencode_bin")
447
- _install_scheduler_linux \
448
- "aidevops-supervisor-pulse" \
449
- "aidevops: supervisor-pulse" \
450
- "${_pulse_cron_schedule}" \
451
- "\"${wrapper_script}\"" \
452
- "${_pulse_interval_sec}" \
453
- "$HOME/.aidevops/logs/pulse-wrapper.log" \
454
- "$_pulse_env" \
455
- "Supervisor pulse enabled (every ${_pulse_interval_label})" \
456
- "Failed to install supervisor pulse scheduler. See runners.md for manual setup." \
457
- "true" \
458
- "false" \
459
- "" \
460
- "${_pulse_timeout_sec}"
461
- return 0
462
- }
463
-
464
- # Setup the supervisor pulse scheduler (consent-gated autonomous orchestration).
465
- # Uses pulse-wrapper.sh which handles dedup, orphan cleanup, and RAM-based concurrency.
466
- # macOS: launchd plist invoking wrapper | Linux: cron entry invoking wrapper
467
- # The plist is ALWAYS regenerated on setup.sh to pick up config changes (env vars,
468
- # thresholds). Only the first-install prompt is gated on consent state.
469
- #######################################
470
- # t2119: Record the schedulers.sh template hash to the shared state
471
- # directory. auto-update-helper.sh's check_launchd_plist_drift compares
472
- # this against the current hash on every update cycle — whenever
473
- # schedulers.sh changes without a VERSION bump (PR #19079 scenario),
474
- # drift is detected and setup.sh --non-interactive is re-run to
475
- # regenerate the installed plists.
476
- #
477
- # Called from setup_supervisor_pulse unconditionally so the hash is
478
- # kept current on every setup.sh run, whether pulse is installed,
479
- # upgraded, or disabled. Whole-file hash is the simplest signal that
480
- # any plist-generating change has occurred.
481
- #######################################
482
- _schedulers_record_template_hash() {
483
- local state_dir="$HOME/.aidevops/.agent-workspace/tmp"
484
- mkdir -p "$state_dir" 2>/dev/null || return 0
485
- local hash_file="$state_dir/schedulers-template-hash.state"
486
- local schedulers_src="${BASH_SOURCE[0]:-}"
487
- [[ -f "$schedulers_src" ]] || return 0
488
- if command -v shasum >/dev/null 2>&1; then
489
- shasum -a 256 "$schedulers_src" 2>/dev/null | awk '{print $1}' >"$hash_file" 2>/dev/null || true
490
- elif command -v sha256sum >/dev/null 2>&1; then
491
- sha256sum "$schedulers_src" 2>/dev/null | awk '{print $1}' >"$hash_file" 2>/dev/null || true
492
- fi
493
- return 0
494
- }
495
-
496
- setup_supervisor_pulse() {
497
- local _os="$1"
498
-
499
- # Record template hash so auto-update can detect drift between
500
- # schedulers.sh and the installed plists on macOS (t2119).
501
- _schedulers_record_template_hash
502
-
503
- # Ensure crontab has a global PATH= line (Linux only; macOS uses launchd env).
504
- # Must run before any cron entries are installed so they inherit the PATH.
505
- if [[ "$_os" != "Darwin" ]]; then
506
- _ensure_cron_path
507
- fi
508
-
509
- # Consent model (GH#2926):
510
- # - Default OFF: supervisor_pulse defaults to false in all config layers
511
- # - Explicit consent required: user must type "y" (prompt defaults to [y/N])
512
- # - Consent persisted: written to config.jsonc so it survives updates
513
- # - Never silently re-enabled: if config says false, skip entirely
514
- # - Non-interactive: only installs if config explicitly says true
515
- local wrapper_script="$HOME/.aidevops/agents/scripts/pulse-wrapper.sh"
516
- local pulse_label="com.aidevops.aidevops-supervisor-pulse"
517
-
518
- local _pulse_user_config
519
- _pulse_user_config=$(_resolve_pulse_consent)
520
-
521
- local _do_install
522
- _do_install=$(_determine_pulse_install "$_pulse_user_config" "$wrapper_script")
523
-
524
- local _pulse_lower
525
- _pulse_lower=$(echo "$_pulse_user_config" | tr '[:upper:]' '[:lower:]')
526
-
527
- # Detect if pulse is already installed (for upgrade messaging)
528
- # Uses shared helper to check launchd, cron, and systemd (GH#17381)
529
- local _pulse_installed=false
530
- if _is_pulse_installed "$pulse_label"; then
531
- _pulse_installed=true
532
- fi
533
-
534
- # Detect dispatch backend binary location (t1665.5 — registry-driven)
535
- local opencode_bin=""
536
- opencode_bin=$(_resolve_pulse_runtime_binary)
537
-
538
- if [[ "$_do_install" == "true" ]]; then
539
- _install_supervisor_pulse "$_os" "$pulse_label" "$wrapper_script" "$opencode_bin" "$_pulse_installed"
540
- elif [[ "$_pulse_lower" == "false" && "$_pulse_installed" == "true" ]]; then
541
- # User explicitly disabled but pulse is still installed — clean up
542
- _uninstall_pulse "$_os" "$pulse_label"
543
- fi
544
-
545
- # Export effective pulse state for setup_stats_wrapper.
546
- # Use the actual install decision (_do_install), not just the consent string,
547
- # so stats wrapper tracks the real scheduler state (e.g., wrapper missing → false).
548
- PULSE_CONSENT_LOWER="$_pulse_lower"
549
- if [[ "$_do_install" == "true" ]]; then
550
- PULSE_ENABLED="true"
551
- else
552
- PULSE_ENABLED="false"
553
- fi
554
- return 0
555
- }
556
-
557
- # Clean up old/legacy pulse launchd plists before reinstalling.
558
- # Args: $1=pulse_label, $2=pulse_plist path
559
- _cleanup_old_pulse_plists() {
560
- local pulse_label="$1"
561
- local pulse_plist="$2"
562
-
563
- # Unload old plist if upgrading
564
- if _launchd_has_agent "$pulse_label"; then
565
- launchctl unload "$pulse_plist" || true
566
- pkill -f 'Supervisor Pulse' 2>/dev/null || true
567
- fi
568
-
569
- # Also clean up old label if present
570
- local old_plist="$HOME/Library/LaunchAgents/com.aidevops.supervisor-pulse.plist"
571
- if [[ -f "$old_plist" ]]; then
572
- launchctl unload "$old_plist" || true
573
- rm -f "$old_plist"
574
- fi
575
- return 0
576
- }
577
-
578
- # Build XML environment variable fragment for headless model overrides.
579
- # GH#17546: Model config was removed from plist embedding.
580
- # GH#17769: Model routing is now derived from pool + routing table at runtime.
581
- # No env vars needed — pulse-wrapper.sh reads the routing table directly.
582
- _build_pulse_headless_env_xml() {
583
- # Intentionally empty — model config read from credentials.sh at runtime.
584
- printf '%s' ""
585
- return 0
586
- }
587
-
588
- # Read user-owned plist env override file and emit XML key/string pairs
589
- # for the matching label's env vars. Keys prefixed with _ are skipped
590
- # (used as comments in the JSON template).
591
- #
592
- # Args: $1=plist_label (e.g. "com.aidevops.aidevops-supervisor-pulse")
593
- # $2=override_file (absolute path; default ~/.agents/configs/plist-env-overrides.json)
594
- # $3=indent (string to prepend each line; default "\t\t")
595
- #
596
- # Returns 0 on success (including empty result when label not found).
597
- # Prints WARN to stderr and returns 0 when file is present but malformed.
598
- # Emits nothing when file is absent.
599
- _build_plist_env_overrides_xml() {
600
- local _label="$1"
601
- local _override_file="${2:-$HOME/.aidevops/agents/configs/plist-env-overrides.json}"
602
- local _indent="${3:- }"
603
-
604
- # Missing file is the normal case (user has not created the override file yet)
605
- [[ -f "$_override_file" ]] || return 0
606
-
607
- # Require jq — without it we cannot parse JSON safely
608
- if ! command -v jq >/dev/null 2>&1; then
609
- echo "[schedulers] WARN: jq not found; skipping plist-env-overrides.json injection" >&2
610
- return 0
611
- fi
612
-
613
- # Validate JSON
614
- if ! jq empty "$_override_file" 2>/dev/null; then
615
- echo "[schedulers] WARN: plist-env-overrides.json is malformed; skipping injection (file: $_override_file)" >&2
616
- return 0
617
- fi
618
-
619
- # Extract key=value pairs for the matching label; skip _ prefixed keys
620
- local _pairs
621
- _pairs=$(jq -r --arg label "$_label" '
622
- .[$label] // {} |
623
- to_entries[] |
624
- select(.key | startswith("_") | not) |
625
- "\(.key)=\(.value)"
626
- ' "$_override_file" 2>/dev/null) || return 0
627
-
628
- [[ -z "$_pairs" ]] && return 0
629
-
630
- local _line _key _val _xml_key _xml_val
631
- while IFS= read -r _line; do
632
- [[ -z "$_line" ]] && continue
633
- _key="${_line%%=*}"
634
- _val="${_line#*=}"
635
- _xml_key=$(_xml_escape "$_key")
636
- _xml_val=$(_xml_escape "$_val")
637
- printf '%s<key>%s</key>\n%s<string>%s</string>\n' \
638
- "$_indent" "$_xml_key" "$_indent" "$_xml_val"
639
- done <<<"$_pairs"
640
-
641
- return 0
642
- }
643
-
644
- # Log which env var overrides were injected from plist-env-overrides.json for a label.
645
- # Prints to stdout (setup.sh output). No-op when file absent or label not found.
646
- # Args: $1=plist_label, $2=override_file (optional)
647
- _log_plist_env_overrides() {
648
- local _label="$1"
649
- local _override_file="${2:-$HOME/.aidevops/agents/configs/plist-env-overrides.json}"
650
-
651
- [[ -f "$_override_file" ]] || return 0
652
- command -v jq >/dev/null 2>&1 || return 0
653
- jq empty "$_override_file" 2>/dev/null || return 0
654
-
655
- local _keys
656
- _keys=$(jq -r --arg label "$_label" '
657
- .[$label] // {} |
658
- keys[] |
659
- select(startswith("_") | not)
660
- ' "$_override_file" 2>/dev/null) || return 0
661
-
662
- [[ -z "$_keys" ]] && return 0
663
-
664
- local _count
665
- _count=$(echo "$_keys" | wc -l | tr -d ' ')
666
- local _keys_inline
667
- _keys_inline=$(echo "$_keys" | tr '\n' ' ' | sed 's/ $//')
668
- print_info " plist-env-overrides: injected ${_count} var(s) into ${_label}: ${_keys_inline}"
669
- return 0
670
- }
671
-
672
- # Generate the full pulse launchd plist XML content.
673
- # Args: $1=pulse_label, $2=wrapper_script, $3=opencode_bin
674
- # Prints the complete plist XML to stdout.
675
- #
676
- # StartInterval is read from supervisor.pulse_interval_seconds in
677
- # settings.json via _read_pulse_interval_seconds (default 180 — t2744).
678
- # Previously this was hardcoded as 120, meaning macOS users could not
679
- # tune the pulse cadence via settings (Linux/cron path always honoured
680
- # the setting). The hardcoding is now removed; the macOS path matches
681
- # the Linux path's behaviour.
682
- _generate_pulse_plist_content() {
683
- local pulse_label="$1"
684
- local wrapper_script="$2"
685
- local opencode_bin="$3"
686
-
687
- # XML-escape paths for safe plist embedding (prevents injection
688
- # if $HOME or paths contain &, <, > characters)
689
- local _xml_wrapper_script _xml_home _xml_opencode_bin _xml_pulse_dir _xml_path
690
- _xml_wrapper_script=$(_xml_escape "$wrapper_script")
691
- _xml_home=$(_xml_escape "$HOME")
692
- _xml_opencode_bin=$(_xml_escape "$opencode_bin")
693
- # Use neutral workspace path for PULSE_DIR so supervisor sessions
694
- # are not associated with any specific managed repo (GH#5136).
695
- _xml_pulse_dir=$(_xml_escape "${HOME}/.aidevops/.agent-workspace")
696
- _xml_path=$(_xml_escape "$(aidevops_launchd_sanitized_path "$PATH")")
697
-
698
- local _headless_xml_env
699
- _headless_xml_env=$(_build_pulse_headless_env_xml)
700
-
701
- # Resolve modern bash for ProgramArguments — launchd bypasses shebangs
702
- # when an explicit interpreter is specified. (GH#19632 / t2176)
703
- local _xml_bash_bin
704
- _xml_bash_bin=$(_xml_escape "$(_resolve_modern_bash)")
705
-
706
- # Resolve the configured pulse interval (settings.json, with default).
707
- # Already validated to [30, 3600] inside _read_pulse_interval_seconds.
708
- local _pulse_interval_sec
709
- _pulse_interval_sec=$(_read_pulse_interval_seconds)
710
-
711
- # Inject user-owned plist env overrides (GH#20563 / t2759).
712
- # Reads ~/.aidevops/agents/configs/plist-env-overrides.json when present.
713
- # Missing file or label not found → emits nothing (no-op, safe default).
714
- local _env_overrides_xml
715
- _env_overrides_xml=$(_build_plist_env_overrides_xml "$pulse_label")
716
-
717
- cat <<PLIST
718
- <?xml version="1.0" encoding="UTF-8"?>
719
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
720
- <plist version="1.0">
721
- <dict>
722
- <key>Label</key>
723
- <string>${pulse_label}</string>
724
- <key>ProgramArguments</key>
725
- <array>
726
- <string>${_xml_bash_bin}</string>
727
- <string>${_xml_wrapper_script}</string>
728
- </array>
729
- <key>StartInterval</key>
730
- <integer>${_pulse_interval_sec}</integer>
731
- <key>StandardOutPath</key>
732
- <string>${_xml_home}/.aidevops/logs/pulse-wrapper.log</string>
733
- <key>StandardErrorPath</key>
734
- <string>${_xml_home}/.aidevops/logs/pulse-wrapper.log</string>
735
- <key>EnvironmentVariables</key>
736
- <dict>
737
- <key>PATH</key>
738
- <string>${_xml_path}</string>
739
- <key>HOME</key>
740
- <string>${_xml_home}</string>
741
- <key>OPENCODE_BIN</key>
742
- <string>${_xml_opencode_bin}</string>
743
- <key>PULSE_DIR</key>
744
- <string>${_xml_pulse_dir}</string>
745
- <key>PULSE_STALE_THRESHOLD</key>
746
- <string>${PULSE_STALE_THRESHOLD_SECONDS}</string>
747
- ${_headless_xml_env}
748
- ${_env_overrides_xml} </dict>
749
- <key>SoftResourceLimits</key>
750
- <dict>
751
- <key>NumberOfFiles</key>
752
- <integer>4096</integer>
753
- </dict>
754
- <key>RunAtLoad</key>
755
- <true/>
756
- <key>KeepAlive</key>
757
- <dict>
758
- <key>SuccessfulExit</key>
759
- <false/>
760
- </dict>
761
- <key>ThrottleInterval</key>
762
- <integer>30</integer>
763
- </dict>
764
- </plist>
765
- PLIST
766
- return 0
767
- }
768
-
769
- # Install supervisor pulse via launchd (macOS)
770
- _install_pulse_launchd() {
771
- local pulse_label="$1"
772
- local wrapper_script="$2"
773
- local opencode_bin="$3"
774
- local _pulse_installed="$4"
775
- local pulse_plist="$HOME/Library/LaunchAgents/${pulse_label}.plist"
776
-
777
- # Capture plist content before touching the existing file.
778
- # This avoids the "unload old, then write fails" window that leaves a 0-byte plist.
779
- local pulse_plist_content
780
- pulse_plist_content=$(_generate_pulse_plist_content "$pulse_label" "$wrapper_script" "$opencode_bin")
781
-
782
- # Defensive: if generation produced empty content, refuse to touch the existing plist.
783
- if [[ -z "$pulse_plist_content" ]]; then
784
- print_warning "Pulse plist generation produced empty content — leaving existing plist untouched"
785
- return 1
786
- fi
787
-
788
- # Resolve interval for the user-facing message (matches what the plist contains).
789
- local _interval_sec _interval_label
790
- _interval_sec=$(_read_pulse_interval_seconds)
791
- if (( _interval_sec % 60 == 0 )); then
792
- _interval_label="$((_interval_sec / 60)) min"
793
- else
794
- _interval_label="${_interval_sec}s"
795
- fi
796
-
797
- # One-time legacy cleanup: unload and remove the old-label plist if present.
798
- # Users on stale installs may have com.aidevops.supervisor-pulse (legacy) and
799
- # com.aidevops.aidevops-supervisor-pulse (current) both loaded, causing 2x
800
- # dispatch. Only targets the hardcoded legacy path; idempotent — no-op when
801
- # the legacy file is absent.
802
- local _legacy_plist="$HOME/Library/LaunchAgents/com.aidevops.supervisor-pulse.plist"
803
- if [[ -f "$_legacy_plist" ]]; then
804
- launchctl unload "$_legacy_plist" 2>/dev/null || true
805
- rm -f "$_legacy_plist"
806
- fi
807
-
808
- # _launchd_install_if_changed handles unload-before-replace only when content
809
- # has changed, and writes atomically via tmp+rename (see setup.sh).
810
- # shell-portability: ignore next — _install_pulse_launchd is macOS-only (launchd)
811
- if _launchd_install_if_changed "$pulse_label" "$pulse_plist" "$pulse_plist_content"; then
812
- if [[ "$_pulse_installed" == "true" ]]; then
813
- print_info "Supervisor pulse updated (launchd config regenerated, every ${_interval_label})"
814
- else
815
- print_info "Supervisor pulse enabled (launchd, every ${_interval_label})"
816
- fi
817
- # Log any user-provided env var overrides that were injected (GH#20563 / t2759)
818
- _log_plist_env_overrides "$pulse_label"
819
- else
820
- print_warning "Failed to load supervisor pulse LaunchAgent"
821
- fi
822
- return 0
823
- }
824
-
825
- # Generate the pulse-watchdog launchd plist XML content.
826
- # Args: $1=label, $2=tick_script, $3=bash_bin
827
- # Prints the complete plist XML to stdout.
828
- #
829
- # The watchdog is an independent launchd job that runs every 60s and revives
830
- # pulse if it has been dead longer than (StartInterval + grace). Layered
831
- # defense alongside the pulse plist's KeepAlive=<dict><SuccessfulExit=false>
832
- # (auto-restart on crash) and StartInterval (scheduled cadence). Catches the
833
- # "clean exit + lost launchd schedule" failure mode that no other layer covers.
834
- # (t2939)
835
- _generate_pulse_watchdog_plist_content() {
836
- local watchdog_label="$1"
837
- local tick_script="$2"
838
- local bash_bin="$3"
839
-
840
- local _xml_label _xml_tick _xml_bash _xml_home _xml_path
841
- _xml_label=$(_xml_escape "$watchdog_label")
842
- _xml_tick=$(_xml_escape "$tick_script")
843
- _xml_bash=$(_xml_escape "$bash_bin")
844
- _xml_home=$(_xml_escape "$HOME")
845
- _xml_path=$(_xml_escape "$(aidevops_launchd_sanitized_path "$PATH")")
846
-
847
- cat <<PLIST
848
- <?xml version="1.0" encoding="UTF-8"?>
849
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
850
- <plist version="1.0">
851
- <dict>
852
- <key>Label</key>
853
- <string>${_xml_label}</string>
854
- <key>ProgramArguments</key>
855
- <array>
856
- <string>${_xml_bash}</string>
857
- <string>${_xml_tick}</string>
858
- </array>
859
- <key>StartInterval</key>
860
- <integer>60</integer>
861
- <key>StandardOutPath</key>
862
- <string>${_xml_home}/.aidevops/logs/pulse-watchdog-launchd.log</string>
863
- <key>StandardErrorPath</key>
864
- <string>${_xml_home}/.aidevops/logs/pulse-watchdog-launchd.log</string>
865
- <key>EnvironmentVariables</key>
866
- <dict>
867
- <key>PATH</key>
868
- <string>${_xml_path}</string>
869
- <key>HOME</key>
870
- <string>${_xml_home}</string>
871
- </dict>
872
- <key>RunAtLoad</key>
873
- <true/>
874
- <key>KeepAlive</key>
875
- <false/>
876
- <key>ThrottleInterval</key>
877
- <integer>30</integer>
878
- </dict>
879
- </plist>
880
- PLIST
881
- return 0
882
- }
883
-
884
- # Install the pulse-watchdog via launchd (macOS).
885
- # t2939: independent revival mechanism — see _generate_pulse_watchdog_plist_content
886
- # header for the layering rationale.
887
- _install_pulse_watchdog_launchd() {
888
- local watchdog_label="sh.aidevops.pulse-watchdog"
889
- local tick_script="$HOME/.aidevops/agents/scripts/pulse-watchdog-tick.sh"
890
- local watchdog_plist="$HOME/Library/LaunchAgents/${watchdog_label}.plist"
891
-
892
- # Refuse to install if the tick script is missing — the watchdog would
893
- # fire-and-fail every 60s, polluting logs without doing useful work.
894
- if [[ ! -x "$tick_script" ]]; then
895
- print_warning "Pulse watchdog tick script missing or non-executable: $tick_script"
896
- return 1
897
- fi
898
-
899
- local _xml_bash_bin
900
- _xml_bash_bin=$(_resolve_modern_bash)
901
-
902
- local watchdog_plist_content
903
- watchdog_plist_content=$(_generate_pulse_watchdog_plist_content "$watchdog_label" "$tick_script" "$_xml_bash_bin")
904
-
905
- if [[ -z "$watchdog_plist_content" ]]; then
906
- print_warning "Pulse watchdog plist generation produced empty content — skipping"
907
- return 1
908
- fi
909
-
910
- # shell-portability: ignore next — _install_pulse_watchdog_launchd is macOS-only
911
- if _launchd_install_if_changed "$watchdog_label" "$watchdog_plist" "$watchdog_plist_content"; then
912
- print_info "Pulse watchdog enabled (launchd, every 60s)"
913
- else
914
- print_warning "Failed to load pulse watchdog LaunchAgent"
915
- fi
916
- return 0
917
- }
918
-
919
- # Install the pulse-watchdog via systemd (Linux).
920
- # t2939: parallels _install_pulse_watchdog_launchd for systems with systemd --user.
921
- _install_pulse_watchdog_systemd() {
922
- local tick_script="$HOME/.aidevops/agents/scripts/pulse-watchdog-tick.sh"
923
- local watchdog_systemd="aidevops-pulse-watchdog"
924
- local watchdog_log="$HOME/.aidevops/logs/pulse-watchdog-launchd.log"
925
-
926
- if [[ ! -x "$tick_script" ]]; then
927
- print_warning "Pulse watchdog tick script missing or non-executable: $tick_script"
928
- return 1
929
- fi
930
-
931
- # Reuse the standard scheduler installer (cron-fallback aware).
932
- # StartInterval=60 maps to every-minute cron schedule.
933
- # shell-portability: ignore next — _install_scheduler_linux is Linux-only
934
- _install_scheduler_linux \
935
- "$watchdog_systemd" \
936
- "aidevops: pulse-watchdog" \
937
- "$CRON_EVERY_MINUTE" \
938
- "\"${tick_script}\"" \
939
- "60" \
940
- "$watchdog_log" \
941
- "" \
942
- "Pulse watchdog enabled (every 60s)" \
943
- "Failed to install pulse watchdog scheduler" \
944
- "true" \
945
- "false"
946
- return 0
947
- }
948
-
949
- # Setup the pulse-watchdog scheduler (parallels setup_supervisor_pulse).
950
- # t2939: layered defense — only installs when supervisor pulse is enabled,
951
- # since a watchdog without a pulse to watch is a no-op every 60s.
952
- #
953
- # Args: $1 = pulse effective state ("true"/"false")
954
- setup_pulse_watchdog() {
955
- local _pulse_effective="$1"
956
- local watchdog_label="sh.aidevops.pulse-watchdog"
957
- local watchdog_systemd="aidevops-pulse-watchdog"
958
-
959
- if [[ "$_pulse_effective" != "true" ]]; then
960
- # Pulse disabled — uninstall the watchdog if present.
961
- _uninstall_scheduler \
962
- "$(uname -s)" \
963
- "$watchdog_label" \
964
- "$watchdog_systemd" \
965
- "aidevops: pulse-watchdog" \
966
- "Pulse watchdog disabled (pulse is off)"
967
- return 0
968
- fi
969
-
970
- mkdir -p "$HOME/.aidevops/logs"
971
-
972
- if [[ "$(uname -s)" == "Darwin" ]]; then
973
- _install_pulse_watchdog_launchd
974
- else
975
- _install_pulse_watchdog_systemd
976
- fi
977
- return 0
978
- }