aidevops 3.13.0 → 3.13.2

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