aidevops 3.12.0 → 3.13.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,2212 +1,415 @@
1
1
  #!/usr/bin/env bash
2
2
  # SPDX-License-Identifier: MIT
3
3
  # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
- # Scheduler setup functions: supervisor pulse, stats wrapper, process guard,
5
- # memory pressure monitor, screen time snapshot, contribution watch,
6
- # profile README, OAuth token refresh.
4
+ # Scheduler setup orchestrator: sources sub-libraries and provides the
5
+ # monitoring-tier setup functions (stats wrapper, failure miner, process
6
+ # guard, memory pressure monitor, screen time snapshot).
7
7
  # Part of aidevops setup.sh modularization (GH#5793)
8
+ #
9
+ # Split from a 2754-line monolith (GH#21052) into three focused sub-libraries:
10
+ # - schedulers-pulse.sh (pulse resolution, supervisor, plist, watchdog)
11
+ # - schedulers-linux.sh (systemd/cron scheduler install/uninstall)
12
+ # - schedulers-platform.sh (contribution watch, complexity scan, profile
13
+ # README, token refresh, DB maintenance, repo
14
+ # health, peer productivity monitor)
15
+ #
16
+ # Functions that remain here (setup_failure_miner is >100 lines; identity-key
17
+ # rule from reference/large-file-split.md §3 requires it stays in this file):
18
+ # setup_stats_wrapper, setup_failure_miner, setup_process_guard,
19
+ # setup_memory_pressure_monitor, setup_screen_time_snapshot
8
20
 
9
- # Keep pulse workers alive long enough for opus-tier dispatches.
10
- PULSE_STALE_THRESHOLD_SECONDS=1800
11
-
12
- # Cron expression: top of every hour. Shared by stats-wrapper,
13
- # contribution-watch, and profile-readme schedulers — keep DRY so a
14
- # future cadence shift only touches one place.
15
- CRON_HOURLY="0 * * * *"
16
-
17
- # Resolve the modern bash binary path for use in launchd ProgramArguments.
18
- # Launchd bypasses the shebang when ProgramArguments specifies an explicit
19
- # interpreter, so we must resolve the path at plist generation time.
20
- # Falls back to /bin/bash if no modern bash is available (the re-exec guard
21
- # in shared-constants.sh provides defense-in-depth). (GH#19632 / t2176)
22
- _resolve_modern_bash() {
23
- local candidate
24
- for candidate in /opt/homebrew/bin/bash /usr/local/bin/bash /home/linuxbrew/.linuxbrew/bin/bash; do
25
- if [[ -x "$candidate" ]]; then
26
- # Verify it's actually bash 4+
27
- local ver
28
- ver=$("$candidate" -c 'echo "${BASH_VERSINFO[0]}"' 2>/dev/null) || continue
29
- if [[ "${ver:-0}" -ge 4 ]]; then
30
- printf '%s' "$candidate"
31
- return 0
32
- fi
33
- fi
34
- done
35
- # No modern bash found — fall back to /bin/bash. The re-exec guard in
36
- # shared-constants.sh handles this case at runtime.
37
- printf '%s' "/bin/bash"
38
- return 0
39
- }
40
-
41
- # Shell safety baseline
42
- set -Eeuo pipefail
43
- IFS=$'\n\t'
44
- # shellcheck disable=SC2154 # rc is assigned by $? in the trap string
45
- trap 'rc=$?; echo "[ERROR] ${BASH_SOURCE[0]}:${LINENO} exit $rc" >&2' ERR
46
- shopt -s inherit_errexit 2>/dev/null || true
47
-
48
- # Resolve the user's pulse consent setting from all config layers.
49
- # Priority: env var > jsonc config > legacy .conf. Prints the raw value
50
- # (may be empty if never configured, or "true"/"false").
51
- _resolve_pulse_consent() {
52
- local _pulse_user_config=""
53
-
54
- # Read explicit user consent from config.jsonc (not merged defaults).
55
- # Empty = user never configured this; "true"/"false" = explicit choice.
56
- if type _jsonc_get_raw &>/dev/null && [[ -f "${JSONC_USER:-$HOME/.config/aidevops/config.jsonc}" ]]; then
57
- _pulse_user_config=$(_jsonc_get_raw "${JSONC_USER:-$HOME/.config/aidevops/config.jsonc}" "orchestration.supervisor_pulse")
58
- fi
59
-
60
- # Also check legacy .conf user override
61
- if [[ -z "$_pulse_user_config" && -f "${FEATURE_TOGGLES_USER:-$HOME/.config/aidevops/feature-toggles.conf}" ]]; then
62
- local _legacy_val
63
- # Use awk instead of grep|tail|cut — grep exits 1 on no match, which
64
- # aborts the script under set -euo pipefail. awk always exits 0.
65
- _legacy_val=$(awk -F= '/^supervisor_pulse=/{val=$2} END{print val}' "${FEATURE_TOGGLES_USER:-$HOME/.config/aidevops/feature-toggles.conf}")
66
- if [[ -n "$_legacy_val" ]]; then
67
- _pulse_user_config="$_legacy_val"
68
- fi
69
- fi
70
-
71
- # Also check env var override (highest priority)
72
- if [[ -n "${AIDEVOPS_SUPERVISOR_PULSE:-}" ]]; then
73
- _pulse_user_config="$AIDEVOPS_SUPERVISOR_PULSE"
74
- fi
75
-
76
- printf '%s' "$_pulse_user_config"
77
- return 0
78
- }
79
-
80
- # Determine whether to install the pulse based on consent state.
81
- # Handles interactive prompting and persisting the user's choice.
82
- # Args: $1=pulse_user_config (raw), $2=wrapper_script path
83
- # Prints "true" or "false".
84
- _determine_pulse_install() {
85
- local _pulse_user_config="$1"
86
- local wrapper_script="$2"
87
- local _do_install=false
88
- local _pulse_lower
89
- _pulse_lower=$(echo "$_pulse_user_config" | tr '[:upper:]' '[:lower:]')
90
-
91
- if [[ "$_pulse_lower" == "false" ]]; then
92
- # User explicitly declined — never prompt, never install
93
- _do_install=false
94
- elif [[ "$_pulse_lower" == "true" ]]; then
95
- # User explicitly consented — install/regenerate
96
- _do_install=true
97
- elif [[ -z "$_pulse_user_config" ]]; then
98
- # No explicit config — fresh install or never configured
99
- if [[ "$NON_INTERACTIVE" == "true" ]]; then
100
- # Non-interactive: default OFF, do not install without consent
101
- _do_install=false
102
- elif [[ -f "$wrapper_script" ]]; then
103
- # Interactive: prompt with default-no
104
- # All user-facing output goes to stderr so $() captures only the result
105
- local enable_pulse=""
106
- echo "" >&2
107
- echo "The supervisor pulse enables autonomous orchestration." >&2
108
- echo "It will act under your GitHub identity and consume API credits:" >&2
109
- echo " - Dispatches AI workers to implement tasks from GitHub issues" >&2
110
- echo " - Creates PRs, merges passing PRs, files improvement issues" >&2
111
- echo " - 4-hourly strategic review (opus-tier) for queue health" >&2
112
- echo " - Circuit breaker pauses dispatch on consecutive failures" >&2
113
- echo "" >&2
114
- setup_prompt enable_pulse "Enable supervisor pulse? [y/N]: " "n"
115
- if [[ "$enable_pulse" =~ ^[Yy]$ ]]; then
116
- _do_install=true
117
- # Record explicit consent
118
- if type cmd_set &>/dev/null; then
119
- cmd_set "orchestration.supervisor_pulse" "true" || true
120
- fi
121
- else
122
- _do_install=false
123
- # Record explicit decline so we never re-prompt on updates
124
- if type cmd_set &>/dev/null; then
125
- cmd_set "orchestration.supervisor_pulse" "false" || true
126
- fi
127
- print_info "Skipped. Enable later: aidevops config set orchestration.supervisor_pulse true && ./setup.sh" >&2
128
- fi
129
- fi
130
- fi
131
-
132
- # Guard: wrapper must exist
133
- if [[ "$_do_install" == "true" && ! -f "$wrapper_script" ]]; then
134
- # Wrapper not deployed yet — skip (will install on next run after rsync)
135
- _do_install=false
136
- fi
137
-
138
- printf '%s' "$_do_install"
139
- return 0
140
- }
141
-
142
- # GH#17769: These functions are deprecated — model routing is now derived
143
- # from the OAuth pool + routing table at runtime. Kept as no-ops for one
144
- # release cycle in case external scripts call them.
145
- _resolve_headless_models_override() {
146
- printf '%s' ""
147
- return 0
148
- }
149
-
150
- _resolve_pulse_model_override() {
151
- printf '%s' ""
152
- return 0
153
- }
154
-
155
- _is_pulse_installed() {
156
- local pulse_label="$1"
157
-
158
- if _scheduler_detect_installed \
159
- "Supervisor pulse" \
160
- "$pulse_label" \
161
- "" \
162
- "pulse-wrapper" \
163
- "" \
164
- "" \
165
- "" \
166
- "aidevops-supervisor-pulse"; then
167
- return 0
168
- fi
169
-
170
- return 1
171
- }
172
-
173
- _resolve_pulse_runtime_binary() {
174
- # GH#18439 Bug 2: Persist the resolved binary path across setup.sh
175
- # invocations. aidevops-auto-update.timer runs setup.sh under systemd's
176
- # minimal PATH, so re-resolving from live `$PATH` alone yields the
177
- # legacy macOS-biased `/opt/homebrew/bin/opencode` fallback on Linux.
178
- # Reading from persistence first (populated during an interactive
179
- # setup.sh run with a rich `$PATH`) prevents the auto-update cycle
180
- # from silently degrading the service file.
181
- local _persisted_file="$HOME/.config/aidevops/scheduler-runtime-bin"
182
- local opencode_bin=""
183
-
184
- # 1. Prefer persisted path if it still points at an executable file.
185
- if [[ -f "$_persisted_file" ]]; then
186
- local _persisted
187
- _persisted=$(head -n1 "$_persisted_file" 2>/dev/null || true)
188
- if [[ -n "$_persisted" ]] && [[ -x "$_persisted" ]]; then
189
- printf '%s' "$_persisted"
190
- return 0
191
- fi
192
- fi
193
-
194
- # 2. Try runtime-registry lookup via live PATH.
195
- if type rt_list_headless &>/dev/null; then
196
- local _sched_rt_id=""
197
- local _sched_bin=""
198
- while IFS= read -r _sched_rt_id; do
199
- _sched_bin=$(rt_binary "$_sched_rt_id") || continue
200
- if [[ -n "$_sched_bin" ]] && command -v "$_sched_bin" &>/dev/null; then
201
- opencode_bin=$(command -v "$_sched_bin")
202
- break
203
- fi
204
- done < <(rt_list_headless)
205
- fi
206
-
207
- # 3. Direct PATH lookup for the default runtime.
208
- if [[ -z "$opencode_bin" ]]; then
209
- opencode_bin=$(command -v opencode 2>/dev/null || true)
210
- fi
211
-
212
- # 4. OS-aware common-install-location sweep. Used when live `$PATH` is
213
- # minimal (systemd-spawned setup.sh) and persistence hasn't been
214
- # seeded yet. Covers Homebrew (macOS + Linuxbrew), /usr/local, npm
215
- # global, Python/uv pipx-style `.local/bin`, and bun.
216
- if [[ -z "$opencode_bin" ]]; then
217
- local _candidate
218
- for _candidate in \
219
- /opt/homebrew/bin/opencode \
220
- /usr/local/bin/opencode \
221
- /home/linuxbrew/.linuxbrew/bin/opencode \
222
- "$HOME/.npm-global/bin/opencode" \
223
- "$HOME/.local/bin/opencode" \
224
- "$HOME/.bun/bin/opencode" \
225
- /opt/homebrew/bin/claude \
226
- /usr/local/bin/claude \
227
- "$HOME/.local/bin/claude"; do
228
- if [[ -x "$_candidate" ]]; then
229
- opencode_bin="$_candidate"
230
- break
231
- fi
232
- done
233
- fi
234
-
235
- # 5. Last-resort legacy fallback (preserves pre-GH#18439 behaviour so
236
- # setup.sh never exits the resolver empty-handed).
237
- [[ -z "$opencode_bin" ]] && opencode_bin="/opt/homebrew/bin/opencode"
238
-
239
- # Persist the resolved path for subsequent non-interactive invocations
240
- # (auto-update timer, cron regeneration). Only write when we actually
241
- # found a real executable — don't persist the legacy fallback.
242
- if [[ -x "$opencode_bin" ]]; then
243
- mkdir -p "$(dirname "$_persisted_file")" 2>/dev/null || true
244
- printf '%s\n' "$opencode_bin" >"$_persisted_file" 2>/dev/null || true
245
- fi
246
-
247
- printf '%s' "$opencode_bin"
248
- return 0
249
- }
250
-
251
- _build_pulse_linux_env() {
252
- # GH#17546/GH#17769: Model config is derived from pool + routing table at
253
- # runtime. No model env vars embedded in cron/systemd.
254
- local opencode_bin="${1:-}"
255
- local _pulse_env="PULSE_DIR=${HOME}/.aidevops/.agent-workspace
256
- PULSE_STALE_THRESHOLD=${PULSE_STALE_THRESHOLD_SECONDS}"
257
-
258
- # GH#18439 Bug 2: embed resolved runtime binary path so pulse-wrapper.sh
259
- # and headless-runtime-helper.sh find the correct binary under systemd's
260
- # minimal PATH (e.g. when aidevops-auto-update.timer regenerates the
261
- # service file). Mirrors the macOS launchd <OPENCODE_BIN> key.
262
- if [[ -n "$opencode_bin" ]]; then
263
- _pulse_env+=$'\n'"OPENCODE_BIN=${opencode_bin}"
264
- fi
265
-
266
- printf '%s' "$_pulse_env"
267
- return 0
268
- }
269
-
270
- # Read supervisor.pulse_interval_seconds from settings.json.
271
- # Falls back to 180 if the file is missing, the key is absent, or jq is unavailable.
272
- # Clamps to the validated range [30, 3600].
273
- # GH#18018: previously this was hardcoded as "120" in _install_supervisor_pulse.
274
- # t2744: default raised 120 → 180 to reduce GraphQL pressure (33% fewer cycles)
275
- # on multi-repo setups where per-cycle cost chronically exceeds 5000/hr.
276
- _read_pulse_interval_seconds() {
277
- local _settings_file="$HOME/.config/aidevops/settings.json"
278
- local _interval=180
279
-
280
- if command -v jq >/dev/null 2>&1 && [[ -f "$_settings_file" ]]; then
281
- local _raw
282
- _raw=$(jq -r '.supervisor.pulse_interval_seconds // empty' "$_settings_file" 2>/dev/null) || _raw=""
283
- if [[ -n "$_raw" ]] && [[ "$_raw" =~ ^[0-9]+$ ]]; then
284
- _interval="$_raw"
285
- fi
286
- fi
287
-
288
- # Clamp to validated range (mirrors settings-helper.sh validation: 30-3600)
289
- if [[ "$_interval" -lt 30 ]]; then
290
- _interval=30
291
- elif [[ "$_interval" -gt 3600 ]]; then
292
- _interval=3600
293
- fi
294
-
295
- printf '%d' "$_interval"
296
- return 0
297
- }
298
-
299
- # Convert an interval in seconds to a cron schedule expression (e.g. "*/2 * * * *").
300
- # Minimum granularity is 1 minute. Intervals that don't divide evenly into minutes
301
- # are rounded down to whole minutes with a warning.
302
- # Args: $1 = interval_seconds
303
- _seconds_to_cron_schedule() {
304
- local _interval_sec="$1"
305
- local _minutes=$((_interval_sec / 60))
306
- local _remainder=$((_interval_sec % 60))
307
-
308
- # Clamp to at least 1 minute
309
- if [[ "$_minutes" -lt 1 ]]; then
310
- _minutes=1
311
- fi
312
-
313
- # Warn if interval doesn't divide evenly into minutes
314
- if [[ "$_remainder" -ne 0 ]]; then
315
- 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
316
- fi
317
-
318
- # cron step values must be 1-59; */60 is invalid. Use @hourly for exactly 60 min,
319
- # clamp anything above 59 to 59 (the _read_pulse_interval_seconds cap is 3600s=60min).
320
- if [[ "$_minutes" -ge 60 ]]; then
321
- printf '@hourly'
322
- else
323
- printf '*/%d * * * *' "$_minutes"
324
- fi
325
- return 0
326
- }
327
-
328
- _install_supervisor_pulse() {
329
- local _os="$1"
330
- local pulse_label="$2"
331
- local wrapper_script="$3"
332
- local opencode_bin="$4"
333
- local _pulse_installed="$5"
334
-
335
- mkdir -p "$HOME/.aidevops/logs"
336
-
337
- if [[ "$_os" == "Darwin" ]]; then
338
- _install_pulse_launchd "$pulse_label" "$wrapper_script" "$opencode_bin" "$_pulse_installed"
339
- return 0
340
- fi
341
-
342
- # GH#18018: read user-configured interval instead of hardcoding 120s / */2 cron
343
- local _pulse_interval_sec
344
- _pulse_interval_sec=$(_read_pulse_interval_seconds)
345
- local _pulse_cron_schedule
346
- _pulse_cron_schedule=$(_seconds_to_cron_schedule "$_pulse_interval_sec")
347
- # Build a human-readable interval label: show minutes for exact multiples of 60, seconds otherwise
348
- local _pulse_interval_label
349
- if (( _pulse_interval_sec % 60 == 0 )); then
350
- _pulse_interval_label="$((_pulse_interval_sec / 60)) min"
351
- else
352
- _pulse_interval_label="${_pulse_interval_sec}s"
353
- fi
354
-
355
- local _pulse_timeout_sec=$((PULSE_STALE_THRESHOLD_SECONDS + 60))
356
- local _pulse_env=""
357
- # GH#18439 Bug 2: thread resolved runtime binary path through to the
358
- # Linux env builder so OPENCODE_BIN is embedded in the systemd service
359
- # file (parity with the macOS launchd plist at line 415).
360
- _pulse_env=$(_build_pulse_linux_env "$opencode_bin")
361
- _install_scheduler_linux \
362
- "aidevops-supervisor-pulse" \
363
- "aidevops: supervisor-pulse" \
364
- "${_pulse_cron_schedule}" \
365
- "\"${wrapper_script}\"" \
366
- "${_pulse_interval_sec}" \
367
- "$HOME/.aidevops/logs/pulse-wrapper.log" \
368
- "$_pulse_env" \
369
- "Supervisor pulse enabled (every ${_pulse_interval_label})" \
370
- "Failed to install supervisor pulse scheduler. See runners.md for manual setup." \
371
- "true" \
372
- "false" \
373
- "" \
374
- "${_pulse_timeout_sec}"
375
- return 0
376
- }
377
-
378
- # Setup the supervisor pulse scheduler (consent-gated autonomous orchestration).
379
- # Uses pulse-wrapper.sh which handles dedup, orphan cleanup, and RAM-based concurrency.
380
- # macOS: launchd plist invoking wrapper | Linux: cron entry invoking wrapper
381
- # The plist is ALWAYS regenerated on setup.sh to pick up config changes (env vars,
382
- # thresholds). Only the first-install prompt is gated on consent state.
383
- #######################################
384
- # t2119: Record the schedulers.sh template hash to the shared state
385
- # directory. auto-update-helper.sh's check_launchd_plist_drift compares
386
- # this against the current hash on every update cycle — whenever
387
- # schedulers.sh changes without a VERSION bump (PR #19079 scenario),
388
- # drift is detected and setup.sh --non-interactive is re-run to
389
- # regenerate the installed plists.
390
- #
391
- # Called from setup_supervisor_pulse unconditionally so the hash is
392
- # kept current on every setup.sh run, whether pulse is installed,
393
- # upgraded, or disabled. Whole-file hash is the simplest signal that
394
- # any plist-generating change has occurred.
395
- #######################################
396
- _schedulers_record_template_hash() {
397
- local state_dir="$HOME/.aidevops/.agent-workspace/tmp"
398
- mkdir -p "$state_dir" 2>/dev/null || return 0
399
- local hash_file="$state_dir/schedulers-template-hash.state"
400
- local schedulers_src="${BASH_SOURCE[0]:-}"
401
- [[ -f "$schedulers_src" ]] || return 0
402
- if command -v shasum >/dev/null 2>&1; then
403
- shasum -a 256 "$schedulers_src" 2>/dev/null | awk '{print $1}' >"$hash_file" 2>/dev/null || true
404
- elif command -v sha256sum >/dev/null 2>&1; then
405
- sha256sum "$schedulers_src" 2>/dev/null | awk '{print $1}' >"$hash_file" 2>/dev/null || true
406
- fi
407
- return 0
408
- }
409
-
410
- setup_supervisor_pulse() {
411
- local _os="$1"
412
-
413
- # Record template hash so auto-update can detect drift between
414
- # schedulers.sh and the installed plists on macOS (t2119).
415
- _schedulers_record_template_hash
416
-
417
- # Ensure crontab has a global PATH= line (Linux only; macOS uses launchd env).
418
- # Must run before any cron entries are installed so they inherit the PATH.
419
- if [[ "$_os" != "Darwin" ]]; then
420
- _ensure_cron_path
421
- fi
422
-
423
- # Consent model (GH#2926):
424
- # - Default OFF: supervisor_pulse defaults to false in all config layers
425
- # - Explicit consent required: user must type "y" (prompt defaults to [y/N])
426
- # - Consent persisted: written to config.jsonc so it survives updates
427
- # - Never silently re-enabled: if config says false, skip entirely
428
- # - Non-interactive: only installs if config explicitly says true
429
- local wrapper_script="$HOME/.aidevops/agents/scripts/pulse-wrapper.sh"
430
- local pulse_label="com.aidevops.aidevops-supervisor-pulse"
431
-
432
- local _pulse_user_config
433
- _pulse_user_config=$(_resolve_pulse_consent)
434
-
435
- local _do_install
436
- _do_install=$(_determine_pulse_install "$_pulse_user_config" "$wrapper_script")
437
-
438
- local _pulse_lower
439
- _pulse_lower=$(echo "$_pulse_user_config" | tr '[:upper:]' '[:lower:]')
440
-
441
- # Detect if pulse is already installed (for upgrade messaging)
442
- # Uses shared helper to check launchd, cron, and systemd (GH#17381)
443
- local _pulse_installed=false
444
- if _is_pulse_installed "$pulse_label"; then
445
- _pulse_installed=true
446
- fi
447
-
448
- # Detect dispatch backend binary location (t1665.5 — registry-driven)
449
- local opencode_bin=""
450
- opencode_bin=$(_resolve_pulse_runtime_binary)
451
-
452
- if [[ "$_do_install" == "true" ]]; then
453
- _install_supervisor_pulse "$_os" "$pulse_label" "$wrapper_script" "$opencode_bin" "$_pulse_installed"
454
- elif [[ "$_pulse_lower" == "false" && "$_pulse_installed" == "true" ]]; then
455
- # User explicitly disabled but pulse is still installed — clean up
456
- _uninstall_pulse "$_os" "$pulse_label"
457
- fi
458
-
459
- # Export effective pulse state for setup_stats_wrapper.
460
- # Use the actual install decision (_do_install), not just the consent string,
461
- # so stats wrapper tracks the real scheduler state (e.g., wrapper missing → false).
462
- PULSE_CONSENT_LOWER="$_pulse_lower"
463
- if [[ "$_do_install" == "true" ]]; then
464
- PULSE_ENABLED="true"
465
- else
466
- PULSE_ENABLED="false"
467
- fi
468
- return 0
469
- }
470
-
471
- # Clean up old/legacy pulse launchd plists before reinstalling.
472
- # Args: $1=pulse_label, $2=pulse_plist path
473
- _cleanup_old_pulse_plists() {
474
- local pulse_label="$1"
475
- local pulse_plist="$2"
476
-
477
- # Unload old plist if upgrading
478
- if _launchd_has_agent "$pulse_label"; then
479
- launchctl unload "$pulse_plist" || true
480
- pkill -f 'Supervisor Pulse' 2>/dev/null || true
481
- fi
482
-
483
- # Also clean up old label if present
484
- local old_plist="$HOME/Library/LaunchAgents/com.aidevops.supervisor-pulse.plist"
485
- if [[ -f "$old_plist" ]]; then
486
- launchctl unload "$old_plist" || true
487
- rm -f "$old_plist"
488
- fi
489
- return 0
490
- }
491
-
492
- # Build XML environment variable fragment for headless model overrides.
493
- # GH#17546: Model config was removed from plist embedding.
494
- # GH#17769: Model routing is now derived from pool + routing table at runtime.
495
- # No env vars needed — pulse-wrapper.sh reads the routing table directly.
496
- _build_pulse_headless_env_xml() {
497
- # Intentionally empty — model config read from credentials.sh at runtime.
498
- printf '%s' ""
499
- return 0
500
- }
501
-
502
- # Read user-owned plist env override file and emit XML key/string pairs
503
- # for the matching label's env vars. Keys prefixed with _ are skipped
504
- # (used as comments in the JSON template).
505
- #
506
- # Args: $1=plist_label (e.g. "com.aidevops.aidevops-supervisor-pulse")
507
- # $2=override_file (absolute path; default ~/.agents/configs/plist-env-overrides.json)
508
- # $3=indent (string to prepend each line; default "\t\t")
509
- #
510
- # Returns 0 on success (including empty result when label not found).
511
- # Prints WARN to stderr and returns 0 when file is present but malformed.
512
- # Emits nothing when file is absent.
513
- _build_plist_env_overrides_xml() {
514
- local _label="$1"
515
- local _override_file="${2:-$HOME/.aidevops/agents/configs/plist-env-overrides.json}"
516
- local _indent="${3:- }"
517
-
518
- # Missing file is the normal case (user has not created the override file yet)
519
- [[ -f "$_override_file" ]] || return 0
520
-
521
- # Require jq — without it we cannot parse JSON safely
522
- if ! command -v jq >/dev/null 2>&1; then
523
- echo "[schedulers] WARN: jq not found; skipping plist-env-overrides.json injection" >&2
524
- return 0
525
- fi
526
-
527
- # Validate JSON
528
- if ! jq empty "$_override_file" 2>/dev/null; then
529
- echo "[schedulers] WARN: plist-env-overrides.json is malformed; skipping injection (file: $_override_file)" >&2
530
- return 0
531
- fi
532
-
533
- # Extract key=value pairs for the matching label; skip _ prefixed keys
534
- local _pairs
535
- _pairs=$(jq -r --arg label "$_label" '
536
- .[$label] // {} |
537
- to_entries[] |
538
- select(.key | startswith("_") | not) |
539
- "\(.key)=\(.value)"
540
- ' "$_override_file" 2>/dev/null) || return 0
541
-
542
- [[ -z "$_pairs" ]] && return 0
543
-
544
- local _line _key _val _xml_key _xml_val
545
- while IFS= read -r _line; do
546
- [[ -z "$_line" ]] && continue
547
- _key="${_line%%=*}"
548
- _val="${_line#*=}"
549
- _xml_key=$(_xml_escape "$_key")
550
- _xml_val=$(_xml_escape "$_val")
551
- printf '%s<key>%s</key>\n%s<string>%s</string>\n' \
552
- "$_indent" "$_xml_key" "$_indent" "$_xml_val"
553
- done <<<"$_pairs"
554
-
555
- return 0
556
- }
557
-
558
- # Log which env var overrides were injected from plist-env-overrides.json for a label.
559
- # Prints to stdout (setup.sh output). No-op when file absent or label not found.
560
- # Args: $1=plist_label, $2=override_file (optional)
561
- _log_plist_env_overrides() {
562
- local _label="$1"
563
- local _override_file="${2:-$HOME/.aidevops/agents/configs/plist-env-overrides.json}"
564
-
565
- [[ -f "$_override_file" ]] || return 0
566
- command -v jq >/dev/null 2>&1 || return 0
567
- jq empty "$_override_file" 2>/dev/null || return 0
568
-
569
- local _keys
570
- _keys=$(jq -r --arg label "$_label" '
571
- .[$label] // {} |
572
- keys[] |
573
- select(startswith("_") | not)
574
- ' "$_override_file" 2>/dev/null) || return 0
575
-
576
- [[ -z "$_keys" ]] && return 0
577
-
578
- local _count
579
- _count=$(echo "$_keys" | wc -l | tr -d ' ')
580
- local _keys_inline
581
- _keys_inline=$(echo "$_keys" | tr '\n' ' ' | sed 's/ $//')
582
- print_info " plist-env-overrides: injected ${_count} var(s) into ${_label}: ${_keys_inline}"
583
- return 0
584
- }
585
-
586
- # Generate the full pulse launchd plist XML content.
587
- # Args: $1=pulse_label, $2=wrapper_script, $3=opencode_bin
588
- # Prints the complete plist XML to stdout.
589
- #
590
- # StartInterval is read from supervisor.pulse_interval_seconds in
591
- # settings.json via _read_pulse_interval_seconds (default 180 — t2744).
592
- # Previously this was hardcoded as 120, meaning macOS users could not
593
- # tune the pulse cadence via settings (Linux/cron path always honoured
594
- # the setting). The hardcoding is now removed; the macOS path matches
595
- # the Linux path's behaviour.
596
- _generate_pulse_plist_content() {
597
- local pulse_label="$1"
598
- local wrapper_script="$2"
599
- local opencode_bin="$3"
600
-
601
- # XML-escape paths for safe plist embedding (prevents injection
602
- # if $HOME or paths contain &, <, > characters)
603
- local _xml_wrapper_script _xml_home _xml_opencode_bin _xml_pulse_dir _xml_path
604
- _xml_wrapper_script=$(_xml_escape "$wrapper_script")
605
- _xml_home=$(_xml_escape "$HOME")
606
- _xml_opencode_bin=$(_xml_escape "$opencode_bin")
607
- # Use neutral workspace path for PULSE_DIR so supervisor sessions
608
- # are not associated with any specific managed repo (GH#5136).
609
- _xml_pulse_dir=$(_xml_escape "${HOME}/.aidevops/.agent-workspace")
610
- _xml_path=$(_xml_escape "$PATH")
611
-
612
- local _headless_xml_env
613
- _headless_xml_env=$(_build_pulse_headless_env_xml)
614
-
615
- # Resolve modern bash for ProgramArguments — launchd bypasses shebangs
616
- # when an explicit interpreter is specified. (GH#19632 / t2176)
617
- local _xml_bash_bin
618
- _xml_bash_bin=$(_xml_escape "$(_resolve_modern_bash)")
619
-
620
- # Resolve the configured pulse interval (settings.json, with default).
621
- # Already validated to [30, 3600] inside _read_pulse_interval_seconds.
622
- local _pulse_interval_sec
623
- _pulse_interval_sec=$(_read_pulse_interval_seconds)
624
-
625
- # Inject user-owned plist env overrides (GH#20563 / t2759).
626
- # Reads ~/.aidevops/agents/configs/plist-env-overrides.json when present.
627
- # Missing file or label not found → emits nothing (no-op, safe default).
628
- local _env_overrides_xml
629
- _env_overrides_xml=$(_build_plist_env_overrides_xml "$pulse_label")
630
-
631
- cat <<PLIST
632
- <?xml version="1.0" encoding="UTF-8"?>
633
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
634
- <plist version="1.0">
635
- <dict>
636
- <key>Label</key>
637
- <string>${pulse_label}</string>
638
- <key>ProgramArguments</key>
639
- <array>
640
- <string>${_xml_bash_bin}</string>
641
- <string>${_xml_wrapper_script}</string>
642
- </array>
643
- <key>StartInterval</key>
644
- <integer>${_pulse_interval_sec}</integer>
645
- <key>StandardOutPath</key>
646
- <string>${_xml_home}/.aidevops/logs/pulse-wrapper.log</string>
647
- <key>StandardErrorPath</key>
648
- <string>${_xml_home}/.aidevops/logs/pulse-wrapper.log</string>
649
- <key>EnvironmentVariables</key>
650
- <dict>
651
- <key>PATH</key>
652
- <string>${_xml_path}</string>
653
- <key>HOME</key>
654
- <string>${_xml_home}</string>
655
- <key>OPENCODE_BIN</key>
656
- <string>${_xml_opencode_bin}</string>
657
- <key>PULSE_DIR</key>
658
- <string>${_xml_pulse_dir}</string>
659
- <key>PULSE_STALE_THRESHOLD</key>
660
- <string>${PULSE_STALE_THRESHOLD_SECONDS}</string>
661
- ${_headless_xml_env}
662
- ${_env_overrides_xml} </dict>
663
- <key>SoftResourceLimits</key>
664
- <dict>
665
- <key>NumberOfFiles</key>
666
- <integer>4096</integer>
667
- </dict>
668
- <key>RunAtLoad</key>
669
- <true/>
670
- <key>KeepAlive</key>
671
- <false/>
672
- </dict>
673
- </plist>
674
- PLIST
675
- return 0
676
- }
677
-
678
- # Install supervisor pulse via launchd (macOS)
679
- _install_pulse_launchd() {
680
- local pulse_label="$1"
681
- local wrapper_script="$2"
682
- local opencode_bin="$3"
683
- local _pulse_installed="$4"
684
- local pulse_plist="$HOME/Library/LaunchAgents/${pulse_label}.plist"
685
-
686
- # Capture plist content before touching the existing file.
687
- # This avoids the "unload old, then write fails" window that leaves a 0-byte plist.
688
- local pulse_plist_content
689
- pulse_plist_content=$(_generate_pulse_plist_content "$pulse_label" "$wrapper_script" "$opencode_bin")
690
-
691
- # Defensive: if generation produced empty content, refuse to touch the existing plist.
692
- if [[ -z "$pulse_plist_content" ]]; then
693
- print_warning "Pulse plist generation produced empty content — leaving existing plist untouched"
694
- return 1
695
- fi
696
-
697
- # Resolve interval for the user-facing message (matches what the plist contains).
698
- local _interval_sec _interval_label
699
- _interval_sec=$(_read_pulse_interval_seconds)
700
- if (( _interval_sec % 60 == 0 )); then
701
- _interval_label="$((_interval_sec / 60)) min"
702
- else
703
- _interval_label="${_interval_sec}s"
704
- fi
705
-
706
- # One-time legacy cleanup: unload and remove the old-label plist if present.
707
- # Users on stale installs may have com.aidevops.supervisor-pulse (legacy) and
708
- # com.aidevops.aidevops-supervisor-pulse (current) both loaded, causing 2x
709
- # dispatch. Only targets the hardcoded legacy path; idempotent — no-op when
710
- # the legacy file is absent.
711
- local _legacy_plist="$HOME/Library/LaunchAgents/com.aidevops.supervisor-pulse.plist"
712
- if [[ -f "$_legacy_plist" ]]; then
713
- launchctl unload "$_legacy_plist" 2>/dev/null || true
714
- rm -f "$_legacy_plist"
715
- fi
716
-
717
- # _launchd_install_if_changed handles unload-before-replace only when content
718
- # has changed, and writes atomically via tmp+rename (see setup.sh).
719
- # shell-portability: ignore next — _install_pulse_launchd is macOS-only (launchd)
720
- if _launchd_install_if_changed "$pulse_label" "$pulse_plist" "$pulse_plist_content"; then
721
- if [[ "$_pulse_installed" == "true" ]]; then
722
- print_info "Supervisor pulse updated (launchd config regenerated, every ${_interval_label})"
723
- else
724
- print_info "Supervisor pulse enabled (launchd, every ${_interval_label})"
725
- fi
726
- # Log any user-provided env var overrides that were injected (GH#20563 / t2759)
727
- _log_plist_env_overrides "$pulse_label"
728
- else
729
- print_warning "Failed to load supervisor pulse LaunchAgent"
730
- fi
731
- return 0
732
- }
733
-
734
- # Check if systemd user services are available on this Linux system.
735
- # Returns 0 if systemd --user is functional, 1 otherwise.
736
- _systemd_user_available() {
737
- command -v systemctl >/dev/null 2>&1 || return 1
738
- systemctl --user status >/dev/null 2>&1 || return 1
739
- return 0
740
- }
741
-
742
- # Escape a value for safe embedding in a systemd unit Environment= or ExecStart=
743
- # directive. systemd interprets % as specifiers (%h, %n, %t, etc.) and spaces
744
- # as key-value separators. This helper:
745
- # 1. Escapes \ → \\ (must be first to avoid double-escaping)
746
- # 2. Doubles % → %% (escape specifiers)
747
- # 3. Escapes embedded " → \"
748
- # 4. Wraps the result in "..." (handles spaces and other shell metacharacters)
749
- # Usage: escaped=$(_systemd_escape "$value")
750
- #
751
- # WARNING: Do NOT use for StandardOutput= or StandardError= directives.
752
- # systemd does not strip outer quotes from those values — "append:/path" is
753
- # treated as a literal filename with quote characters, failing silently.
754
- # Use bare values for StandardOutput=/StandardError=:
755
- # StandardOutput=append:${log_file} ← correct
756
- # StandardOutput=$(_systemd_escape "append:${log_file}") ← WRONG
757
- _systemd_escape() {
758
- local _val="$1"
759
- # Step 1: escape backslashes
760
- _val="${_val//\\/\\\\}"
761
- # Step 2: escape % specifiers
762
- _val="${_val//%/%%}"
763
- # Step 3: escape embedded double-quotes
764
- _val="${_val//\"/\\\"}"
765
- # Step 4: wrap in double-quotes
766
- printf '"%s"' "$_val"
767
- return 0
768
- }
769
-
770
- # Build systemd Environment= lines from newline-separated KEY=VALUE pairs.
771
- # Always appends HOME and PATH for parity with launchd and cron execution.
772
- _scheduler_systemd_env_lines() {
773
- local env_vars="$1"
774
- local _env_lines=""
775
-
776
- if [[ -n "$env_vars" ]]; then
777
- while IFS= read -r _kv; do
778
- [[ -z "$_kv" ]] && continue
779
- local _key="${_kv%%=*}"
780
- local _raw_val="${_kv#*=}"
781
- local _escaped_val
782
- _escaped_val=$(_systemd_escape "$_raw_val")
783
- _env_lines+="Environment=${_key}=${_escaped_val}"$'\n'
784
- done <<<"$env_vars"
785
- fi
786
-
787
- _env_lines+="Environment=HOME=$(_systemd_escape "$HOME")"$'\n'
788
- _env_lines+="Environment=PATH=$(_systemd_escape "$PATH")"$'\n'
789
- printf '%s' "$_env_lines"
790
- return 0
791
- }
792
-
793
- # Build inline cron environment assignments from newline-separated KEY=VALUE pairs.
794
- _scheduler_cron_env_prefix() {
795
- local env_vars="$1"
796
- local _env_prefix=""
797
-
798
- if [[ -n "$env_vars" ]]; then
799
- while IFS= read -r _kv; do
800
- [[ -z "$_kv" ]] && continue
801
- local _key="${_kv%%=*}"
802
- local _raw_val="${_kv#*=}"
803
- local _escaped_val
804
- _escaped_val=$(_cron_escape "$_raw_val")
805
- _env_prefix+="${_key}=${_escaped_val} "
806
- done <<<"$env_vars"
807
- fi
808
-
809
- printf '%s' "$_env_prefix"
810
- return 0
811
- }
812
-
813
- # Install a generic scheduler via systemd user timer (Linux with systemd).
814
- # Args:
815
- # $1 = service_name (e.g. "aidevops-stats-wrapper")
816
- # $2 = exec_command (shell command run via /bin/bash -lc)
817
- # $3 = interval_sec (OnUnitActiveSec interval in seconds; may be empty for calendar-only)
818
- # $4 = log_file (absolute path to log file)
819
- # $5 = env_vars (newline-separated KEY=VALUE pairs, may be empty)
820
- # $6 = run_at_load ("true" or "false")
821
- # $7 = low_priority ("true" or "false")
822
- # $8 = on_calendar (optional systemd OnCalendar spec)
823
- # $9 = timeout_sec (optional TimeoutStartSec; defaults to interval_sec)
824
- # Returns 0 on success, 1 if systemd enable fails (caller should fall back to cron).
825
- _install_scheduler_systemd() {
826
- local service_name="$1"
827
- local exec_command="$2"
828
- local interval_sec="$3"
829
- local log_file="$4"
830
- local env_vars="$5"
831
- local run_at_load="$6"
832
- local low_priority="$7"
833
- local on_calendar="$8"
834
- local timeout_sec="$9"
835
- local service_dir="$HOME/.config/systemd/user"
836
- local service_file="${service_dir}/${service_name}.service"
837
- local timer_file="${service_dir}/${service_name}.timer"
838
-
839
- mkdir -p "$service_dir"
840
-
841
- # GH#18439 Bug 1: command substitution strips trailing newlines, which
842
- # would run the final Environment=PATH=... into the following
843
- # StandardOutput=... directive on the same line. Use a sentinel ('x')
844
- # to preserve the trailing newline that _scheduler_systemd_env_lines
845
- # always emits.
846
- local _env_lines
847
- _env_lines=$(
848
- _scheduler_systemd_env_lines "$env_vars"
849
- printf 'x'
850
- )
851
- _env_lines="${_env_lines%x}"
852
-
853
- if [[ -z "$timeout_sec" ]]; then
854
- timeout_sec="$interval_sec"
855
- fi
856
- if [[ -z "$timeout_sec" ]]; then
857
- timeout_sec="3600"
858
- fi
859
-
860
- local _service_extra=""
861
- if [[ "$low_priority" == "true" ]]; then
862
- _service_extra+="Nice=10"$'\n'
863
- _service_extra+="IOSchedulingClass=idle"$'\n'
864
- fi
865
-
866
- printf '%s' "[Unit]
867
- Description=aidevops ${service_name}
868
- After=network.target
869
-
870
- [Service]
871
- Type=oneshot
872
- KillMode=process
873
- ExecStart=/bin/bash -lc $(_systemd_escape "$exec_command")
874
- TimeoutStartSec=${timeout_sec}
875
- ${_service_extra}${_env_lines}StandardOutput=append:${log_file}
876
- StandardError=append:${log_file}
877
- " >"$service_file"
878
-
879
- local _timer_lines=""
880
- if [[ "$run_at_load" == "true" ]]; then
881
- _timer_lines+="OnActiveSec=10s"$'\n'
882
- fi
883
- if [[ -n "$interval_sec" ]]; then
884
- _timer_lines+="OnBootSec=${interval_sec}"$'\n'
885
- _timer_lines+="OnUnitActiveSec=${interval_sec}"$'\n'
886
- fi
887
- if [[ -n "$on_calendar" ]]; then
888
- _timer_lines+="OnCalendar=${on_calendar}"$'\n'
889
- fi
890
-
891
- printf '%s' "[Unit]
892
- Description=aidevops ${service_name} Timer
893
-
894
- [Timer]
895
- ${_timer_lines}Persistent=true
896
-
897
- [Install]
898
- WantedBy=timers.target
899
- " >"$timer_file"
900
-
901
- systemctl --user daemon-reload 2>/dev/null || true
902
- if systemctl --user enable --now "${service_name}.timer" 2>/dev/null; then
903
- return 0
904
- fi
905
- return 1
906
- }
907
-
908
- # Install a generic cron entry.
909
- # Args: $1=cron_tag, $2=cron_schedule, $3=exec_command, $4=log_file, $5=env_vars
910
- _install_scheduler_cron() {
911
- local cron_tag="$1"
912
- local cron_schedule="$2"
913
- local exec_command="$3"
914
- local log_file="$4"
915
- local env_vars="$5"
916
- local _cron_exec
917
- local _cron_log
918
- local _env_prefix
919
-
920
- _env_prefix=$(_scheduler_cron_env_prefix "$env_vars")
921
- _cron_exec=$(_cron_escape "$exec_command")
922
- _cron_log=$(_cron_escape "$log_file")
923
-
924
- (
925
- crontab -l 2>/dev/null | grep -vF "${cron_tag}" || true
926
- echo "${cron_schedule} ${_env_prefix}/bin/bash -lc ${_cron_exec} >> ${_cron_log} 2>&1 # ${cron_tag}"
927
- ) | crontab - 2>/dev/null || true
928
- return 0
929
- }
930
-
931
- # Dispatcher: install a scheduler on Linux, preferring systemd over cron.
932
- # Args:
933
- # $1 = service_name (systemd service name, e.g. "aidevops-stats-wrapper")
934
- # $2 = cron_tag (comment tag for cron line, e.g. "aidevops: stats-wrapper")
935
- # $3 = cron_schedule (cron schedule expression, e.g. "*/15 * * * *")
936
- # $4 = exec_command (shell command run via /bin/bash -lc)
937
- # $5 = interval_sec (systemd OnUnitActiveSec in seconds; may be empty for calendar-only)
938
- # $6 = log_file (absolute path to log file)
939
- # $7 = env_vars (newline-separated KEY=VALUE pairs for systemd/cron, may be empty)
940
- # $8 = success_msg (message to print on success)
941
- # $9 = fail_msg (message to print on failure)
942
- # $10 = run_at_load ("true" or "false")
943
- # $11 = low_priority ("true" or "false")
944
- # $12 = on_calendar (optional systemd OnCalendar spec)
945
- # $13 = timeout_sec (optional TimeoutStartSec)
946
- # Returns 0 always (failures are warnings, not fatal).
947
- _install_scheduler_linux() {
948
- local service_name="$1"
949
- local cron_tag="$2"
950
- local cron_schedule="$3"
951
- local exec_command="$4"
952
- local interval_sec="$5"
953
- local log_file="$6"
954
- local env_vars="$7"
955
- local success_msg="$8"
956
- local fail_msg="$9"
957
- local run_at_load="${10}"
958
- local low_priority="${11}"
959
- local on_calendar="${12:-}"
960
- local timeout_sec="${13:-}"
961
-
962
- if _systemd_user_available; then
963
- if _install_scheduler_systemd \
964
- "$service_name" \
965
- "$exec_command" \
966
- "$interval_sec" \
967
- "$log_file" \
968
- "$env_vars" \
969
- "$run_at_load" \
970
- "$low_priority" \
971
- "$on_calendar" \
972
- "$timeout_sec"; then
973
- print_info "${success_msg} (systemd user timer)"
974
- # After systemd install succeeds, remove any pre-existing cron entry
975
- # to prevent dual-execution (GH#17695 Finding A)
976
- if command -v crontab >/dev/null 2>&1; then
977
- local current_cron
978
- current_cron=$(crontab -l 2>/dev/null) || current_cron=""
979
- if [[ -n "$current_cron" ]] && echo "$current_cron" | grep -qF "$cron_tag"; then
980
- echo "$current_cron" | grep -vF "$cron_tag" | crontab -
981
- echo "[schedulers] Removed pre-existing cron entry for $cron_tag (migrated to systemd)"
982
- fi
983
- fi
984
- else
985
- print_warning "systemd enable failed for ${service_name} — falling back to cron"
986
- _install_scheduler_cron "$cron_tag" "$cron_schedule" "$exec_command" "$log_file" "$env_vars"
987
- if crontab -l 2>/dev/null | grep -qF "${cron_tag}" 2>/dev/null; then
988
- print_info "${success_msg} (cron fallback)"
989
- else
990
- print_warning "${fail_msg}"
991
- fi
992
- fi
993
- else
994
- _install_scheduler_cron "$cron_tag" "$cron_schedule" "$exec_command" "$log_file" "$env_vars"
995
- if crontab -l 2>/dev/null | grep -qF "${cron_tag}" 2>/dev/null; then
996
- print_info "${success_msg} (cron)"
997
- else
998
- print_warning "${fail_msg}"
999
- fi
1000
- fi
1001
- return 0
1002
- }
1003
-
1004
- # Uninstall a scheduler across all backends (launchd/systemd/cron).
1005
- # Args:
1006
- # $1 = os (output of uname -s)
1007
- # $2 = launchd_label (e.g. "sh.aidevops.stats-wrapper")
1008
- # $3 = systemd_name (e.g. "aidevops-stats-wrapper")
1009
- # $4 = cron_tag (grep pattern for cron line, e.g. "aidevops: stats-wrapper")
1010
- # $5 = success_msg (message to print on removal)
1011
- # Returns 0 always.
1012
- _uninstall_scheduler() {
1013
- local _os="$1"
1014
- local launchd_label="$2"
1015
- local systemd_name="$3"
1016
- local cron_tag="$4"
1017
- local success_msg="$5"
1018
-
1019
- if [[ "$_os" == "Darwin" ]]; then
1020
- local _plist="$HOME/Library/LaunchAgents/${launchd_label}.plist"
1021
- if _launchd_has_agent "$launchd_label"; then
1022
- launchctl unload "$_plist" 2>/dev/null || true
1023
- rm -f "$_plist"
1024
- print_info "${success_msg} (launchd agent removed)"
1025
- fi
1026
- else
1027
- # Check and remove from ALL backends sequentially, not just the first
1028
- # match. Prevents orphan entries when migrating between systemd and cron
1029
- # (GH#17695 Finding A).
1030
- if _systemd_user_available && systemctl --user is-enabled "${systemd_name}.timer" >/dev/null 2>&1; then
1031
- systemctl --user disable --now "${systemd_name}.timer" 2>/dev/null || true
1032
- rm -f "$HOME/.config/systemd/user/${systemd_name}.service"
1033
- rm -f "$HOME/.config/systemd/user/${systemd_name}.timer"
1034
- systemctl --user daemon-reload 2>/dev/null || true
1035
- print_info "${success_msg} (systemd timer removed)"
1036
- fi
1037
- if command -v crontab >/dev/null 2>&1; then
1038
- local current_cron
1039
- current_cron=$(crontab -l 2>/dev/null) || current_cron=""
1040
- if [[ -n "$current_cron" ]] && echo "$current_cron" | grep -qF "${cron_tag}"; then
1041
- echo "$current_cron" | grep -vF "${cron_tag}" | crontab - 2>/dev/null || true
1042
- print_info "${success_msg} (cron entry removed)"
1043
- fi
1044
- fi
1045
- fi
1046
- return 0
1047
- }
1048
-
1049
- # Uninstall supervisor pulse (user explicitly disabled)
1050
- _uninstall_pulse() {
1051
- local _os="$1"
1052
- local pulse_label="$2"
1053
- if [[ "$_os" == "Darwin" ]]; then
1054
- local pulse_plist="$HOME/Library/LaunchAgents/${pulse_label}.plist"
1055
- if _launchd_has_agent "$pulse_label"; then
1056
- launchctl unload "$pulse_plist" || true
1057
- rm -f "$pulse_plist"
1058
- pkill -f 'Supervisor Pulse' 2>/dev/null || true
1059
- print_info "Supervisor pulse disabled (launchd agent removed per config)"
1060
- fi
1061
- elif _systemd_user_available; then
1062
- local service_name="aidevops-supervisor-pulse"
1063
- if systemctl --user is-enabled "${service_name}.timer" >/dev/null 2>&1; then
1064
- systemctl --user disable --now "${service_name}.timer" 2>/dev/null || true
1065
- rm -f "$HOME/.config/systemd/user/${service_name}.service"
1066
- rm -f "$HOME/.config/systemd/user/${service_name}.timer"
1067
- systemctl --user daemon-reload 2>/dev/null || true
1068
- print_info "Supervisor pulse disabled (systemd timer removed per config)"
1069
- fi
1070
- else
1071
- if crontab -l 2>/dev/null | grep -qF "pulse-wrapper"; then
1072
- crontab -l 2>/dev/null | grep -v 'aidevops: supervisor-pulse' | crontab - || true
1073
- print_info "Supervisor pulse disabled (cron entry removed per config)"
1074
- fi
1075
- fi
1076
- return 0
1077
- }
1078
-
1079
- # Setup stats-wrapper scheduler — runs quality sweep and health issue updates
1080
- # separately from the pulse (t1429). Only installed when the supervisor
1081
- # pulse is enabled (stats are useless without it).
1082
- # macOS: launchd plist (hourly) | Linux: systemd timer or cron (hourly)
1083
- # t2744: interval raised from 15 min → hourly. Stats UI is not realtime,
1084
- # the four-times-an-hour cadence drove ~200-400 GraphQL points/hr of pure
1085
- # overhead on multi-repo setups.
1086
- setup_stats_wrapper() {
1087
- local _pulse_lower="$1"
1088
- # Use effective pulse state (PULSE_ENABLED) if available; fall back to consent string.
1089
- # PULSE_ENABLED reflects the actual install decision (e.g., false when wrapper is missing).
1090
- local _pulse_effective="${PULSE_ENABLED:-$_pulse_lower}"
1091
- local stats_script="$HOME/.aidevops/agents/scripts/stats-wrapper.sh"
1092
- local stats_label="com.aidevops.aidevops-stats-wrapper"
1093
- local stats_systemd="aidevops-stats-wrapper"
1094
- local stats_log="$HOME/.aidevops/logs/stats.log"
1095
- if [[ -x "$stats_script" ]] && [[ "$_pulse_effective" == "true" ]]; then
1096
- # Always regenerate to pick up config/format changes (matches pulse behavior)
1097
- if [[ "$(uname -s)" == "Darwin" ]]; then
1098
- local stats_plist="$HOME/Library/LaunchAgents/${stats_label}.plist"
1099
-
1100
- local _xml_stats_script _xml_stats_home _xml_stats_path
1101
- _xml_stats_script=$(_xml_escape "$stats_script")
1102
- _xml_stats_home=$(_xml_escape "$HOME")
1103
- _xml_stats_path=$(_xml_escape "$PATH")
1104
- local stats_plist_content
1105
- stats_plist_content=$(
1106
- cat <<PLIST
1107
- <?xml version="1.0" encoding="UTF-8"?>
1108
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1109
- <plist version="1.0">
1110
- <dict>
1111
- <key>Label</key>
1112
- <string>${stats_label}</string>
1113
- <key>ProgramArguments</key>
1114
- <array>
1115
- <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1116
- <string>${_xml_stats_script}</string>
1117
- </array>
1118
- <key>StartInterval</key>
1119
- <integer>3600</integer>
1120
- <key>StandardOutPath</key>
1121
- <string>${_xml_stats_home}/.aidevops/logs/stats.log</string>
1122
- <key>StandardErrorPath</key>
1123
- <string>${_xml_stats_home}/.aidevops/logs/stats.log</string>
1124
- <key>EnvironmentVariables</key>
1125
- <dict>
1126
- <key>PATH</key>
1127
- <string>${_xml_stats_path}</string>
1128
- <key>HOME</key>
1129
- <string>${_xml_stats_home}</string>
1130
- </dict>
1131
- <key>RunAtLoad</key>
1132
- <true/>
1133
- <key>KeepAlive</key>
1134
- <false/>
1135
- </dict>
1136
- </plist>
1137
- PLIST
1138
- )
1139
- if _launchd_install_if_changed "$stats_label" "$stats_plist" "$stats_plist_content"; then
1140
- print_info "Stats wrapper enabled (launchd, every hour)"
1141
- else
1142
- print_warning "Failed to load stats wrapper LaunchAgent"
1143
- fi
1144
- else
1145
- _install_scheduler_linux \
1146
- "$stats_systemd" \
1147
- "aidevops: stats-wrapper" \
1148
- "$CRON_HOURLY" \
1149
- "\"${stats_script}\"" \
1150
- "3600" \
1151
- "$stats_log" \
1152
- "" \
1153
- "Stats wrapper enabled (every hour)" \
1154
- "Failed to install stats wrapper scheduler" \
1155
- "true" \
1156
- "false"
1157
- fi
1158
- elif [[ "$_pulse_effective" == "false" ]]; then
1159
- # Remove stats scheduler if pulse is disabled
1160
- _uninstall_scheduler \
1161
- "$(uname -s)" \
1162
- "$stats_label" \
1163
- "$stats_systemd" \
1164
- "aidevops: stats-wrapper" \
1165
- "Stats wrapper disabled (pulse is off)"
1166
- fi
1167
- return 0
1168
- }
1169
-
1170
- # Setup failure miner — mines GitHub CI failure notifications for systemic patterns
1171
- # and auto-files root-cause issues. Runs as a pure bash script (no LLM needed).
1172
- # Installed when pulse is enabled and the helper script exists.
1173
- # macOS: launchd plist (hourly at :15) | Linux: systemd timer or cron (hourly at :15)
1174
- setup_failure_miner() {
1175
- local _pulse_lower="$1"
1176
- local _pulse_effective="${PULSE_ENABLED:-$_pulse_lower}"
1177
- local miner_script="$HOME/.aidevops/agents/scripts/gh-failure-miner-helper.sh"
1178
- local miner_label="sh.aidevops.routine-gh-failure-miner"
1179
- local miner_systemd="aidevops-gh-failure-miner"
1180
- local miner_log="$HOME/.aidevops/logs/routine-gh-failure-miner.log"
1181
- if [[ ! -x "$miner_script" ]] || [[ "$_pulse_effective" != "true" ]]; then
1182
- # Remove scheduler if pulse is disabled or script missing
1183
- _uninstall_scheduler \
1184
- "$(uname -s)" \
1185
- "$miner_label" \
1186
- "$miner_systemd" \
1187
- "aidevops: gh-failure-miner" \
1188
- "Failure miner disabled (pulse is off or script missing)"
1189
- return 0
1190
- fi
1191
-
1192
- mkdir -p "$HOME/.aidevops/logs"
1193
-
1194
- if [[ "$(uname -s)" == "Darwin" ]]; then
1195
- local miner_plist="$HOME/Library/LaunchAgents/${miner_label}.plist"
1196
-
1197
- local _xml_miner_script _xml_miner_home _xml_miner_path _xml_miner_log
1198
- _xml_miner_script=$(_xml_escape "$miner_script")
1199
- _xml_miner_home=$(_xml_escape "$HOME")
1200
- _xml_miner_path=$(_xml_escape "/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin:${PATH}")
1201
- _xml_miner_log=$(_xml_escape "$miner_log")
1202
-
1203
- local miner_plist_content
1204
- miner_plist_content=$(
1205
- cat <<MINER_PLIST
1206
- <?xml version="1.0" encoding="UTF-8"?>
1207
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1208
- <plist version="1.0">
1209
- <dict>
1210
- <key>Label</key>
1211
- <string>${miner_label}</string>
1212
- <key>ProgramArguments</key>
1213
- <array>
1214
- <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1215
- <string>${_xml_miner_script}</string>
1216
- <string>create-issues</string>
1217
- <string>--since-hours</string>
1218
- <string>24</string>
1219
- <string>--pulse-repos</string>
1220
- <string>--systemic-threshold</string>
1221
- <string>2</string>
1222
- <string>--max-issues</string>
1223
- <string>3</string>
1224
- <string>--label</string>
1225
- <string>auto-dispatch</string>
1226
- </array>
1227
- <key>EnvironmentVariables</key>
1228
- <dict>
1229
- <key>HOME</key>
1230
- <string>${_xml_miner_home}</string>
1231
- <key>PATH</key>
1232
- <string>${_xml_miner_path}</string>
1233
- </dict>
1234
- <key>StartCalendarInterval</key>
1235
- <array>
1236
- <dict>
1237
- <key>Minute</key>
1238
- <integer>15</integer>
1239
- </dict>
1240
- </array>
1241
- <key>StandardOutPath</key>
1242
- <string>${_xml_miner_log}</string>
1243
- <key>StandardErrorPath</key>
1244
- <string>${_xml_miner_log}</string>
1245
- <key>RunAtLoad</key>
1246
- <false/>
1247
- </dict>
1248
- </plist>
1249
- MINER_PLIST
1250
- )
1251
-
1252
- if _launchd_install_if_changed "$miner_label" "$miner_plist" "$miner_plist_content"; then
1253
- print_info "Failure miner enabled (launchd, hourly at :15)"
1254
- else
1255
- print_warning "Failed to load failure miner LaunchAgent"
1256
- fi
1257
- else
1258
- _install_scheduler_linux \
1259
- "$miner_systemd" \
1260
- "aidevops: gh-failure-miner" \
1261
- "15 * * * *" \
1262
- "\"${miner_script}\" create-issues --since-hours 24 --pulse-repos --systemic-threshold 2 --max-issues 3 --label auto-dispatch" \
1263
- "3600" \
1264
- "$miner_log" \
1265
- "" \
1266
- "Failure miner enabled (hourly at :15)" \
1267
- "Failed to install failure miner scheduler" \
1268
- "false" \
1269
- "false" \
1270
- "*-*-* *:15:00"
1271
- fi
1272
- return 0
1273
- }
1274
-
1275
- # Setup process guard — kills runaway AI processes (ShellCheck bloat, stuck workers)
1276
- # before they exhaust memory and cause kernel panics. Always installed when the
1277
- # script exists; no consent needed (safety net, not autonomous action).
1278
- # macOS: launchd plist (30s interval, RunAtLoad=true) | Linux: systemd timer or cron (every minute)
1279
- setup_process_guard() {
1280
- local guard_script="$HOME/.aidevops/agents/scripts/process-guard-helper.sh"
1281
- local guard_label="sh.aidevops.process-guard"
1282
- local guard_systemd="aidevops-process-guard"
1283
- local guard_log="$HOME/.aidevops/logs/process-guard.log"
1284
- if [[ ! -x "$guard_script" ]]; then
1285
- return 0
1286
- fi
1287
-
1288
- mkdir -p "$HOME/.aidevops/logs"
1289
-
1290
- if [[ "$(uname -s)" == "Darwin" ]]; then
1291
- local guard_plist="$HOME/Library/LaunchAgents/${guard_label}.plist"
1292
-
1293
- # XML-escape paths for safe plist embedding (prevents injection
1294
- # if $HOME or paths contain &, <, > characters)
1295
- local _xml_guard_script _xml_guard_home _xml_guard_path
1296
- _xml_guard_script=$(_xml_escape "$guard_script")
1297
- _xml_guard_home=$(_xml_escape "$HOME")
1298
- _xml_guard_path=$(_xml_escape "$PATH")
1299
-
1300
- local guard_plist_content
1301
- guard_plist_content=$(
1302
- cat <<GUARD_PLIST
1303
- <?xml version="1.0" encoding="UTF-8"?>
1304
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1305
- <plist version="1.0">
1306
- <dict>
1307
- <key>Label</key>
1308
- <string>${guard_label}</string>
1309
- <key>ProgramArguments</key>
1310
- <array>
1311
- <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1312
- <string>${_xml_guard_script}</string>
1313
- <string>kill-runaways</string>
1314
- </array>
1315
- <key>StartInterval</key>
1316
- <integer>30</integer>
1317
- <key>StandardOutPath</key>
1318
- <string>${_xml_guard_home}/.aidevops/logs/process-guard.log</string>
1319
- <key>StandardErrorPath</key>
1320
- <string>${_xml_guard_home}/.aidevops/logs/process-guard.log</string>
1321
- <key>EnvironmentVariables</key>
1322
- <dict>
1323
- <key>PATH</key>
1324
- <string>${_xml_guard_path}</string>
1325
- <key>HOME</key>
1326
- <string>${_xml_guard_home}</string>
1327
- <key>SHELLCHECK_RSS_LIMIT_KB</key>
1328
- <string>524288</string>
1329
- <key>SHELLCHECK_RUNTIME_LIMIT</key>
1330
- <string>120</string>
1331
- <key>CHILD_RSS_LIMIT_KB</key>
1332
- <string>8388608</string>
1333
- <key>CHILD_RUNTIME_LIMIT</key>
1334
- <string>7200</string>
1335
- </dict>
1336
- <key>RunAtLoad</key>
1337
- <true/>
1338
- <key>KeepAlive</key>
1339
- <false/>
1340
- </dict>
1341
- </plist>
1342
- GUARD_PLIST
1343
- )
1344
-
1345
- if _launchd_install_if_changed "$guard_label" "$guard_plist" "$guard_plist_content"; then
1346
- print_info "Process guard enabled (launchd, every 30s, survives reboot)"
1347
- else
1348
- print_warning "Failed to load process guard LaunchAgent"
1349
- fi
1350
- else
1351
- # Linux: systemd timer (30s) or cron fallback (every minute — cron minimum granularity)
1352
- _install_scheduler_linux \
1353
- "$guard_systemd" \
1354
- "aidevops: process-guard" \
1355
- "* * * * *" \
1356
- "\"${guard_script}\" kill-runaways" \
1357
- "30" \
1358
- "$guard_log" \
1359
- "SHELLCHECK_RSS_LIMIT_KB=524288
1360
- SHELLCHECK_RUNTIME_LIMIT=120
1361
- CHILD_RSS_LIMIT_KB=8388608
1362
- CHILD_RUNTIME_LIMIT=7200" \
1363
- "Process guard enabled (every 30s)" \
1364
- "Failed to install process guard scheduler" \
1365
- "true" \
1366
- "false"
1367
- fi
1368
- return 0
1369
- }
1370
-
1371
- # Setup memory pressure monitor — process-focused memory watchdog (t1398.5, GH#2915).
1372
- # Monitors individual process RSS, runtime, session count, and aggregate memory.
1373
- # Auto-kills runaway ShellCheck (language server respawns them). Always installed
1374
- # when the script exists; no consent needed (safety net, not autonomous action).
1375
- # macOS: launchd plist (60s interval, RunAtLoad=true) | Linux: systemd timer or cron (every minute)
1376
- setup_memory_pressure_monitor() {
1377
- local monitor_script="$HOME/.aidevops/agents/scripts/memory-pressure-monitor.sh"
1378
- local monitor_label="sh.aidevops.memory-pressure-monitor"
1379
- local monitor_systemd="aidevops-memory-pressure-monitor"
1380
- local monitor_log="$HOME/.aidevops/logs/memory-pressure-launchd.log"
1381
- if [[ ! -x "$monitor_script" ]]; then
1382
- return 0
1383
- fi
1384
-
1385
- mkdir -p "$HOME/.aidevops/logs"
1386
-
1387
- if [[ "$(uname -s)" == "Darwin" ]]; then
1388
- local monitor_plist="$HOME/Library/LaunchAgents/${monitor_label}.plist"
1389
-
1390
- # XML-escape paths for safe plist embedding
1391
- local _xml_monitor_script _xml_monitor_home
1392
- _xml_monitor_script=$(_xml_escape "$monitor_script")
1393
- _xml_monitor_home=$(_xml_escape "$HOME")
1394
-
1395
- local monitor_plist_content
1396
- monitor_plist_content=$(
1397
- cat <<MONITOR_PLIST
1398
- <?xml version="1.0" encoding="UTF-8"?>
1399
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1400
- <plist version="1.0">
1401
- <dict>
1402
- <key>Label</key>
1403
- <string>${monitor_label}</string>
1404
- <key>ProgramArguments</key>
1405
- <array>
1406
- <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1407
- <string>${_xml_monitor_script}</string>
1408
- </array>
1409
- <key>StartInterval</key>
1410
- <integer>60</integer>
1411
- <key>StandardOutPath</key>
1412
- <string>${_xml_monitor_home}/.aidevops/logs/memory-pressure-launchd.log</string>
1413
- <key>StandardErrorPath</key>
1414
- <string>${_xml_monitor_home}/.aidevops/logs/memory-pressure-launchd.log</string>
1415
- <key>EnvironmentVariables</key>
1416
- <dict>
1417
- <key>PATH</key>
1418
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
1419
- <key>HOME</key>
1420
- <string>${_xml_monitor_home}</string>
1421
- </dict>
1422
- <key>RunAtLoad</key>
1423
- <true/>
1424
- <key>KeepAlive</key>
1425
- <false/>
1426
- <key>ProcessType</key>
1427
- <string>Background</string>
1428
- <key>LowPriorityBackgroundIO</key>
1429
- <true/>
1430
- <key>Nice</key>
1431
- <integer>10</integer>
1432
- </dict>
1433
- </plist>
1434
- MONITOR_PLIST
1435
- )
1436
-
1437
- if _launchd_install_if_changed "$monitor_label" "$monitor_plist" "$monitor_plist_content"; then
1438
- print_info "Memory pressure monitor enabled (launchd, every 60s, survives reboot)"
1439
- else
1440
- print_warning "Failed to load memory pressure monitor LaunchAgent"
1441
- fi
1442
- else
1443
- # Linux: systemd timer (60s) or cron fallback (every minute — cron minimum granularity)
1444
- _install_scheduler_linux \
1445
- "$monitor_systemd" \
1446
- "aidevops: memory-pressure-monitor" \
1447
- "* * * * *" \
1448
- "\"${monitor_script}\"" \
1449
- "60" \
1450
- "$monitor_log" \
1451
- "" \
1452
- "Memory pressure monitor enabled (every 60s)" \
1453
- "Failed to install memory pressure monitor scheduler" \
1454
- "true" \
1455
- "true"
1456
- fi
1457
- return 0
1458
- }
1459
-
1460
- # Setup screen time snapshot — captures daily screen time for contributor stats.
1461
- # Accumulates data in screen-time.jsonl (macOS Knowledge DB retains only ~28 days).
1462
- # Always installed when the script exists; no consent needed (data collection only).
1463
- # macOS: launchd plist (every 6h, RunAtLoad=true) | Linux: systemd timer or cron (every 6h)
1464
- setup_screen_time_snapshot() {
1465
- local st_script="$HOME/.aidevops/agents/scripts/screen-time-helper.sh"
1466
- local st_label="sh.aidevops.screen-time-snapshot"
1467
- local st_systemd="aidevops-screen-time-snapshot"
1468
- local st_log="$HOME/.aidevops/.agent-workspace/logs/screen-time-snapshot.log"
1469
- if [[ ! -x "$st_script" ]]; then
1470
- return 0
1471
- fi
1472
-
1473
- mkdir -p "$HOME/.aidevops/.agent-workspace/logs"
1474
-
1475
- if [[ "$(uname -s)" == "Darwin" ]]; then
1476
- local st_plist="$HOME/Library/LaunchAgents/${st_label}.plist"
1477
-
1478
- # XML-escape paths for safe plist embedding
1479
- local _xml_st_script _xml_st_home
1480
- _xml_st_script=$(_xml_escape "$st_script")
1481
- _xml_st_home=$(_xml_escape "$HOME")
1482
-
1483
- local st_plist_content
1484
- st_plist_content=$(
1485
- cat <<ST_PLIST
1486
- <?xml version="1.0" encoding="UTF-8"?>
1487
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1488
- <plist version="1.0">
1489
- <dict>
1490
- <key>Label</key>
1491
- <string>${st_label}</string>
1492
- <key>ProgramArguments</key>
1493
- <array>
1494
- <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1495
- <string>${_xml_st_script}</string>
1496
- <string>snapshot</string>
1497
- </array>
1498
- <key>StartInterval</key>
1499
- <integer>21600</integer>
1500
- <key>StandardOutPath</key>
1501
- <string>${_xml_st_home}/.aidevops/.agent-workspace/logs/screen-time-snapshot.log</string>
1502
- <key>StandardErrorPath</key>
1503
- <string>${_xml_st_home}/.aidevops/.agent-workspace/logs/screen-time-snapshot.log</string>
1504
- <key>EnvironmentVariables</key>
1505
- <dict>
1506
- <key>PATH</key>
1507
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
1508
- <key>HOME</key>
1509
- <string>${_xml_st_home}</string>
1510
- </dict>
1511
- <key>RunAtLoad</key>
1512
- <true/>
1513
- <key>KeepAlive</key>
1514
- <false/>
1515
- <key>ProcessType</key>
1516
- <string>Background</string>
1517
- <key>LowPriorityBackgroundIO</key>
1518
- <true/>
1519
- <key>Nice</key>
1520
- <integer>10</integer>
1521
- </dict>
1522
- </plist>
1523
- ST_PLIST
1524
- )
1525
-
1526
- if _launchd_install_if_changed "$st_label" "$st_plist" "$st_plist_content"; then
1527
- print_info "Screen time snapshot enabled (launchd, every 6h, survives reboot)"
1528
- else
1529
- print_warning "Failed to load screen time snapshot LaunchAgent"
1530
- fi
1531
- else
1532
- # Linux: systemd timer (every 6h) or cron fallback
1533
- _install_scheduler_linux \
1534
- "$st_systemd" \
1535
- "aidevops: screen-time-snapshot" \
1536
- "0 */6 * * *" \
1537
- "\"${st_script}\" snapshot" \
1538
- "21600" \
1539
- "$st_log" \
1540
- "" \
1541
- "Screen time snapshot enabled (every 6h)" \
1542
- "Failed to install screen time snapshot scheduler" \
1543
- "true" \
1544
- "true"
1545
- fi
1546
- return 0
1547
- }
1548
-
1549
- # Resolve and validate the log directory from config for contribution watch.
1550
- # Reads paths.log_dir from jsonc config, validates characters, expands tilde.
1551
- # Prints the resolved absolute path. Returns 1 on invalid characters.
1552
- _resolve_cw_log_dir() {
1553
- local _cw_log_dir
1554
- # shellcheck disable=SC2088 # Tilde is intentionally literal here; expanded below via ${/#\~/$HOME}
1555
- if type _jsonc_get &>/dev/null; then
1556
- _cw_log_dir=$(_jsonc_get "paths.log_dir" "~/.aidevops/logs")
1557
- else
1558
- _cw_log_dir="~/.aidevops/logs"
1559
- fi
1560
- # Whitelist: only allow characters safe in shell paths and cron lines.
1561
- # Reject anything outside [A-Za-z0-9_./ ~-] (tilde allowed before expansion).
1562
- # Store regex in variable — bash [[ =~ ]] requires unquoted RHS for regex,
1563
- # and a variable avoids quoting issues with special chars in the pattern.
1564
- local _cw_log_dir_re='^[A-Za-z0-9_./ ~-]+$'
1565
- if ! [[ "$_cw_log_dir" =~ $_cw_log_dir_re ]]; then
1566
- # Redirect to stderr so $() captures only the path result
1567
- print_error "Invalid characters in paths.log_dir (only [A-Za-z0-9_./ ~-] allowed): $_cw_log_dir" >&2
1568
- return 1
1569
- fi
1570
- _cw_log_dir="${_cw_log_dir/#\~/$HOME}"
1571
- printf '%s' "$_cw_log_dir"
1572
- return 0
1573
- }
1574
-
1575
- # Install contribution watch via launchd (macOS).
1576
- # Args: $1=label, $2=script path, $3=log dir
1577
- _install_cw_launchd() {
1578
- local cw_label="$1"
1579
- local cw_script="$2"
1580
- local _cw_log_dir="$3"
1581
- local cw_plist="$HOME/Library/LaunchAgents/${cw_label}.plist"
1582
-
1583
- local _xml_cw_script _xml_cw_home _xml_cw_log_dir
1584
- _xml_cw_script=$(_xml_escape "$cw_script")
1585
- _xml_cw_home=$(_xml_escape "$HOME")
1586
- _xml_cw_log_dir=$(_xml_escape "$_cw_log_dir")
21
+ # Keep pulse workers alive long enough for opus-tier dispatches.
22
+ PULSE_STALE_THRESHOLD_SECONDS=1800
1587
23
 
1588
- local cw_plist_content
1589
- cw_plist_content=$(
1590
- cat <<CW_PLIST
1591
- <?xml version="1.0" encoding="UTF-8"?>
1592
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1593
- <plist version="1.0">
1594
- <dict>
1595
- <key>Label</key>
1596
- <string>${cw_label}</string>
1597
- <key>ProgramArguments</key>
1598
- <array>
1599
- <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1600
- <string>${_xml_cw_script}</string>
1601
- <string>scan</string>
1602
- </array>
1603
- <key>StartInterval</key>
1604
- <integer>3600</integer>
1605
- <key>StandardOutPath</key>
1606
- <string>${_xml_cw_log_dir}/contribution-watch.log</string>
1607
- <key>StandardErrorPath</key>
1608
- <string>${_xml_cw_log_dir}/contribution-watch.log</string>
1609
- <key>EnvironmentVariables</key>
1610
- <dict>
1611
- <key>PATH</key>
1612
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
1613
- <key>HOME</key>
1614
- <string>${_xml_cw_home}</string>
1615
- </dict>
1616
- <key>RunAtLoad</key>
1617
- <false/>
1618
- <key>KeepAlive</key>
1619
- <false/>
1620
- <key>ProcessType</key>
1621
- <string>Background</string>
1622
- <key>LowPriorityBackgroundIO</key>
1623
- <true/>
1624
- <key>Nice</key>
1625
- <integer>10</integer>
1626
- </dict>
1627
- </plist>
1628
- CW_PLIST
1629
- )
24
+ # Cron expression: top of every hour. Shared by stats-wrapper,
25
+ # contribution-watch, and profile-readme schedulers — keep DRY so a
26
+ # future cadence shift only touches one place.
27
+ CRON_HOURLY="0 * * * *"
1630
28
 
1631
- if _launchd_install_if_changed "$cw_label" "$cw_plist" "$cw_plist_content"; then
1632
- print_info "Contribution watch enabled (launchd, hourly scan)"
1633
- else
1634
- print_warning "Failed to load contribution watch LaunchAgent"
1635
- fi
1636
- return 0
1637
- }
29
+ # Cron expression: every minute. Shared by process-guard, memory-pressure
30
+ # monitor, and pulse-watchdog schedulers (cron's minimum granularity).
31
+ # Kept DRY for the same reason as CRON_HOURLY.
32
+ CRON_EVERY_MINUTE="* * * * *"
1638
33
 
1639
- # Install contribution watch via systemd or cron (Linux).
1640
- # Args: $1=script path, $2=log dir
1641
- _install_cw_linux() {
1642
- local cw_script="$1"
1643
- local _cw_log_dir="$2"
1644
- local cw_systemd="aidevops-contribution-watch"
1645
- _install_scheduler_linux \
1646
- "$cw_systemd" \
1647
- "aidevops: contribution-watch" \
1648
- "$CRON_HOURLY" \
1649
- "\"${cw_script}\" scan" \
1650
- "3600" \
1651
- "${_cw_log_dir}/contribution-watch.log" \
1652
- "" \
1653
- "Contribution watch enabled (hourly scan)" \
1654
- "Failed to install contribution watch scheduler" \
1655
- "false" \
1656
- "true"
1657
- return 0
1658
- }
34
+ # Shell safety baseline
35
+ set -Eeuo pipefail
36
+ IFS=$'\n\t'
37
+ # shellcheck disable=SC2154 # rc is assigned by $? in the trap string
38
+ trap 'rc=$?; echo "[ERROR] ${BASH_SOURCE[0]}:${LINENO} exit $rc" >&2' ERR
39
+ shopt -s inherit_errexit 2>/dev/null || true
1659
40
 
1660
- # Setup contribution watch monitors external issues/PRs for new activity (t1554).
1661
- # Auto-seeds on first run (discovers authored/commented issues/PRs), then installs
1662
- # a launchd/systemd/cron job to scan periodically. Requires gh CLI authenticated.
1663
- # No consent needed — this is passive monitoring (read-only notifications API),
1664
- # not autonomous action. Comment bodies are never processed by LLM in automated context.
1665
- # Respects config: aidevops config set orchestration.contribution_watch false
1666
- setup_contribution_watch() {
1667
- local cw_script="$HOME/.aidevops/agents/scripts/contribution-watch-helper.sh"
1668
- local cw_label="sh.aidevops.contribution-watch"
1669
- local cw_state="$HOME/.aidevops/cache/contribution-watch.json"
1670
- if ! [[ -x "$cw_script" ]] || ! is_feature_enabled orchestration.contribution_watch 2>/dev/null || ! command -v gh &>/dev/null || ! gh auth status &>/dev/null 2>&1; then
1671
- return 0
1672
- fi
41
+ # SCRIPT_DIR resolves to the setup-modules/ directory so sub-library
42
+ # source calls work regardless of the caller's working directory.
43
+ if [[ -z "${SCRIPT_DIR:-}" ]]; then
44
+ _sched_orch_lib_path="${BASH_SOURCE[0]%/*}"
45
+ [[ "$_sched_orch_lib_path" == "${BASH_SOURCE[0]}" ]] && _sched_orch_lib_path="."
46
+ SCRIPT_DIR="$(cd "$_sched_orch_lib_path" && pwd)"
47
+ unset _sched_orch_lib_path
48
+ fi
1673
49
 
1674
- # Resolve and validate log directory
1675
- local _cw_log_dir
1676
- _cw_log_dir=$(_resolve_cw_log_dir) || return 1
1677
- mkdir -p "$HOME/.aidevops/cache" "$_cw_log_dir"
50
+ # Source sub-libraries. Each carries its own include guard so double-sourcing
51
+ # is safe. SC1091 suppressed per reference/large-file-split.md §5.1 — paths
52
+ # are computed at runtime via $SCRIPT_DIR and cannot be statically resolved.
1678
53
 
1679
- # Auto-seed on first run (populates state file with existing contributions)
1680
- if [[ ! -f "$cw_state" ]]; then
1681
- print_info "Discovering external contributions for contribution watch..."
1682
- if bash "$cw_script" seed >/dev/null 2>&1; then
1683
- print_info "Contribution watch seeded (external issues/PRs discovered)"
1684
- else
1685
- print_warning "Contribution watch seed failed (non-fatal, will retry on next run)"
1686
- fi
1687
- fi
54
+ # shellcheck source=./schedulers-pulse.sh
55
+ # shellcheck disable=SC1091 # sub-library resolved at runtime via $SCRIPT_DIR
56
+ source "${SCRIPT_DIR}/schedulers-pulse.sh"
1688
57
 
1689
- # Install/update scheduled scanner
1690
- if [[ "$(uname -s)" == "Darwin" ]]; then
1691
- _install_cw_launchd "$cw_label" "$cw_script" "$_cw_log_dir"
1692
- else
1693
- _install_cw_linux "$cw_script" "$_cw_log_dir"
1694
- fi
1695
- return 0
1696
- }
58
+ # shellcheck source=./schedulers-linux.sh
59
+ # shellcheck disable=SC1091 # sub-library resolved at runtime via $SCRIPT_DIR
60
+ source "${SCRIPT_DIR}/schedulers-linux.sh"
1697
61
 
1698
- # Install complexity scan via launchd (macOS).
1699
- # Args: $1=label, $2=script path, $3=log dir
1700
- # (t2903) Extracted from pulse dispatch preflight — independent schedule so
1701
- # the 200-470s scan never starves dispatch or downstream scanners.
1702
- _install_complexity_scan_launchd() {
1703
- local cs_label="$1"
1704
- local cs_script="$2"
1705
- local _cs_log_dir="$3"
1706
- local cs_plist="$HOME/Library/LaunchAgents/${cs_label}.plist"
62
+ # shellcheck source=./schedulers-platform.sh
63
+ # shellcheck disable=SC1091 # sub-library resolved at runtime via $SCRIPT_DIR
64
+ source "${SCRIPT_DIR}/schedulers-platform.sh"
1707
65
 
1708
- local _xml_cs_script _xml_cs_home _xml_cs_log_dir
1709
- _xml_cs_script=$(_xml_escape "$cs_script")
1710
- _xml_cs_home=$(_xml_escape "$HOME")
1711
- _xml_cs_log_dir=$(_xml_escape "$_cs_log_dir")
66
+ # Setup stats-wrapper scheduler — runs quality sweep and health issue updates
67
+ # separately from the pulse (t1429). Only installed when the supervisor
68
+ # pulse is enabled (stats are useless without it).
69
+ # macOS: launchd plist (hourly) | Linux: systemd timer or cron (hourly)
70
+ # t2744: interval raised from 15 min → hourly. Stats UI is not realtime,
71
+ # the four-times-an-hour cadence drove ~200-400 GraphQL points/hr of pure
72
+ # overhead on multi-repo setups.
73
+ setup_stats_wrapper() {
74
+ local _pulse_lower="$1"
75
+ # Use effective pulse state (PULSE_ENABLED) if available; fall back to consent string.
76
+ # PULSE_ENABLED reflects the actual install decision (e.g., false when wrapper is missing).
77
+ local _pulse_effective="${PULSE_ENABLED:-$_pulse_lower}"
78
+ local stats_script="$HOME/.aidevops/agents/scripts/stats-wrapper.sh"
79
+ local stats_label="com.aidevops.aidevops-stats-wrapper"
80
+ local stats_systemd="aidevops-stats-wrapper"
81
+ local stats_log="$HOME/.aidevops/logs/stats.log"
82
+ if [[ -x "$stats_script" ]] && [[ "$_pulse_effective" == "true" ]]; then
83
+ # Always regenerate to pick up config/format changes (matches pulse behavior)
84
+ if [[ "$(uname -s)" == "Darwin" ]]; then
85
+ local stats_plist="$HOME/Library/LaunchAgents/${stats_label}.plist"
1712
86
 
1713
- local cs_plist_content
1714
- cs_plist_content=$(
1715
- cat <<CS_PLIST
87
+ local _xml_stats_script _xml_stats_home _xml_stats_path
88
+ _xml_stats_script=$(_xml_escape "$stats_script")
89
+ _xml_stats_home=$(_xml_escape "$HOME")
90
+ _xml_stats_path=$(_xml_escape "$PATH")
91
+ local stats_plist_content
92
+ stats_plist_content=$(
93
+ cat <<PLIST
1716
94
  <?xml version="1.0" encoding="UTF-8"?>
1717
95
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1718
96
  <plist version="1.0">
1719
97
  <dict>
1720
98
  <key>Label</key>
1721
- <string>${cs_label}</string>
99
+ <string>${stats_label}</string>
1722
100
  <key>ProgramArguments</key>
1723
101
  <array>
1724
102
  <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1725
- <string>${_xml_cs_script}</string>
1726
- <string>run</string>
103
+ <string>${_xml_stats_script}</string>
1727
104
  </array>
1728
105
  <key>StartInterval</key>
1729
106
  <integer>3600</integer>
1730
107
  <key>StandardOutPath</key>
1731
- <string>${_xml_cs_log_dir}/complexity-scan-runner.log</string>
108
+ <string>${_xml_stats_home}/.aidevops/logs/stats.log</string>
1732
109
  <key>StandardErrorPath</key>
1733
- <string>${_xml_cs_log_dir}/complexity-scan-runner.log</string>
110
+ <string>${_xml_stats_home}/.aidevops/logs/stats.log</string>
1734
111
  <key>EnvironmentVariables</key>
1735
112
  <dict>
1736
113
  <key>PATH</key>
1737
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
114
+ <string>${_xml_stats_path}</string>
1738
115
  <key>HOME</key>
1739
- <string>${_xml_cs_home}</string>
116
+ <string>${_xml_stats_home}</string>
1740
117
  </dict>
1741
118
  <key>RunAtLoad</key>
1742
119
  <true/>
1743
120
  <key>KeepAlive</key>
1744
121
  <false/>
1745
- <key>ProcessType</key>
1746
- <string>Background</string>
1747
- <key>LowPriorityBackgroundIO</key>
1748
- <true/>
1749
- <key>Nice</key>
1750
- <integer>10</integer>
1751
122
  </dict>
1752
123
  </plist>
1753
- CS_PLIST
1754
- )
1755
-
1756
- if _launchd_install_if_changed "$cs_label" "$cs_plist" "$cs_plist_content"; then
1757
- print_info "Complexity scan enabled (launchd, hourly run)"
1758
- else
1759
- print_warning "Failed to load complexity scan LaunchAgent"
124
+ PLIST
125
+ )
126
+ if _launchd_install_if_changed "$stats_label" "$stats_plist" "$stats_plist_content"; then
127
+ print_info "Stats wrapper enabled (launchd, every hour)"
128
+ else
129
+ print_warning "Failed to load stats wrapper LaunchAgent"
130
+ fi
131
+ else
132
+ _install_scheduler_linux \
133
+ "$stats_systemd" \
134
+ "aidevops: stats-wrapper" \
135
+ "$CRON_HOURLY" \
136
+ "\"${stats_script}\"" \
137
+ "3600" \
138
+ "$stats_log" \
139
+ "" \
140
+ "Stats wrapper enabled (every hour)" \
141
+ "Failed to install stats wrapper scheduler" \
142
+ "true" \
143
+ "false"
144
+ fi
145
+ elif [[ "$_pulse_effective" == "false" ]]; then
146
+ # Remove stats scheduler if pulse is disabled
147
+ _uninstall_scheduler \
148
+ "$(uname -s)" \
149
+ "$stats_label" \
150
+ "$stats_systemd" \
151
+ "aidevops: stats-wrapper" \
152
+ "Stats wrapper disabled (pulse is off)"
1760
153
  fi
1761
154
  return 0
1762
155
  }
1763
156
 
1764
- # Install complexity scan via systemd or cron (Linux).
1765
- # Args: $1=script path, $2=log dir
1766
- _install_complexity_scan_linux() {
1767
- local cs_script="$1"
1768
- local _cs_log_dir="$2"
1769
- local cs_systemd="aidevops-complexity-scan"
1770
- _install_scheduler_linux \
1771
- "$cs_systemd" \
1772
- "aidevops: complexity-scan" \
1773
- "$CRON_HOURLY" \
1774
- "\"${cs_script}\" run" \
1775
- "3600" \
1776
- "${_cs_log_dir}/complexity-scan-runner.log" \
1777
- "" \
1778
- "Complexity scan enabled (hourly run)" \
1779
- "Failed to install complexity scan scheduler" \
1780
- "true" \
1781
- "true"
1782
- return 0
1783
- }
1784
-
1785
- # Setup complexity scan (t2903) — extracts the weekly complexity scan from
1786
- # pulse dispatch preflight into its own launchd/cron schedule. The scan was
1787
- # observed consuming 200-470s per pulse cycle (26%+ of the 1800s pulse stale
1788
- # ceiling), starving downstream scanners. Promoting it to its own schedule
1789
- # decouples it from dispatch entirely. The runner reuses run_weekly_complexity_scan
1790
- # from pulse-simplification.sh, which has internal 15-min cadence gating
1791
- # (COMPLEXITY_SCAN_INTERVAL=900) so hourly launchd ticks are always safe.
1792
- setup_complexity_scan() {
1793
- local cs_script="$HOME/.aidevops/agents/scripts/complexity-scan-runner.sh"
1794
- local cs_label="sh.aidevops.complexity-scan"
1795
- if ! [[ -x "$cs_script" ]]; then
157
+ # Setup failure miner mines GitHub CI failure notifications for systemic patterns
158
+ # and auto-files root-cause issues. Runs as a pure bash script (no LLM needed).
159
+ # Installed when pulse is enabled and the helper script exists.
160
+ # macOS: launchd plist (hourly at :15) | Linux: systemd timer or cron (hourly at :15)
161
+ #
162
+ # NOTE: This function is 105 lines and must remain in this file (schedulers.sh) to
163
+ # preserve its (file, fname) identity key for the function-complexity CI scanner.
164
+ # Moving it to a sub-library would register it as a new violation.
165
+ # See reference/large-file-split.md §3 "Identity-Key Preservation Rules".
166
+ setup_failure_miner() {
167
+ local _pulse_lower="$1"
168
+ local _pulse_effective="${PULSE_ENABLED:-$_pulse_lower}"
169
+ local miner_script="$HOME/.aidevops/agents/scripts/gh-failure-miner-helper.sh"
170
+ local miner_label="sh.aidevops.routine-gh-failure-miner"
171
+ local miner_systemd="aidevops-gh-failure-miner"
172
+ local miner_log="$HOME/.aidevops/logs/routine-gh-failure-miner.log"
173
+ if [[ ! -x "$miner_script" ]] || [[ "$_pulse_effective" != "true" ]]; then
174
+ # Remove scheduler if pulse is disabled or script missing
175
+ _uninstall_scheduler \
176
+ "$(uname -s)" \
177
+ "$miner_label" \
178
+ "$miner_systemd" \
179
+ "aidevops: gh-failure-miner" \
180
+ "Failure miner disabled (pulse is off or script missing)"
1796
181
  return 0
1797
182
  fi
1798
183
 
1799
- # Reuse contribution-watch's log-dir resolver (same logic, same config key).
1800
- local _cs_log_dir
1801
- _cs_log_dir=$(_resolve_cw_log_dir) || return 1
1802
- mkdir -p "$_cs_log_dir"
184
+ mkdir -p "$HOME/.aidevops/logs"
1803
185
 
1804
- # Install/update scheduled runner
1805
186
  if [[ "$(uname -s)" == "Darwin" ]]; then
1806
- _install_complexity_scan_launchd "$cs_label" "$cs_script" "$_cs_log_dir"
1807
- else
1808
- _install_complexity_scan_linux "$cs_script" "$_cs_log_dir"
1809
- fi
1810
- return 0
1811
- }
1812
-
1813
- # Install pulse-merge-routine launchd plist (macOS).
1814
- # Args: $1=label $2=script $3=log_dir
1815
- _install_pulse_merge_routine_launchd() {
1816
- local pmr_label="$1"
1817
- local pmr_script="$2"
1818
- local _pmr_log_dir="$3"
1819
- local pmr_plist="$HOME/Library/LaunchAgents/${pmr_label}.plist"
187
+ local miner_plist="$HOME/Library/LaunchAgents/${miner_label}.plist"
1820
188
 
1821
- local _xml_pmr_script _xml_pmr_home _xml_pmr_log_dir
1822
- _xml_pmr_script=$(_xml_escape "$pmr_script")
1823
- _xml_pmr_home=$(_xml_escape "$HOME")
1824
- _xml_pmr_log_dir=$(_xml_escape "$_pmr_log_dir")
189
+ local _xml_miner_script _xml_miner_home _xml_miner_path _xml_miner_log
190
+ _xml_miner_script=$(_xml_escape "$miner_script")
191
+ _xml_miner_home=$(_xml_escape "$HOME")
192
+ _xml_miner_path=$(_xml_escape "/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin:${PATH}")
193
+ _xml_miner_log=$(_xml_escape "$miner_log")
1825
194
 
1826
- local pmr_plist_content
1827
- pmr_plist_content=$(
1828
- cat <<PMR_PLIST
195
+ local miner_plist_content
196
+ miner_plist_content=$(
197
+ cat <<MINER_PLIST
1829
198
  <?xml version="1.0" encoding="UTF-8"?>
1830
199
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1831
200
  <plist version="1.0">
1832
201
  <dict>
1833
202
  <key>Label</key>
1834
- <string>${pmr_label}</string>
203
+ <string>${miner_label}</string>
1835
204
  <key>ProgramArguments</key>
1836
205
  <array>
1837
206
  <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1838
- <string>${_xml_pmr_script}</string>
1839
- <string>run</string>
207
+ <string>${_xml_miner_script}</string>
208
+ <string>create-issues</string>
209
+ <string>--since-hours</string>
210
+ <string>24</string>
211
+ <string>--pulse-repos</string>
212
+ <string>--systemic-threshold</string>
213
+ <string>2</string>
214
+ <string>--max-issues</string>
215
+ <string>3</string>
216
+ <string>--label</string>
217
+ <string>auto-dispatch</string>
1840
218
  </array>
1841
- <key>StartInterval</key>
1842
- <integer>120</integer>
1843
- <key>StandardOutPath</key>
1844
- <string>${_xml_pmr_log_dir}/pulse-merge-routine.log</string>
1845
- <key>StandardErrorPath</key>
1846
- <string>${_xml_pmr_log_dir}/pulse-merge-routine.log</string>
1847
219
  <key>EnvironmentVariables</key>
1848
220
  <dict>
1849
- <key>PATH</key>
1850
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
1851
221
  <key>HOME</key>
1852
- <string>${_xml_pmr_home}</string>
222
+ <string>${_xml_miner_home}</string>
223
+ <key>PATH</key>
224
+ <string>${_xml_miner_path}</string>
1853
225
  </dict>
226
+ <key>StartCalendarInterval</key>
227
+ <array>
228
+ <dict>
229
+ <key>Minute</key>
230
+ <integer>15</integer>
231
+ </dict>
232
+ </array>
233
+ <key>StandardOutPath</key>
234
+ <string>${_xml_miner_log}</string>
235
+ <key>StandardErrorPath</key>
236
+ <string>${_xml_miner_log}</string>
1854
237
  <key>RunAtLoad</key>
1855
- <true/>
1856
- <key>KeepAlive</key>
1857
238
  <false/>
1858
- <key>ProcessType</key>
1859
- <string>Background</string>
1860
- <key>LowPriorityBackgroundIO</key>
1861
- <true/>
1862
- <key>Nice</key>
1863
- <integer>10</integer>
1864
239
  </dict>
1865
240
  </plist>
1866
- PMR_PLIST
1867
- )
1868
-
1869
- if _launchd_install_if_changed "$pmr_label" "$pmr_plist" "$pmr_plist_content"; then
1870
- print_info "Pulse merge routine enabled (launchd, every 2 min)"
1871
- else
1872
- print_warning "Failed to load pulse merge routine LaunchAgent"
1873
- fi
1874
- return 0
1875
- }
1876
-
1877
- # Install pulse-merge-routine via systemd or cron (Linux).
1878
- # Args: $1=script path, $2=log dir
1879
- _install_pulse_merge_routine_linux() {
1880
- local pmr_script="$1"
1881
- local _pmr_log_dir="$2"
1882
- local pmr_systemd="aidevops-pulse-merge-routine"
1883
- _install_scheduler_linux \
1884
- "$pmr_systemd" \
1885
- "aidevops: pulse-merge-routine" \
1886
- "*/2 * * * *" \
1887
- "\"${pmr_script}\" run" \
1888
- "120" \
1889
- "${_pmr_log_dir}/pulse-merge-routine.log" \
1890
- "" \
1891
- "Pulse merge routine enabled (every 2 min)" \
1892
- "Failed to install pulse merge routine scheduler" \
1893
- "true" \
1894
- "true"
1895
- return 0
1896
- }
1897
-
1898
- # Setup pulse merge routine (t2862, GH#20919) — runs merge_ready_prs_all_repos()
1899
- # as a fast 120s standalone routine, decoupled from the monolithic pulse cycle.
1900
- # The pulse cycle's preflight stack (60-470s) meant the merge pass ran only ~7
1901
- # times/24h despite ~40+ cycles. This routine ensures green PRs merge within ~3
1902
- # min of CI completion. The in-cycle merge call in pulse-wrapper.sh is kept as
1903
- # defense-in-depth but short-circuits when this routine ran within the last 60s.
1904
- setup_pulse_merge_routine() {
1905
- local pmr_script="$HOME/.aidevops/agents/scripts/pulse-merge-routine.sh"
1906
- local pmr_label="sh.aidevops.pulse-merge-routine"
1907
- if ! [[ -x "$pmr_script" ]]; then
1908
- return 0
1909
- fi
1910
-
1911
- # Reuse contribution-watch's log-dir resolver (same logic, same config key).
1912
- local _pmr_log_dir
1913
- _pmr_log_dir=$(_resolve_cw_log_dir) || return 1
1914
- mkdir -p "$_pmr_log_dir"
1915
-
1916
- # Install/update scheduled runner
1917
- if [[ "$(uname -s)" == "Darwin" ]]; then
1918
- _install_pulse_merge_routine_launchd "$pmr_label" "$pmr_script" "$_pmr_log_dir"
1919
- else
1920
- _install_pulse_merge_routine_linux "$pmr_script" "$_pmr_log_dir"
1921
- fi
1922
- return 0
1923
- }
241
+ MINER_PLIST
242
+ )
1924
243
 
1925
- # Setup draft responses private repo + local draft storage for reviewing
1926
- # AI-drafted replies to external contributions (t1555).
1927
- # Respects config: aidevops config set orchestration.draft_responses false
1928
- setup_draft_responses() {
1929
- local dr_script="$HOME/.aidevops/agents/scripts/draft-response-helper.sh"
1930
- if [[ -x "$dr_script" ]] && is_feature_enabled orchestration.draft_responses 2>/dev/null && is_feature_enabled orchestration.contribution_watch 2>/dev/null && command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1; then
1931
- mkdir -p "$HOME/.aidevops/.agent-workspace/draft-responses"
1932
- if bash "$dr_script" init >/dev/null 2>&1; then
1933
- print_info "Draft responses ready (private repo + local drafts)"
244
+ if _launchd_install_if_changed "$miner_label" "$miner_plist" "$miner_plist_content"; then
245
+ print_info "Failure miner enabled (launchd, hourly at :15)"
1934
246
  else
1935
- print_warning "Draft responses repo setup failed (non-fatal, local drafts still work)"
247
+ print_warning "Failed to load failure miner LaunchAgent"
1936
248
  fi
249
+ else
250
+ _install_scheduler_linux \
251
+ "$miner_systemd" \
252
+ "aidevops: gh-failure-miner" \
253
+ "15 * * * *" \
254
+ "\"${miner_script}\" create-issues --since-hours 24 --pulse-repos --systemic-threshold 2 --max-issues 3 --label auto-dispatch" \
255
+ "3600" \
256
+ "$miner_log" \
257
+ "" \
258
+ "Failure miner enabled (hourly at :15)" \
259
+ "Failed to install failure miner scheduler" \
260
+ "false" \
261
+ "false" \
262
+ "*-*-* *:15:00"
1937
263
  fi
1938
264
  return 0
1939
265
  }
1940
266
 
1941
- # Setup profile READMEauto-create repo and seed README if not already set up.
1942
- # Requires gh CLI authenticated. Creates username/username repo, seeds README
1943
- # with stat markers, registers in repos.json with priority: "profile".
1944
- _profile_readme_ready() {
1945
- local pr_script="$1"
1946
- if ! [[ -x "$pr_script" ]]; then
1947
- return 1
1948
- fi
1949
- if ! command -v gh &>/dev/null; then
1950
- return 1
1951
- fi
1952
- if ! gh auth status &>/dev/null; then
1953
- return 1
267
+ # Setup process guardkills runaway AI processes (ShellCheck bloat, stuck workers)
268
+ # before they exhaust memory and cause kernel panics. Always installed when the
269
+ # script exists; no consent needed (safety net, not autonomous action).
270
+ # macOS: launchd plist (30s interval, RunAtLoad=true) | Linux: systemd timer or cron (every minute)
271
+ setup_process_guard() {
272
+ local guard_script="$HOME/.aidevops/agents/scripts/process-guard-helper.sh"
273
+ local guard_label="sh.aidevops.process-guard"
274
+ local guard_systemd="aidevops-process-guard"
275
+ local guard_log="$HOME/.aidevops/logs/process-guard.log"
276
+ if [[ ! -x "$guard_script" ]]; then
277
+ return 0
1954
278
  fi
1955
- return 0
1956
- }
1957
279
 
1958
- _run_profile_readme_init() {
1959
- local pr_script="$1"
1960
- print_info "Checking GitHub profile README..."
1961
- if bash "$pr_script" init; then
1962
- print_info "Profile README ready."
1963
- else
1964
- print_warning "Profile README setup failed (non-fatal, skipping)"
1965
- fi
1966
- return 0
1967
- }
280
+ mkdir -p "$HOME/.aidevops/logs"
281
+
282
+ if [[ "$(uname -s)" == "Darwin" ]]; then
283
+ local guard_plist="$HOME/Library/LaunchAgents/${guard_label}.plist"
1968
284
 
1969
- _install_profile_readme_launchd() {
1970
- local pr_label="$1"
1971
- local pr_script="$2"
1972
- local pr_plist="$HOME/Library/LaunchAgents/${pr_label}.plist"
1973
- local _xml_pr_script _xml_pr_home
1974
- _xml_pr_script=$(_xml_escape "$pr_script")
1975
- _xml_pr_home=$(_xml_escape "$HOME")
285
+ # XML-escape paths for safe plist embedding (prevents injection
286
+ # if $HOME or paths contain &, <, > characters)
287
+ local _xml_guard_script _xml_guard_home _xml_guard_path
288
+ _xml_guard_script=$(_xml_escape "$guard_script")
289
+ _xml_guard_home=$(_xml_escape "$HOME")
290
+ _xml_guard_path=$(_xml_escape "$PATH")
1976
291
 
1977
- local pr_plist_content
1978
- pr_plist_content=$(
1979
- cat <<PR_PLIST
292
+ local guard_plist_content
293
+ guard_plist_content=$(
294
+ cat <<GUARD_PLIST
1980
295
  <?xml version="1.0" encoding="UTF-8"?>
1981
296
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1982
297
  <plist version="1.0">
1983
298
  <dict>
1984
299
  <key>Label</key>
1985
- <string>${pr_label}</string>
300
+ <string>${guard_label}</string>
1986
301
  <key>ProgramArguments</key>
1987
302
  <array>
1988
303
  <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
1989
- <string>${_xml_pr_script}</string>
1990
- <string>update</string>
304
+ <string>${_xml_guard_script}</string>
305
+ <string>kill-runaways</string>
1991
306
  </array>
1992
307
  <key>StartInterval</key>
1993
- <integer>3600</integer>
308
+ <integer>30</integer>
1994
309
  <key>StandardOutPath</key>
1995
- <string>${_xml_pr_home}/.aidevops/.agent-workspace/logs/profile-readme-update.log</string>
310
+ <string>${_xml_guard_home}/.aidevops/logs/process-guard.log</string>
1996
311
  <key>StandardErrorPath</key>
1997
- <string>${_xml_pr_home}/.aidevops/.agent-workspace/logs/profile-readme-update.log</string>
312
+ <string>${_xml_guard_home}/.aidevops/logs/process-guard.log</string>
1998
313
  <key>EnvironmentVariables</key>
1999
314
  <dict>
2000
315
  <key>PATH</key>
2001
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
316
+ <string>${_xml_guard_path}</string>
2002
317
  <key>HOME</key>
2003
- <string>${_xml_pr_home}</string>
318
+ <string>${_xml_guard_home}</string>
319
+ <key>SHELLCHECK_RSS_LIMIT_KB</key>
320
+ <string>524288</string>
321
+ <key>SHELLCHECK_RUNTIME_LIMIT</key>
322
+ <string>120</string>
323
+ <key>CHILD_RSS_LIMIT_KB</key>
324
+ <string>8388608</string>
325
+ <key>CHILD_RUNTIME_LIMIT</key>
326
+ <string>7200</string>
2004
327
  </dict>
2005
328
  <key>RunAtLoad</key>
2006
- <false/>
329
+ <true/>
2007
330
  <key>KeepAlive</key>
2008
331
  <false/>
2009
- <key>ProcessType</key>
2010
- <string>Background</string>
2011
- <key>LowPriorityBackgroundIO</key>
2012
- <true/>
2013
- <key>Nice</key>
2014
- <integer>10</integer>
2015
332
  </dict>
2016
333
  </plist>
2017
- PR_PLIST
2018
- )
334
+ GUARD_PLIST
335
+ )
2019
336
 
2020
- if _launchd_install_if_changed "$pr_label" "$pr_plist" "$pr_plist_content"; then
2021
- print_info "Profile README update enabled (launchd, hourly)"
337
+ if _launchd_install_if_changed "$guard_label" "$guard_plist" "$guard_plist_content"; then
338
+ print_info "Process guard enabled (launchd, every 30s, survives reboot)"
339
+ else
340
+ print_warning "Failed to load process guard LaunchAgent"
341
+ fi
2022
342
  else
2023
- print_warning "Failed to load profile README update LaunchAgent"
2024
- fi
2025
- return 0
2026
- }
2027
-
2028
- _install_profile_readme_scheduler() {
2029
- local pr_label="$1"
2030
- local pr_systemd="$2"
2031
- local pr_script="$3"
2032
- local pr_log="$4"
2033
-
2034
- if [[ "$(uname -s)" == "Darwin" ]]; then
2035
- _install_profile_readme_launchd "$pr_label" "$pr_script"
2036
- return 0
2037
- fi
2038
-
2039
- _install_scheduler_linux \
2040
- "$pr_systemd" \
2041
- "aidevops: profile-readme-update" \
2042
- "$CRON_HOURLY" \
2043
- "\"${pr_script}\" update" \
2044
- "3600" \
2045
- "$pr_log" \
2046
- "" \
2047
- "Profile README update enabled (hourly)" \
2048
- "Failed to install profile README update scheduler" \
2049
- "false" \
2050
- "true"
2051
- return 0
2052
- }
2053
-
2054
- setup_profile_readme() {
2055
- local pr_script="$HOME/.aidevops/agents/scripts/profile-readme-helper.sh"
2056
- local pr_label="sh.aidevops.profile-readme-update"
2057
- if ! _profile_readme_ready "$pr_script"; then
2058
- return 0
343
+ # Linux: systemd timer (30s) or cron fallback (every minute — cron minimum granularity)
344
+ _install_scheduler_linux \
345
+ "$guard_systemd" \
346
+ "aidevops: process-guard" \
347
+ "$CRON_EVERY_MINUTE" \
348
+ "\"${guard_script}\" kill-runaways" \
349
+ "30" \
350
+ "$guard_log" \
351
+ "SHELLCHECK_RSS_LIMIT_KB=524288
352
+ SHELLCHECK_RUNTIME_LIMIT=120
353
+ CHILD_RSS_LIMIT_KB=8388608
354
+ CHILD_RUNTIME_LIMIT=7200" \
355
+ "Process guard enabled (every 30s)" \
356
+ "Failed to install process guard scheduler" \
357
+ "true" \
358
+ "false"
2059
359
  fi
2060
-
2061
- # Initialize profile repo if not already set up.
2062
- # Always run init — it's idempotent and handles:
2063
- # - Fresh installs (no profile repo)
2064
- # - Missing markers (injects them into existing README)
2065
- # - Diverged history (repo deleted and recreated on GitHub)
2066
- # - Already-initialized repos (returns early with no changes)
2067
- _run_profile_readme_init "$pr_script"
2068
-
2069
- # Profile README auto-update scheduled job.
2070
- # Installed whenever gh CLI is available — the update script self-heals
2071
- # (discovers/creates the profile repo on first run via _resolve_profile_repo).
2072
- # macOS: launchd plist (hourly) | Linux: systemd timer or cron (hourly)
2073
- local pr_systemd="aidevops-profile-readme-update"
2074
- local pr_log="$HOME/.aidevops/.agent-workspace/logs/profile-readme-update.log"
2075
- mkdir -p "$HOME/.aidevops/.agent-workspace/logs"
2076
-
2077
- _install_profile_readme_scheduler "$pr_label" "$pr_systemd" "$pr_script" "$pr_log"
2078
360
  return 0
2079
361
  }
2080
362
 
2081
- # Detect Windows Git Bash / MINGW64 / MSYS2 environment.
2082
- # WSL reports "Linux" from uname -s and uses the cron path — correct behaviour.
2083
- # Returns 0 (true) on Windows Git Bash/MINGW/MSYS/Cygwin, 1 otherwise.
2084
- _is_windows() {
2085
- case "$(uname -s)" in
2086
- MINGW* | MSYS* | CYGWIN*)
363
+ # Setup memory pressure monitor process-focused memory watchdog (t1398.5, GH#2915).
364
+ # Monitors individual process RSS, runtime, session count, and aggregate memory.
365
+ # Auto-kills runaway ShellCheck (language server respawns them). Always installed
366
+ # when the script exists; no consent needed (safety net, not autonomous action).
367
+ # macOS: launchd plist (60s interval, RunAtLoad=true) | Linux: systemd timer or cron (every minute)
368
+ setup_memory_pressure_monitor() {
369
+ local monitor_script="$HOME/.aidevops/agents/scripts/memory-pressure-monitor.sh"
370
+ local monitor_label="sh.aidevops.memory-pressure-monitor"
371
+ local monitor_systemd="aidevops-memory-pressure-monitor"
372
+ local monitor_log="$HOME/.aidevops/logs/memory-pressure-launchd.log"
373
+ if [[ ! -x "$monitor_script" ]]; then
2087
374
  return 0
2088
- ;;
2089
- *)
2090
- return 1
2091
- ;;
2092
- esac
2093
- }
2094
-
2095
- # Install OAuth token refresh via Windows Task Scheduler (schtasks).
2096
- # Args: $1=tr_script (Unix path), $2=log_dir (Unix path)
2097
- # Runs every 30 minutes, matching macOS launchd and Linux cron behaviour.
2098
- # Uses bash.exe from Git for Windows to execute the shell script.
2099
- _install_token_refresh_schtasks() {
2100
- local tr_script="$1"
2101
- local log_dir="$2"
2102
- local task_name="aidevops-token-refresh"
2103
-
2104
- # Resolve bash.exe — Git for Windows ships it alongside git.exe
2105
- local bash_exe
2106
- bash_exe=$(command -v bash.exe 2>/dev/null || command -v bash 2>/dev/null || echo "bash")
2107
-
2108
- # Convert Unix paths to Windows paths for schtasks (requires cygpath from Git Bash)
2109
- local tr_script_win log_dir_win bash_exe_win
2110
- if command -v cygpath &>/dev/null; then
2111
- tr_script_win=$(cygpath -w "$tr_script")
2112
- log_dir_win=$(cygpath -w "$log_dir")
2113
- bash_exe_win=$(cygpath -w "$bash_exe")
2114
- else
2115
- # Fallback: manual conversion (replace /c/ with C:\, forward to backslash)
2116
- tr_script_win=$(echo "$tr_script" | sed 's|^/\([a-zA-Z]\)/|\1:\\|; s|/|\\|g')
2117
- log_dir_win=$(echo "$log_dir" | sed 's|^/\([a-zA-Z]\)/|\1:\\|; s|/|\\|g')
2118
- bash_exe_win="bash.exe"
2119
- fi
2120
-
2121
- # Remove existing task (idempotent — ignore error if not present)
2122
- schtasks /Delete /TN "$task_name" /F >/dev/null 2>&1 || true
2123
-
2124
- # Create scheduled task: every 30 minutes, run at logon, run whether logged on or not
2125
- # /SC MINUTE /MO 30 = every 30 minutes
2126
- # /RL HIGHEST = run with highest available privileges (needed for token writes)
2127
- # /F = force creation (overwrite if exists)
2128
- # The action runs bash.exe with -c to chain both refresh calls
2129
- local action_cmd
2130
- action_cmd="\"${bash_exe_win}\" -c \"'${tr_script_win}' refresh anthropic >> '${log_dir_win}\\token-refresh.log' 2>&1; '${tr_script_win}' refresh openai >> '${log_dir_win}\\token-refresh.log' 2>&1\""
2131
-
2132
- if schtasks /Create \
2133
- /TN "$task_name" \
2134
- /TR "$action_cmd" \
2135
- /SC MINUTE \
2136
- /MO 30 \
2137
- /RL HIGHEST \
2138
- /F \
2139
- >/dev/null 2>&1; then
2140
- print_info "OAuth token refresh enabled (schtasks, every 30 min)"
2141
- # Run immediately to refresh any expired tokens
2142
- schtasks /Run /TN "$task_name" >/dev/null 2>&1 || true
2143
- else
2144
- print_warning "Failed to create token refresh scheduled task. Run manually: schtasks /Create /TN aidevops-token-refresh /TR \"bash '${tr_script_win}' refresh anthropic\" /SC MINUTE /MO 30"
2145
375
  fi
2146
- return 0
2147
- }
2148
376
 
2149
- # Remove OAuth token refresh Windows scheduled task (uninstall path).
2150
- _uninstall_token_refresh_schtasks() {
2151
- local task_name="aidevops-token-refresh"
2152
- if schtasks /Query /TN "$task_name" >/dev/null 2>&1; then
2153
- schtasks /Delete /TN "$task_name" /F >/dev/null 2>&1 || true
2154
- print_info "OAuth token refresh disabled (schtasks task removed)"
2155
- fi
2156
- return 0
2157
- }
377
+ mkdir -p "$HOME/.aidevops/logs"
2158
378
 
2159
- # Setup OAuth token refresh scheduled job.
2160
- # Refreshes expired/expiring tokens every 30 min so sessions never hit
2161
- # "invalid x-api-key". Also runs at load to catch tokens that expired
2162
- # while the machine was off.
2163
- # macOS: launchd plist | Linux/WSL: systemd timer or cron | Windows Git Bash: schtasks
2164
- _oauth_token_refresh_ready() {
2165
- local tr_script="$1"
2166
- if ! [[ -x "$tr_script" ]]; then
2167
- return 1
2168
- fi
2169
- if ! [[ -f "$HOME/.aidevops/oauth-pool.json" ]]; then
2170
- return 1
2171
- fi
2172
- return 0
2173
- }
379
+ if [[ "$(uname -s)" == "Darwin" ]]; then
380
+ local monitor_plist="$HOME/Library/LaunchAgents/${monitor_label}.plist"
2174
381
 
2175
- _install_token_refresh_launchd() {
2176
- local tr_label="$1"
2177
- local tr_script="$2"
2178
- local tr_plist="$HOME/Library/LaunchAgents/${tr_label}.plist"
2179
- local _xml_tr_script _xml_tr_home
2180
- _xml_tr_script=$(_xml_escape "$tr_script")
2181
- _xml_tr_home=$(_xml_escape "$HOME")
382
+ # XML-escape paths for safe plist embedding
383
+ local _xml_monitor_script _xml_monitor_home
384
+ _xml_monitor_script=$(_xml_escape "$monitor_script")
385
+ _xml_monitor_home=$(_xml_escape "$HOME")
2182
386
 
2183
- local tr_plist_content
2184
- tr_plist_content=$(
2185
- cat <<TR_PLIST
387
+ local monitor_plist_content
388
+ monitor_plist_content=$(
389
+ cat <<MONITOR_PLIST
2186
390
  <?xml version="1.0" encoding="UTF-8"?>
2187
391
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2188
392
  <plist version="1.0">
2189
393
  <dict>
2190
394
  <key>Label</key>
2191
- <string>${tr_label}</string>
395
+ <string>${monitor_label}</string>
2192
396
  <key>ProgramArguments</key>
2193
397
  <array>
2194
398
  <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
2195
- <string>-c</string>
2196
- <string>&quot;${_xml_tr_script}&quot; refresh anthropic; &quot;${_xml_tr_script}&quot; refresh openai</string>
399
+ <string>${_xml_monitor_script}</string>
2197
400
  </array>
2198
401
  <key>StartInterval</key>
2199
- <integer>1800</integer>
402
+ <integer>60</integer>
2200
403
  <key>StandardOutPath</key>
2201
- <string>${_xml_tr_home}/.aidevops/.agent-workspace/logs/token-refresh.log</string>
404
+ <string>${_xml_monitor_home}/.aidevops/logs/memory-pressure-launchd.log</string>
2202
405
  <key>StandardErrorPath</key>
2203
- <string>${_xml_tr_home}/.aidevops/.agent-workspace/logs/token-refresh.log</string>
406
+ <string>${_xml_monitor_home}/.aidevops/logs/memory-pressure-launchd.log</string>
2204
407
  <key>EnvironmentVariables</key>
2205
408
  <dict>
2206
409
  <key>PATH</key>
2207
410
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
2208
411
  <key>HOME</key>
2209
- <string>${_xml_tr_home}</string>
412
+ <string>${_xml_monitor_home}</string>
2210
413
  </dict>
2211
414
  <key>RunAtLoad</key>
2212
415
  <true/>
@@ -2220,238 +423,82 @@ _install_token_refresh_launchd() {
2220
423
  <integer>10</integer>
2221
424
  </dict>
2222
425
  </plist>
2223
- TR_PLIST
2224
- )
2225
-
2226
- if _launchd_install_if_changed "$tr_label" "$tr_plist" "$tr_plist_content"; then
2227
- print_info "OAuth token refresh enabled (launchd, every 30 min)"
2228
- else
2229
- print_warning "Failed to load token refresh LaunchAgent"
2230
- fi
2231
- return 0
2232
- }
2233
-
2234
- setup_oauth_token_refresh() {
2235
- local tr_script="$HOME/.aidevops/agents/scripts/oauth-pool-helper.sh"
2236
- local tr_label="sh.aidevops.token-refresh"
2237
- if ! _oauth_token_refresh_ready "$tr_script"; then
2238
- return 0
2239
- fi
2240
-
2241
- local tr_log_dir="$HOME/.aidevops/.agent-workspace/logs"
2242
- mkdir -p "$tr_log_dir"
2243
-
2244
- if [[ "$(uname -s)" == "Darwin" ]]; then
2245
- _install_token_refresh_launchd "$tr_label" "$tr_script"
2246
- elif _is_windows; then
2247
- # Windows Git Bash / MINGW64 / MSYS2: use Task Scheduler (schtasks)
2248
- _install_token_refresh_schtasks "$tr_script" "$tr_log_dir"
2249
- else
2250
- # Linux / WSL without systemd: systemd timer or cron fallback
2251
- _install_scheduler_linux \
2252
- "aidevops-token-refresh" \
2253
- "aidevops: token-refresh" \
2254
- "*/30 * * * *" \
2255
- "\"${tr_script}\" refresh anthropic; \"${tr_script}\" refresh openai" \
2256
- "1800" \
2257
- "${tr_log_dir}/token-refresh.log" \
2258
- "" \
2259
- "OAuth token refresh enabled (every 30 min)" \
2260
- "Failed to install token refresh scheduler" \
2261
- "true" \
2262
- "true"
2263
- fi
2264
- return 0
2265
- }
2266
-
2267
- # Setup opencode DB maintenance scheduler (r913, t2183).
2268
- # Runs weekly (Sun 04:00 local) to checkpoint/optimize/vacuum opencode.db.
2269
- # The helper self-noops on missing DB, so installing unconditionally is safe —
2270
- # a non-opencode machine wakes up weekly, sees no DB, exits 0 silently.
2271
- #
2272
- # Platform split (mirrors the pattern for token-refresh):
2273
- # macOS — helper owns its plist generation via cmd_install (Approach B).
2274
- # Linux — _install_scheduler_linux with cron `0 4 * * 0` + systemd
2275
- # OnCalendar `Sun *-*-* 04:00:00` for accurate wall-clock firing.
2276
- # Windows — TODO(t2183-followup): opencode on Windows is rare and the
2277
- # helper self-noops on missing DB, so leaving unscheduled is
2278
- # low-risk for this iteration.
2279
- setup_opencode_db_maintenance() {
2280
- local ocdbm_script="$HOME/.aidevops/agents/scripts/opencode-db-maintenance-helper.sh"
2281
- if ! [[ -x "$ocdbm_script" ]]; then
2282
- return 0
2283
- fi
2284
-
2285
- local ocdbm_log_dir="$HOME/.aidevops/.agent-workspace/logs"
2286
- mkdir -p "$ocdbm_log_dir"
426
+ MONITOR_PLIST
427
+ )
2287
428
 
2288
- if [[ "$(uname -s)" == "Darwin" ]]; then
2289
- # Helper owns its own plist generation (Approach B, like repo-sync).
2290
- # Quiet the helper's multi-line output and emit one consolidated line
2291
- # to match the style of setup_profile_readme / setup_oauth_token_refresh.
2292
- if bash "$ocdbm_script" install >/dev/null 2>&1; then
2293
- print_info "OpenCode DB maintenance enabled (launchd, weekly Sun 04:00)"
429
+ if _launchd_install_if_changed "$monitor_label" "$monitor_plist" "$monitor_plist_content"; then
430
+ print_info "Memory pressure monitor enabled (launchd, every 60s, survives reboot)"
2294
431
  else
2295
- print_warning "Failed to install opencode DB maintenance LaunchAgent"
432
+ print_warning "Failed to load memory pressure monitor LaunchAgent"
2296
433
  fi
2297
- elif _is_windows; then
2298
- # Windows scheduling deferred — helper self-noops on missing DB so
2299
- # the cost of leaving unscheduled is ~0 until opencode lands on
2300
- # Windows in quantity.
2301
- return 0
2302
434
  else
2303
- # Linux / WSL: prefer systemd user timer, fall back to cron.
2304
- # Weekly Sunday 04:00 local — cron: `0 4 * * 0`; systemd OnCalendar
2305
- # ensures wall-clock firing even across suspends/reboots.
435
+ # Linux: systemd timer (60s) or cron fallback (every minute cron minimum granularity)
2306
436
  _install_scheduler_linux \
2307
- "aidevops-opencode-db-maintenance" \
2308
- "aidevops: opencode-db-maintenance" \
2309
- "0 4 * * 0" \
2310
- "\"${ocdbm_script}\" auto" \
2311
- "604800" \
2312
- "${ocdbm_log_dir}/opencode-db-maintenance.log" \
437
+ "$monitor_systemd" \
438
+ "aidevops: memory-pressure-monitor" \
439
+ "$CRON_EVERY_MINUTE" \
440
+ "\"${monitor_script}\"" \
441
+ "60" \
442
+ "$monitor_log" \
2313
443
  "" \
2314
- "OpenCode DB maintenance enabled (weekly Sun 04:00)" \
2315
- "Failed to install opencode DB maintenance scheduler" \
2316
- "false" \
444
+ "Memory pressure monitor enabled (every 60s)" \
445
+ "Failed to install memory pressure monitor scheduler" \
2317
446
  "true" \
2318
- "Sun *-*-* 04:00:00"
2319
- fi
2320
- return 0
2321
- }
2322
-
2323
- # Setup repo-sync scheduler if not already installed.
2324
- # Keeps local git repos up to date with daily ff-only pulls.
2325
- # Respects config: aidevops config set orchestration.repo_sync false
2326
- setup_repo_sync() {
2327
- local repo_sync_script="$HOME/.aidevops/agents/scripts/repo-sync-helper.sh"
2328
- if ! [[ -x "$repo_sync_script" ]] || ! is_feature_enabled repo_sync 2>/dev/null; then
2329
- return 0
2330
- fi
2331
-
2332
- local _repo_sync_installed=false
2333
- if _launchd_has_agent "com.aidevops.aidevops-repo-sync"; then
2334
- _repo_sync_installed=true
2335
- elif _launchd_has_agent "sh.aidevops.repo-sync"; then
2336
- _repo_sync_installed=true
2337
- elif crontab -l 2>/dev/null | grep -qF "aidevops-repo-sync"; then
2338
- _repo_sync_installed=true
2339
- elif command -v systemctl >/dev/null 2>&1 &&
2340
- systemctl --user is-enabled "aidevops-repo-sync.timer" >/dev/null 2>&1; then
2341
- _repo_sync_installed=true
2342
- fi
2343
- if [[ "$_repo_sync_installed" == "false" ]]; then
2344
- if [[ "$NON_INTERACTIVE" == "true" ]]; then
2345
- bash "$repo_sync_script" enable >/dev/null 2>&1 || true
2346
- print_info "Repo sync enabled (daily). Disable: aidevops repo-sync disable"
2347
- else
2348
- echo ""
2349
- echo "Repo sync keeps your local git repos up to date by running"
2350
- echo "git pull --ff-only daily on clean repos on their default branch."
2351
- echo ""
2352
- setup_prompt enable_repo_sync "Enable daily repo sync? [Y/n]: " "Y"
2353
- if [[ "$enable_repo_sync" =~ ^[Yy]?$ || -z "$enable_repo_sync" ]]; then
2354
- bash "$repo_sync_script" enable
2355
- else
2356
- print_info "Skipped. Enable later: aidevops repo-sync enable"
2357
- fi
2358
- fi
447
+ "true"
2359
448
  fi
2360
449
  return 0
2361
450
  }
2362
451
 
2363
- # Setup r914 repo-aidevops-health scheduler if not already installed.
2364
- # Daily drift keeper for repos.json: bumps stale .aidevops.json versions
2365
- # and surfaces missing-folder / no-init drift for human triage.
2366
- # Respects config: aidevops config set orchestration.repo_aidevops_health false
2367
- setup_repo_aidevops_health() {
2368
- local repo_health_script="$HOME/.aidevops/agents/scripts/repo-aidevops-health-helper.sh"
2369
- if ! [[ -x "$repo_health_script" ]] || ! is_feature_enabled repo_aidevops_health 2>/dev/null; then
452
+ # Setup screen time snapshot captures daily screen time for contributor stats.
453
+ # Accumulates data in screen-time.jsonl (macOS Knowledge DB retains only ~28 days).
454
+ # Always installed when the script exists; no consent needed (data collection only).
455
+ # macOS: launchd plist (every 6h, RunAtLoad=true) | Linux: systemd timer or cron (every 6h)
456
+ setup_screen_time_snapshot() {
457
+ local st_script="$HOME/.aidevops/agents/scripts/screen-time-helper.sh"
458
+ local st_label="sh.aidevops.screen-time-snapshot"
459
+ local st_systemd="aidevops-screen-time-snapshot"
460
+ local st_log="$HOME/.aidevops/.agent-workspace/logs/screen-time-snapshot.log"
461
+ if [[ ! -x "$st_script" ]]; then
2370
462
  return 0
2371
463
  fi
2372
464
 
2373
- local _repo_health_installed=false
2374
- if _launchd_has_agent "sh.aidevops.repo-aidevops-health"; then
2375
- _repo_health_installed=true
2376
- elif crontab -l 2>/dev/null | grep -qF "aidevops-repo-aidevops-health"; then
2377
- _repo_health_installed=true
2378
- elif command -v systemctl >/dev/null 2>&1 &&
2379
- systemctl --user is-enabled "aidevops-repo-aidevops-health.timer" >/dev/null 2>&1; then
2380
- _repo_health_installed=true
2381
- fi
2382
- if [[ "$_repo_health_installed" == "false" ]]; then
2383
- if [[ "$NON_INTERACTIVE" == "true" ]]; then
2384
- bash "$repo_health_script" enable >/dev/null 2>&1 || true
2385
- print_info "r914 repo-aidevops-health enabled (daily @03:30). Disable: aidevops repo-aidevops-health disable"
2386
- else
2387
- echo ""
2388
- echo "r914 keeps \`.aidevops.json\` versions current across all registered"
2389
- echo "repos and surfaces registry drift (missing folders, unregistered git"
2390
- echo "repos) for human triage. Runs daily at 03:30."
2391
- echo ""
2392
- setup_prompt enable_repo_health "Enable daily r914 repo-aidevops-health? [Y/n]: " "Y"
2393
- if [[ "$enable_repo_health" =~ ^[Yy]?$ || -z "$enable_repo_health" ]]; then
2394
- bash "$repo_health_script" enable
2395
- else
2396
- print_info "Skipped. Enable later: aidevops repo-aidevops-health enable"
2397
- fi
2398
- fi
2399
- fi
2400
- return 0
2401
- }
2402
-
2403
- # ============================================================================
2404
- # Peer productivity monitor (t2932)
2405
- # ============================================================================
2406
- #
2407
- # Adaptive cross-runner dispatch coordination: observes peer GitHub activity
2408
- # every 30 min and updates ~/.config/aidevops/dispatch-override.conf to
2409
- # `ignore` peers whose pulse is broken (claims issues but never PRs) and
2410
- # back to `honour` when they recover. Self-healing across the ecosystem —
2411
- # each runner observes peers independently, no central coordinator needed.
2412
- # Manual entries in dispatch-override.conf above the auto-managed marker
2413
- # always take precedence.
465
+ mkdir -p "$HOME/.aidevops/.agent-workspace/logs"
2414
466
 
2415
- # Install peer-productivity-monitor launchd plist (macOS).
2416
- # Args: $1=label $2=script $3=log_dir
2417
- _install_peer_productivity_monitor_launchd() {
2418
- local ppm_label="$1"
2419
- local ppm_script="$2"
2420
- local _ppm_log_dir="$3"
2421
- local ppm_plist="$HOME/Library/LaunchAgents/${ppm_label}.plist"
467
+ if [[ "$(uname -s)" == "Darwin" ]]; then
468
+ local st_plist="$HOME/Library/LaunchAgents/${st_label}.plist"
2422
469
 
2423
- local _xml_ppm_script _xml_ppm_home _xml_ppm_log_dir
2424
- _xml_ppm_script=$(_xml_escape "$ppm_script")
2425
- _xml_ppm_home=$(_xml_escape "$HOME")
2426
- _xml_ppm_log_dir=$(_xml_escape "$_ppm_log_dir")
470
+ # XML-escape paths for safe plist embedding
471
+ local _xml_st_script _xml_st_home
472
+ _xml_st_script=$(_xml_escape "$st_script")
473
+ _xml_st_home=$(_xml_escape "$HOME")
2427
474
 
2428
- local ppm_plist_content
2429
- ppm_plist_content=$(
2430
- cat <<PPM_PLIST
475
+ local st_plist_content
476
+ st_plist_content=$(
477
+ cat <<ST_PLIST
2431
478
  <?xml version="1.0" encoding="UTF-8"?>
2432
479
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2433
480
  <plist version="1.0">
2434
481
  <dict>
2435
482
  <key>Label</key>
2436
- <string>${ppm_label}</string>
483
+ <string>${st_label}</string>
2437
484
  <key>ProgramArguments</key>
2438
485
  <array>
2439
486
  <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
2440
- <string>${_xml_ppm_script}</string>
2441
- <string>observe</string>
487
+ <string>${_xml_st_script}</string>
488
+ <string>snapshot</string>
2442
489
  </array>
2443
490
  <key>StartInterval</key>
2444
- <integer>1800</integer>
491
+ <integer>21600</integer>
2445
492
  <key>StandardOutPath</key>
2446
- <string>${_xml_ppm_log_dir}/peer-productivity-launchd.log</string>
493
+ <string>${_xml_st_home}/.aidevops/.agent-workspace/logs/screen-time-snapshot.log</string>
2447
494
  <key>StandardErrorPath</key>
2448
- <string>${_xml_ppm_log_dir}/peer-productivity-launchd.log</string>
495
+ <string>${_xml_st_home}/.aidevops/.agent-workspace/logs/screen-time-snapshot.log</string>
2449
496
  <key>EnvironmentVariables</key>
2450
497
  <dict>
2451
498
  <key>PATH</key>
2452
499
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
2453
500
  <key>HOME</key>
2454
- <string>${_xml_ppm_home}</string>
501
+ <string>${_xml_st_home}</string>
2455
502
  </dict>
2456
503
  <key>RunAtLoad</key>
2457
504
  <true/>
@@ -2465,60 +512,28 @@ _install_peer_productivity_monitor_launchd() {
2465
512
  <integer>10</integer>
2466
513
  </dict>
2467
514
  </plist>
2468
- PPM_PLIST
2469
- )
2470
-
2471
- if _launchd_install_if_changed "$ppm_label" "$ppm_plist" "$ppm_plist_content"; then
2472
- print_info "Peer productivity monitor enabled (launchd, every 30 min)"
2473
- else
2474
- print_warning "Failed to load peer-productivity-monitor LaunchAgent"
2475
- fi
2476
- return 0
2477
- }
2478
-
2479
- # Install peer-productivity-monitor via systemd or cron (Linux).
2480
- # Args: $1=script path, $2=log dir
2481
- _install_peer_productivity_monitor_linux() {
2482
- local ppm_script="$1"
2483
- local _ppm_log_dir="$2"
2484
- local ppm_systemd="aidevops-peer-productivity-monitor"
2485
- _install_scheduler_linux \
2486
- "$ppm_systemd" \
2487
- "aidevops: peer-productivity-monitor" \
2488
- "*/30 * * * *" \
2489
- "\"${ppm_script}\" observe" \
2490
- "1800" \
2491
- "${_ppm_log_dir}/peer-productivity-launchd.log" \
2492
- "" \
2493
- "Peer productivity monitor enabled (every 30 min)" \
2494
- "Failed to install peer-productivity-monitor scheduler" \
2495
- "true" \
2496
- "true"
2497
- return 0
2498
- }
2499
-
2500
- # Setup peer-productivity-monitor (t2932) — observes peer GitHub activity
2501
- # every 30 min and updates ~/.config/aidevops/dispatch-override.conf so the
2502
- # local pulse competes with broken peers and collaborates with healthy ones.
2503
- # Manual entries in dispatch-override.conf above the auto-managed marker
2504
- # always take precedence.
2505
- setup_peer_productivity_monitor() {
2506
- local ppm_script="$HOME/.aidevops/agents/scripts/peer-productivity-monitor.sh"
2507
- local ppm_label="sh.aidevops.peer-productivity-monitor"
2508
- if ! [[ -x "$ppm_script" ]]; then
2509
- return 0
2510
- fi
2511
-
2512
- # Reuse contribution-watch's log-dir resolver (same logic, same config key).
2513
- local _ppm_log_dir
2514
- _ppm_log_dir=$(_resolve_cw_log_dir) || return 1
2515
- mkdir -p "$_ppm_log_dir"
515
+ ST_PLIST
516
+ )
2516
517
 
2517
- # Install/update scheduled runner
2518
- if [[ "$(uname -s)" == "Darwin" ]]; then
2519
- _install_peer_productivity_monitor_launchd "$ppm_label" "$ppm_script" "$_ppm_log_dir"
518
+ if _launchd_install_if_changed "$st_label" "$st_plist" "$st_plist_content"; then
519
+ print_info "Screen time snapshot enabled (launchd, every 6h, survives reboot)"
520
+ else
521
+ print_warning "Failed to load screen time snapshot LaunchAgent"
522
+ fi
2520
523
  else
2521
- _install_peer_productivity_monitor_linux "$ppm_script" "$_ppm_log_dir"
524
+ # Linux: systemd timer (every 6h) or cron fallback
525
+ _install_scheduler_linux \
526
+ "$st_systemd" \
527
+ "aidevops: screen-time-snapshot" \
528
+ "0 */6 * * *" \
529
+ "\"${st_script}\" snapshot" \
530
+ "21600" \
531
+ "$st_log" \
532
+ "" \
533
+ "Screen time snapshot enabled (every 6h)" \
534
+ "Failed to install screen time snapshot scheduler" \
535
+ "true" \
536
+ "true"
2522
537
  fi
2523
538
  return 0
2524
539
  }