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.
- package/README.md +0 -1
- package/VERSION +1 -1
- package/aidevops.sh +44 -26
- package/package.json +1 -1
- package/setup.sh +25 -21
- package/aidevops-init-lib.sh +0 -1411
- package/aidevops-repos-lib.sh +0 -700
- package/aidevops-skills-plugin-lib.sh +0 -697
- package/aidevops-status-lib.sh +0 -141
- package/aidevops-update-lib.sh +0 -512
- package/aidevops-upgrade-planning-lib.sh +0 -370
- package/setup-modules/agent-deploy.sh +0 -1035
- package/setup-modules/agent-runtime.sh +0 -287
- package/setup-modules/config.sh +0 -478
- package/setup-modules/core.sh +0 -736
- package/setup-modules/mcp-setup.sh +0 -947
- package/setup-modules/migrations.sh +0 -1688
- package/setup-modules/plugins.sh +0 -728
- package/setup-modules/post-setup.sh +0 -301
- package/setup-modules/schedulers-linux.sh +0 -386
- package/setup-modules/schedulers-platform.sh +0 -1072
- package/setup-modules/schedulers-pulse.sh +0 -978
- package/setup-modules/schedulers.sh +0 -565
- package/setup-modules/shell-env.sh +0 -1240
- package/setup-modules/tool-beads.sh +0 -324
- package/setup-modules/tool-install.sh +0 -2134
|
@@ -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
|
-
}
|